mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 08:08:32 +02:00
fix chat and payments and add spectator mode
This commit is contained in:
@@ -8,6 +8,8 @@ Wownerogue is a browser-based roguelike that synchronizes dungeon runs with Mone
|
||||
- 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
|
||||
- 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
|
||||
@@ -36,6 +38,34 @@ Every game uses cryptographic verification to prove 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.
|
||||
@@ -149,6 +179,165 @@ All gameplay functions work without wallet RPC access; only paid modes require i
|
||||
|
||||
The default Socket.IO client served from `/html` listens on port 3000. Configure a reverse proxy if you need TLS or path routing.
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Building for Production
|
||||
|
||||
There is no build step required—the Node.js server runs directly. However, ensure you configure the environment correctly:
|
||||
|
||||
```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`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Wownerogue Game Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wownerogue
|
||||
WorkingDirectory=/var/www/wownerogue/src
|
||||
EnvironmentFile=/var/www/wownerogue/src/.env
|
||||
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:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wownerogue
|
||||
sudo systemctl start wownerogue
|
||||
sudo systemctl status wownerogue
|
||||
```
|
||||
|
||||
### 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):
|
||||
@@ -297,29 +486,116 @@ Tests cover payment handlers, wallet RPC error propagation, security rules, move
|
||||
- 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
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js
|
||||
├── index.js # Entry point, server setup
|
||||
├── config/
|
||||
│ ├── environmentValidator.js
|
||||
│ └── paymentConfig.js
|
||||
├── db/
|
||||
│ ├── databaseManager.js
|
||||
│ ├── dbcalls.js
|
||||
│ └── user.js
|
||||
├── game/
|
||||
├── middleware/
|
||||
│ ├── 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
|
||||
html/
|
||||
└── index.html
|
||||
├── index.html
|
||||
├── admin.html
|
||||
└── js/
|
||||
├── core/
|
||||
├── display/
|
||||
├── input/
|
||||
├── network/
|
||||
└── ui/
|
||||
test/
|
||||
└── *.test.js
|
||||
```
|
||||
|
||||
Refer to `REFACTORING_SUMMARY.md` for module-by-module details and migration history.
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
**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
|
||||
|
||||
- Persist payment configuration updates for hot reloads.
|
||||
- Extend error normalization to remaining network modules.
|
||||
- Build a lightweight monitoring dashboard for live metrics.
|
||||
- ✅ 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
|
||||
|
||||
@@ -11,20 +11,28 @@
|
||||
- [x] **Difficulty Tuning**: Configurable presets (easy/normal/hard/casino) for house edge control ✅
|
||||
- [x] **Provably Fair**: SHA-256 commitment scheme for verifiable game generation ✅
|
||||
|
||||
## New Features (Just Added)
|
||||
- [x] **Chat History Persistence**: `ChatHistoryManager` stores messages in PostgreSQL with 30-day retention ✅
|
||||
- [x] **Chat History on Connect**: New users receive last 50 messages on connect via `chat_history` event ✅
|
||||
- [x] **Spectator System**: Full spectator mode with `SpectatorManager` for watching live games ✅
|
||||
- [x] **Active Games List**: Real-time game list with pagination, sorting, and auto-refresh ✅
|
||||
- [x] **Spectate UI**: "Watch Games" button, live game panel, and spectator controls ✅
|
||||
- [x] **Spectator Broadcasts**: Game updates broadcast to spectators via Socket.IO rooms ✅
|
||||
|
||||
## Improvements / Refactoring
|
||||
- [ ] **Error Handling**: Extend `normalizeError` usage to remaining network modules for consistent error reporting.
|
||||
- [ ] **Configuration Persistence**: Implement persistence for payment configuration updates to support hot reloads.
|
||||
- [ ] **Monitoring Dashboard**: Build a lightweight dashboard for live metrics (active games, queue length, rate limits).
|
||||
- [ ] **Frontend Cleanup**: Remove any remaining dead code or unused assets from the `html/` directory.
|
||||
- [ ] **Provably Fair UI**: Add frontend display of game hash commitment and post-game verification link.
|
||||
- [ ] **Verification Endpoint**: Add `/verify/:gameId` REST endpoint for public game verification.
|
||||
- [x] **Error Handling**: Extended `normalizeError` to queueHandler, queueManager, sessionManager ✅
|
||||
- [x] **Configuration Persistence**: Added `configPersistence.js` for hot-reloadable DB-backed config ✅
|
||||
- [x] **Monitoring Dashboard**: Created `/admin.html` with live metrics (games, queue, memory, wallet) ✅
|
||||
- [x] **Frontend Cleanup**: Removed rot.js (using minified), game1.mid (unused), .DS_Store, moved tiles.xcf ✅
|
||||
- [x] **Provably Fair UI**: Hash commitment shown at game start, seed revealed with verification link at game end ✅
|
||||
- [x] **Verification Endpoint**: `/verify/:gameId` HTML page + `/api/verify` JSON endpoint for public verification ✅
|
||||
|
||||
## Documentation
|
||||
- [x] **API Documentation**: README updated with accurate API surface including health check and Socket.IO events ✅
|
||||
- [x] **Session Persistence**: README documents session token behavior and localStorage requirements ✅
|
||||
- [x] **Difficulty System**: README documents difficulty presets and house edge tuning ✅
|
||||
- [x] **Provably Fair**: README documents cryptographic verification system ✅
|
||||
- [ ] **Deployment Guide**: Create a detailed deployment guide including reverse proxy setup (Nginx/Caddy).
|
||||
- [x] **Deployment Guide**: README includes production deployment with Nginx/Caddy configs and systemd ✅
|
||||
|
||||
## Pre-Beta Checklist
|
||||
- [x] All 38 tests pass (8 test suites) ✅
|
||||
@@ -33,6 +41,8 @@
|
||||
- [x] Payout eligibility shown per payment mode in UI ✅
|
||||
- [x] Session persistence tooltip and localStorage warning added ✅
|
||||
- [x] Stagenet/testnet warning banner for Monero non-mainnet ✅
|
||||
- [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.
|
||||
- [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked).
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wownerogue - Admin Dashboard</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #0a0a0a;
|
||||
color: #0f0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
h1 { color: #0ff; margin-bottom: 20px; text-align: center; }
|
||||
h2 { color: #ff0; margin: 15px 0 10px; font-size: 14px; }
|
||||
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; max-width: 1400px; margin: 0 auto; }
|
||||
.card {
|
||||
background: #001a00;
|
||||
border: 1px solid #0f0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
.card.warning { border-color: #ff0; }
|
||||
.card.error { border-color: #f00; }
|
||||
.metric { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #002200; }
|
||||
.metric:last-child { border-bottom: none; }
|
||||
.metric-label { color: #888; }
|
||||
.metric-value { color: #0f0; font-weight: bold; }
|
||||
.metric-value.warning { color: #ff0; }
|
||||
.metric-value.error { color: #f00; }
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; }
|
||||
.status-dot.green { background: #0f0; }
|
||||
.status-dot.yellow { background: #ff0; }
|
||||
.status-dot.red { background: #f00; }
|
||||
.refresh-bar { text-align: center; margin-bottom: 20px; color: #888; }
|
||||
.refresh-bar button {
|
||||
background: #0a5c0a;
|
||||
color: #ff0;
|
||||
border: 2px solid #0f0;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.refresh-bar button:hover { background: #0f0; color: #000; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #002200; }
|
||||
th { color: #0ff; }
|
||||
.log-entry { font-size: 11px; color: #888; padding: 3px 0; }
|
||||
.log-entry.error { color: #f00; }
|
||||
.log-entry.warn { color: #ff0; }
|
||||
#error-banner { display: none; background: #300; border: 1px solid #f00; color: #f88; padding: 10px; margin-bottom: 15px; border-radius: 5px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎮 Wownerogue Admin Dashboard</h1>
|
||||
|
||||
<div id="error-banner"></div>
|
||||
|
||||
<div class="refresh-bar">
|
||||
Last updated: <span id="lastUpdate">-</span>
|
||||
<button onclick="refreshAll()">🔄 Refresh Now</button>
|
||||
Auto-refresh: <select id="autoRefresh" onchange="setAutoRefresh()">
|
||||
<option value="0">Off</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="30000">30s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="dashboard">
|
||||
<!-- Server Status -->
|
||||
<div class="card" id="server-card">
|
||||
<h2>🖥️ Server Status</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Status</span>
|
||||
<span class="metric-value" id="serverStatus"><span class="status-dot"></span>Loading...</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Uptime</span>
|
||||
<span class="metric-value" id="uptime">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Node Version</span>
|
||||
<span class="metric-value" id="nodeVersion">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Environment</span>
|
||||
<span class="metric-value" id="nodeEnv">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="card" id="memory-card">
|
||||
<h2>💾 Memory Usage</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Heap Used</span>
|
||||
<span class="metric-value" id="heapUsed">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Heap Total</span>
|
||||
<span class="metric-value" id="heapTotal">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">RSS</span>
|
||||
<span class="metric-value" id="rss">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">External</span>
|
||||
<span class="metric-value" id="external">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Stats -->
|
||||
<div class="card" id="games-card">
|
||||
<h2>🎮 Game Statistics</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Active Games</span>
|
||||
<span class="metric-value" id="activeGames">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Queue Length</span>
|
||||
<span class="metric-value" id="queueLength">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Connected Players</span>
|
||||
<span class="metric-value" id="connectedPlayers">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Game Mode</span>
|
||||
<span class="metric-value" id="gameMode">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blockchain -->
|
||||
<div class="card" id="blockchain-card">
|
||||
<h2>⛏️ Blockchain</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Current Block</span>
|
||||
<span class="metric-value" id="currentBlock">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Block Source</span>
|
||||
<span class="metric-value" id="blockSource">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Network</span>
|
||||
<span class="metric-value" id="network">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Status -->
|
||||
<div class="card" id="wallet-card">
|
||||
<h2>💰 Wallet RPC</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Status</span>
|
||||
<span class="metric-value" id="walletStatus"><span class="status-dot"></span>-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Endpoint</span>
|
||||
<span class="metric-value" id="walletEndpoint">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Pending Payouts</span>
|
||||
<span class="metric-value" id="pendingPayouts">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limiting -->
|
||||
<div class="card" id="ratelimit-card">
|
||||
<h2>🛡️ Rate Limiting</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Tracked IPs</span>
|
||||
<span class="metric-value" id="trackedIPs">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Blocked Requests (1h)</span>
|
||||
<span class="metric-value" id="blockedRequests">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Max Games/Hour</span>
|
||||
<span class="metric-value" id="maxGamesPerHour">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Modes -->
|
||||
<div class="card" id="payment-card">
|
||||
<h2>💵 Payment Configuration</h2>
|
||||
<div id="paymentConfig">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events Log -->
|
||||
<div class="card" style="grid-column: span 2;">
|
||||
<h2>📋 System Status</h2>
|
||||
<div id="systemLog">
|
||||
<div class="log-entry">Dashboard loaded...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let refreshInterval = null;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function setStatusDot(elementId, status) {
|
||||
const el = document.getElementById(elementId);
|
||||
const dot = el.querySelector('.status-dot');
|
||||
if (dot) {
|
||||
dot.className = 'status-dot ' + status;
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(message, level = 'info') {
|
||||
const log = document.getElementById('systemLog');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + level;
|
||||
entry.textContent = new Date().toLocaleTimeString() + ' - ' + message;
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
if (log.children.length > 20) {
|
||||
log.removeChild(log.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('error-banner').style.display = 'none';
|
||||
|
||||
// Server status
|
||||
document.getElementById('serverStatus').innerHTML = '<span class="status-dot green"></span>Online';
|
||||
document.getElementById('uptime').textContent = formatUptime(data.uptime || 0);
|
||||
document.getElementById('nodeVersion').textContent = data.nodeVersion || '-';
|
||||
document.getElementById('nodeEnv').textContent = data.environment || '-';
|
||||
|
||||
// Memory
|
||||
if (data.memory) {
|
||||
document.getElementById('heapUsed').textContent = formatBytes(data.memory.heapUsed || 0);
|
||||
document.getElementById('heapTotal').textContent = formatBytes(data.memory.heapTotal || 0);
|
||||
document.getElementById('rss').textContent = formatBytes(data.memory.rss || 0);
|
||||
document.getElementById('external').textContent = formatBytes(data.memory.external || 0);
|
||||
|
||||
// Warn if heap > 80%
|
||||
const heapPercent = data.memory.heapUsed / data.memory.heapTotal;
|
||||
const heapEl = document.getElementById('heapUsed');
|
||||
heapEl.className = 'metric-value' + (heapPercent > 0.8 ? ' warning' : '');
|
||||
}
|
||||
|
||||
// Games
|
||||
if (data.games) {
|
||||
document.getElementById('activeGames').textContent = data.games.active || 0;
|
||||
document.getElementById('queueLength').textContent = data.games.queued || 0;
|
||||
document.getElementById('connectedPlayers').textContent = data.games.connected || 0;
|
||||
document.getElementById('gameMode').textContent = data.games.mode || '-';
|
||||
}
|
||||
|
||||
// Blockchain
|
||||
document.getElementById('currentBlock').textContent = data.blockHeight || '-';
|
||||
document.getElementById('blockSource').textContent = data.blockSource || '-';
|
||||
document.getElementById('network').textContent = data.network || 'mainnet';
|
||||
|
||||
// Wallet
|
||||
if (data.wallet) {
|
||||
const walletOk = data.wallet.status === 'connected' || data.wallet.available;
|
||||
document.getElementById('walletStatus').innerHTML =
|
||||
`<span class="status-dot ${walletOk ? 'green' : 'red'}"></span>${walletOk ? 'Connected' : 'Disconnected'}`;
|
||||
document.getElementById('walletEndpoint').textContent = data.wallet.endpoint ? '****' + data.wallet.endpoint.slice(-10) : '-';
|
||||
document.getElementById('pendingPayouts').textContent = data.wallet.pendingPayouts || 0;
|
||||
} else {
|
||||
document.getElementById('walletStatus').innerHTML = '<span class="status-dot yellow"></span>N/A (Free mode)';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (data.rateLimiter) {
|
||||
document.getElementById('trackedIPs').textContent = data.rateLimiter.trackedEntries || 0;
|
||||
document.getElementById('blockedRequests').textContent = data.rateLimiter.blockedCount || 0;
|
||||
}
|
||||
document.getElementById('maxGamesPerHour').textContent = data.limits?.maxGamesPerHour || 60;
|
||||
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('error-banner').textContent = 'Failed to connect to server: ' + err.message;
|
||||
document.getElementById('error-banner').style.display = 'block';
|
||||
document.getElementById('serverStatus').innerHTML = '<span class="status-dot red"></span>Offline';
|
||||
addLog('Health check failed: ' + err.message, 'error');
|
||||
}
|
||||
|
||||
// Also fetch game modes
|
||||
try {
|
||||
const modesRes = await fetch('/api/game-modes');
|
||||
const modes = await modesRes.json();
|
||||
|
||||
let html = '';
|
||||
for (const [key, mode] of Object.entries(modes)) {
|
||||
if (mode && typeof mode === 'object') {
|
||||
html += `<div class="metric"><span class="metric-label">${mode.name || key}</span>`;
|
||||
html += `<span class="metric-value ${mode.enabled ? '' : 'warning'}">${mode.enabled ? '✓ Enabled' : '✗ Disabled'}`;
|
||||
if (mode.cost) html += ` (${mode.cost})`;
|
||||
html += `</span></div>`;
|
||||
}
|
||||
}
|
||||
document.getElementById('paymentConfig').innerHTML = html || '<em>Free mode</em>';
|
||||
} catch (e) {
|
||||
document.getElementById('paymentConfig').innerHTML = '<em>Unable to load</em>';
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoRefresh() {
|
||||
const interval = parseInt(document.getElementById('autoRefresh').value);
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
if (interval > 0) {
|
||||
refreshInterval = setInterval(refreshAll, interval);
|
||||
addLog(`Auto-refresh set to ${interval/1000}s`);
|
||||
} else {
|
||||
addLog('Auto-refresh disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
refreshAll();
|
||||
setAutoRefresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
+5
-1
@@ -75,6 +75,9 @@
|
||||
<button id="startButton" style="background: #0a5c0a; color: #ff0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; display: none;">
|
||||
🎮 START GAME
|
||||
</button>
|
||||
<button id="watchGamesButton" style="background: #053655; color: #0af; border: 2px solid #0af; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
👁️ WATCH GAMES
|
||||
</button>
|
||||
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
@@ -219,8 +222,9 @@
|
||||
// Initialize SocketHandlers (CRITICAL for receiving game_start events!)
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
console.log("Initializing SocketHandlers...");
|
||||
SocketHandlers.init();
|
||||
// IMPORTANT: AudioAlerts patches SocketHandlers methods, so must init BEFORE socket bindings
|
||||
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.init(); }
|
||||
SocketHandlers.init();
|
||||
} else {
|
||||
console.error("SocketHandlers object not found!");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ var Game = {
|
||||
_unconfirmedPayment: false,
|
||||
_unconfirmedPaymentInfo: null,
|
||||
_awaitingPayment: false,
|
||||
_isSpectating: false, // True when viewing someone else's game
|
||||
_gameActive: false, // True when game is running (playing or spectating)
|
||||
|
||||
init: function() {
|
||||
// Update screen dimensions from options
|
||||
|
||||
@@ -66,8 +66,23 @@ const InputHandler = {
|
||||
setupKeyboardControls: function() {
|
||||
const self = this; // Store reference to InputHandler for use in event handler
|
||||
$(document).on('keydown', function(e) {
|
||||
// Handle ESC to leave spectate mode
|
||||
if (e.key === 'Escape' && typeof SocketHandlers !== 'undefined' && SocketHandlers._spectatorMode) {
|
||||
e.preventDefault();
|
||||
if (window.socket) {
|
||||
socket.emit('leave_spectate');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.activeElement === $('#game-display')[0]) {
|
||||
if (Game && Game._gameActive) {
|
||||
// Block movement input if spectating
|
||||
if (typeof Game !== 'undefined' && Game._isSpectating) {
|
||||
// In spectator mode - ignore movement keys
|
||||
return;
|
||||
}
|
||||
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
let moved = false;
|
||||
@@ -160,6 +175,22 @@ const InputHandler = {
|
||||
UI.scrollChat();
|
||||
});
|
||||
|
||||
// Watch games button - toggle the games list panel
|
||||
$('#watchGamesButton').click(function(e) {
|
||||
const $panel = $('#gamesListPanel');
|
||||
if ($panel.length && $panel.is(':visible')) {
|
||||
$panel.hide();
|
||||
} else {
|
||||
// Request fresh game list and show panel
|
||||
if (window.socket) {
|
||||
socket.emit('get_active_games', { page: 1, pageSize: 20 });
|
||||
}
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
SocketHandlers._showGamesPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handling for the animation toggle button
|
||||
$('#animationToggleButton').click(function(e) {
|
||||
|
||||
|
||||
-7228
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,46 @@ const SocketHandlers = {
|
||||
|
||||
this.registerEventHandlers();
|
||||
this._initialized = true;
|
||||
|
||||
// Delegated handlers for proof copy buttons
|
||||
$(document).on('click', '.copy-hash-btn', function() {
|
||||
const hash = $(this).data('hash');
|
||||
if (hash) {
|
||||
SocketHandlers._copyToClipboard(hash, $(this));
|
||||
}
|
||||
});
|
||||
$(document).on('click', '.copy-seed-btn', function() {
|
||||
const seed = $(this).data('seed');
|
||||
if (seed) {
|
||||
SocketHandlers._copyToClipboard(seed, $(this));
|
||||
}
|
||||
});
|
||||
|
||||
// If the low-level socket connected before handlers were registered, emulate onConnect.
|
||||
if (window.socket && window.socket.connected) {
|
||||
this.onConnect();
|
||||
}
|
||||
},
|
||||
|
||||
_copyToClipboard: function(text, $btn) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
const origText = $btn.text();
|
||||
$btn.text('Copied!');
|
||||
setTimeout(function() { $btn.text(origText); }, 1500);
|
||||
});
|
||||
} else {
|
||||
// Fallback
|
||||
const $temp = $('<input>');
|
||||
$('body').append($temp);
|
||||
$temp.val(text).select();
|
||||
document.execCommand('copy');
|
||||
$temp.remove();
|
||||
const origText = $btn.text();
|
||||
$btn.text('Copied!');
|
||||
setTimeout(function() { $btn.text(origText); }, 1500);
|
||||
}
|
||||
},
|
||||
|
||||
registerEventHandlers: function() {
|
||||
// Connection handlers
|
||||
@@ -39,6 +73,7 @@ const SocketHandlers = {
|
||||
socket.on('message', this.onMessage);
|
||||
socket.on('status_update', this.onStatusUpdate);
|
||||
socket.on('chat_broadcast', this.onChatBroadcast);
|
||||
socket.on('chat_history', this.onChatHistory);
|
||||
|
||||
// Game state handlers
|
||||
socket.on('waiting_status', this.onWaitingStatus);
|
||||
@@ -61,6 +96,12 @@ const SocketHandlers = {
|
||||
|
||||
// Block height handler
|
||||
socket.on('blockheight', this.onBlockHeight);
|
||||
|
||||
// Spectator handlers
|
||||
socket.on('active_games', this.onActiveGames);
|
||||
socket.on('spectate_start', this.onSpectateStart);
|
||||
socket.on('spectator_update', this.onSpectatorUpdate);
|
||||
socket.on('spectate_ended', this.onSpectateEnded);
|
||||
},
|
||||
|
||||
onConnect: function() {
|
||||
@@ -176,6 +217,29 @@ const SocketHandlers = {
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
onChatHistory: function(data) {
|
||||
if (!data || !data.messages || !Array.isArray(data.messages)) return;
|
||||
|
||||
// Add a separator for history messages
|
||||
if (data.messages.length > 0) {
|
||||
$('#messages').append($('<li class="chat-history-header" style="color: #666; font-style: italic; border-bottom: 1px solid #333; margin-bottom: 5px; padding-bottom: 5px;">').text('--- Recent Chat History ---'));
|
||||
}
|
||||
|
||||
// Add each historical message
|
||||
data.messages.forEach(function(msg) {
|
||||
const msgElement = $('<li style="color: #888;">');
|
||||
const username = msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
msgElement.html('<strong>' + username + ':</strong> ' + msg.message);
|
||||
$('#messages').append(msgElement);
|
||||
});
|
||||
|
||||
if (data.messages.length > 0) {
|
||||
$('#messages').append($('<li class="chat-history-footer" style="color: #666; font-style: italic; border-top: 1px solid #333; margin-top: 5px; padding-top: 5px;">').text('--- End of History ---'));
|
||||
}
|
||||
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
onWaitingStatus: function(data) {
|
||||
if (data.status === 'waiting') {
|
||||
$('#messages').append($('<li style="color:#ff0;">').text(data.message));
|
||||
@@ -213,6 +277,21 @@ const SocketHandlers = {
|
||||
onGameStart: function(data) {
|
||||
$('#messages').append($('<li class="game-start">').text("Starting game..."));
|
||||
|
||||
// Display provably fair commitment if present
|
||||
if (data && data.proof && data.proof.commitment) {
|
||||
const shortHash = data.proof.commitment.substring(0, 16) + '...';
|
||||
const $proofMsg = $('<li class="proof-commitment" style="color:#0af; font-size:11px;">').html(
|
||||
'🔐 <strong>Provably Fair:</strong> Game hash commitment: <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
|
||||
data.proof.commitment + '">' + shortHash + '</code> ' +
|
||||
'<button class="copy-hash-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-hash="' +
|
||||
data.proof.commitment + '">Copy</button>'
|
||||
);
|
||||
$('#messages').append($proofMsg);
|
||||
|
||||
// Store for later verification display
|
||||
SocketHandlers._currentGameProof = data.proof;
|
||||
}
|
||||
|
||||
if (typeof Game !== 'undefined' && Game.stopWaitingScreen) {
|
||||
Game.stopWaitingScreen();
|
||||
}
|
||||
@@ -242,9 +321,7 @@ const SocketHandlers = {
|
||||
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('In Game', '#0f0');
|
||||
if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) {
|
||||
try { AudioAlerts.playFile('game_start'); } catch(_) {}
|
||||
}
|
||||
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
|
||||
},
|
||||
|
||||
onGameUpdate: function(data) {
|
||||
@@ -265,6 +342,28 @@ const SocketHandlers = {
|
||||
|
||||
onGameOver: function(data) {
|
||||
$('#messages').append($('<li class="game-over">').text("Game Over: " + data.message));
|
||||
|
||||
// Display provably fair verification info if present
|
||||
if (data && data.proof && data.proof.seed) {
|
||||
const shortSeed = data.proof.seed.substring(0, 16) + '...';
|
||||
const shortCommitment = data.proof.commitment.substring(0, 16) + '...';
|
||||
const verifyUrl = data.proof.verificationUrl || ('/verify/' + data.proof.gameId);
|
||||
|
||||
const $proofReveal = $('<li class="proof-reveal" style="color:#4ade80; font-size:11px; margin-top:5px; padding:8px; background:rgba(0,50,0,0.5); border-radius:4px;">').html(
|
||||
'🔓 <strong>Game Verified:</strong><br>' +
|
||||
'<span style="color:#888;">Seed:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
|
||||
data.proof.seed + '">' + shortSeed + '</code> ' +
|
||||
'<button class="copy-seed-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-seed="' +
|
||||
data.proof.seed + '">Copy</button><br>' +
|
||||
'<span style="color:#888;">Hash:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;">' + shortCommitment + '</code><br>' +
|
||||
'<a href="' + verifyUrl + '" target="_blank" style="color:#0af;">🔗 Verify this game</a>'
|
||||
);
|
||||
$('#messages').append($proofReveal);
|
||||
|
||||
// Clear stored proof
|
||||
SocketHandlers._currentGameProof = null;
|
||||
}
|
||||
|
||||
if (typeof Game !== 'undefined') {
|
||||
if (Game.endGame) {
|
||||
Game.endGame(data);
|
||||
@@ -499,9 +598,7 @@ const SocketHandlers = {
|
||||
));
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('Confirmed', '#0f0');
|
||||
if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) {
|
||||
try { AudioAlerts.playFile('payment_confirmed'); } catch(_) {}
|
||||
}
|
||||
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
|
||||
},
|
||||
|
||||
_mempoolShownForPayment: new Set(),
|
||||
@@ -522,10 +619,7 @@ const SocketHandlers = {
|
||||
));
|
||||
UI.scrollChat();
|
||||
SocketHandlers._setBannerStatus('Mempool', '#0af');
|
||||
// Fallback audio trigger if AudioAlerts patched earlier failed to wrap or user enabled after patch
|
||||
if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) {
|
||||
try { AudioAlerts.playFile('payment_detected'); } catch(_) {}
|
||||
}
|
||||
// Sound handled by AudioAlerts._patchSocketHandlers() - don't duplicate
|
||||
},
|
||||
|
||||
// Internal: periodic cleanup of client-side payment marker sets (invoked opportunistically)
|
||||
@@ -556,11 +650,275 @@ const SocketHandlers = {
|
||||
if (typeof UI !== 'undefined' && UI.updateBlockHeight) {
|
||||
UI.updateBlockHeight(data.blockHeight);
|
||||
}
|
||||
// Sync to ScreenManager for welcome screen display
|
||||
if (typeof ScreenManager !== 'undefined' && data.blockHeight) {
|
||||
ScreenManager._currentBlockHeight = data.blockHeight;
|
||||
}
|
||||
// Fallback: if status banner still says Connecting..., upgrade it
|
||||
const current = $('#statusValue').text();
|
||||
if (current === 'Connecting...') {
|
||||
SocketHandlers._setBannerStatus('Ready', '#0f0');
|
||||
}
|
||||
},
|
||||
|
||||
// ====== SPECTATOR HANDLERS ======
|
||||
|
||||
_spectatorMode: false,
|
||||
_spectatingGameId: null,
|
||||
_activeGames: [],
|
||||
|
||||
onActiveGames: function(data) {
|
||||
if (!data || !data.games) return;
|
||||
SocketHandlers._activeGames = data.games;
|
||||
SocketHandlers._updateGamesListPanel(data);
|
||||
},
|
||||
|
||||
onSpectateStart: function(data) {
|
||||
if (!data) return;
|
||||
SocketHandlers._spectatorMode = true;
|
||||
SocketHandlers._spectatingGameId = data.gameId;
|
||||
|
||||
// Hide games panel
|
||||
SocketHandlers._hideGamesPanel();
|
||||
|
||||
// Show spectator controls
|
||||
SocketHandlers._showSpectatorControls(data.playerId);
|
||||
|
||||
// Start the game display in spectator mode
|
||||
if (typeof Game !== 'undefined' && data.initialState) {
|
||||
const state = data.initialState;
|
||||
try {
|
||||
// Use a modified startGame that marks as spectator
|
||||
Game._isSpectating = true;
|
||||
Game.startGame(
|
||||
state.player,
|
||||
state.map,
|
||||
state.monster,
|
||||
state.items || {},
|
||||
state.visibleTiles,
|
||||
state.lighting,
|
||||
state.torches
|
||||
);
|
||||
|
||||
$('#messages').append($('<li class="spectate-start" style="color:#0af;">').text(
|
||||
'👁️ Now spectating player ' + data.playerId + '. Press ESC or click "Leave" to exit.'
|
||||
));
|
||||
UI.scrollChat();
|
||||
} catch (err) {
|
||||
console.error('Failed to start spectator view:', err);
|
||||
}
|
||||
}
|
||||
|
||||
SocketHandlers._setBannerStatus('Spectating', '#0af');
|
||||
},
|
||||
|
||||
onSpectatorUpdate: function(data) {
|
||||
if (!data || !SocketHandlers._spectatorMode) return;
|
||||
|
||||
// Update the game display with new state
|
||||
if (typeof Game !== 'undefined' && typeof Game.updateGameState === 'function') {
|
||||
Game.updateGameState(data.gameState);
|
||||
}
|
||||
},
|
||||
|
||||
onSpectateEnded: function(data) {
|
||||
SocketHandlers._spectatorMode = false;
|
||||
SocketHandlers._spectatingGameId = null;
|
||||
|
||||
if (typeof Game !== 'undefined') {
|
||||
Game._isSpectating = false;
|
||||
}
|
||||
|
||||
// Hide spectator controls
|
||||
SocketHandlers._hideSpectatorControls();
|
||||
|
||||
// Show end message
|
||||
const reason = data?.reason || 'unknown';
|
||||
const gameOverData = data?.gameOverData;
|
||||
|
||||
let message = '👁️ Spectating ended';
|
||||
if (reason === 'game_over' && gameOverData) {
|
||||
message = '👁️ Game ended: ' + (gameOverData.status === 'won' ? '🏆 Player escaped!' : '💀 Player caught!');
|
||||
} else if (reason === 'user_left') {
|
||||
message = '👁️ Left spectator mode';
|
||||
}
|
||||
|
||||
$('#messages').append($('<li class="spectate-end" style="color:#0af;">').text(message));
|
||||
UI.scrollChat();
|
||||
|
||||
// Return to welcome screen
|
||||
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
|
||||
// Show games panel again
|
||||
SocketHandlers._showGamesPanel();
|
||||
|
||||
SocketHandlers._setBannerStatus('Ready', '#0f0');
|
||||
},
|
||||
|
||||
_updateGamesListPanel: function(data) {
|
||||
let $panel = $('#gamesListPanel');
|
||||
|
||||
// Create panel if it doesn't exist
|
||||
if (!$panel.length) {
|
||||
$panel = $(`
|
||||
<div id="gamesListPanel" style="
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 280px;
|
||||
max-height: 400px;
|
||||
background: rgba(0, 20, 0, 0.95);
|
||||
border: 2px solid #0f0;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 1500;
|
||||
overflow-y: auto;
|
||||
">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #0f0; padding-bottom: 5px; margin-bottom: 10px;">
|
||||
<strong>👁️ Live Games</strong>
|
||||
<button id="gamesListClose" style="background: none; border: 1px solid #0f0; color: #0f0; cursor: pointer; padding: 2px 6px;">×</button>
|
||||
</div>
|
||||
<div id="gamesListContent"></div>
|
||||
<div id="gamesListPagination" style="border-top: 1px solid #0f0; padding-top: 5px; margin-top: 10px; text-align: center;"></div>
|
||||
</div>
|
||||
`);
|
||||
$('body').append($panel);
|
||||
|
||||
// Close button handler
|
||||
$panel.on('click', '#gamesListClose', function() {
|
||||
$panel.hide();
|
||||
});
|
||||
|
||||
// Spectate button handler (delegated)
|
||||
$panel.on('click', '.spectate-btn', function() {
|
||||
const gameId = $(this).data('gameid');
|
||||
if (gameId && window.socket) {
|
||||
socket.emit('spectate_game', { gameId: gameId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const $content = $('#gamesListContent');
|
||||
$content.empty();
|
||||
|
||||
if (!data.games || data.games.length === 0) {
|
||||
$content.html('<div style="color: #888; text-align: center; padding: 20px;">No active games</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Render each game
|
||||
data.games.forEach(function(game) {
|
||||
const duration = game.durationSeconds || 0;
|
||||
const mins = Math.floor(duration / 60);
|
||||
const secs = duration % 60;
|
||||
const timeStr = mins > 0 ? mins + 'm ' + secs + 's' : secs + 's';
|
||||
|
||||
const $gameItem = $(`
|
||||
<div class="game-list-item" style="
|
||||
padding: 8px;
|
||||
margin-bottom: 5px;
|
||||
background: rgba(0, 40, 0, 0.5);
|
||||
border: 1px solid #050;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
" data-gameid="${game.gameId}">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #0f0;">Player ${game.playerId}</span>
|
||||
<span style="color: #888; font-size: 10px;">${timeStr}</span>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #888; margin-top: 3px;">
|
||||
${game.moveCount} moves |
|
||||
${game.hasTreasure ? '💎' : '⬜'} |
|
||||
👁️ ${game.spectatorCount}
|
||||
</div>
|
||||
<button class="spectate-btn" data-gameid="${game.gameId}" style="
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
background: #050;
|
||||
border: 1px solid #0f0;
|
||||
color: #0f0;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
">👁️ Watch Game</button>
|
||||
</div>
|
||||
`);
|
||||
$content.append($gameItem);
|
||||
});
|
||||
|
||||
// Pagination info
|
||||
const pag = data.pagination;
|
||||
if (pag && pag.totalGames > pag.pageSize) {
|
||||
$('#gamesListPagination').html(
|
||||
'Page ' + pag.page + '/' + pag.totalPages +
|
||||
' (' + pag.totalGames + ' games)'
|
||||
);
|
||||
} else {
|
||||
$('#gamesListPagination').html(pag ? pag.totalGames + ' game(s)' : '');
|
||||
}
|
||||
|
||||
$panel.show();
|
||||
},
|
||||
|
||||
_showGamesPanel: function() {
|
||||
$('#gamesListPanel').show();
|
||||
},
|
||||
|
||||
_hideGamesPanel: function() {
|
||||
$('#gamesListPanel').hide();
|
||||
},
|
||||
|
||||
_showSpectatorControls: function(playerId) {
|
||||
let $controls = $('#spectatorControls');
|
||||
|
||||
if (!$controls.length) {
|
||||
$controls = $(`
|
||||
<div id="spectatorControls" style="
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 20, 60, 0.95);
|
||||
border: 2px solid #0af;
|
||||
border-radius: 5px;
|
||||
padding: 10px 15px;
|
||||
color: #0af;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 1500;
|
||||
">
|
||||
<div style="margin-bottom: 8px;">
|
||||
👁️ <strong>Spectating:</strong> <span id="spectatingPlayer">---</span>
|
||||
</div>
|
||||
<button id="leaveSpectate" style="
|
||||
background: #500;
|
||||
border: 1px solid #f55;
|
||||
color: #f55;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
">Leave Spectate</button>
|
||||
</div>
|
||||
`);
|
||||
$('body').append($controls);
|
||||
|
||||
$controls.on('click', '#leaveSpectate', function() {
|
||||
if (window.socket) {
|
||||
socket.emit('leave_spectate');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#spectatingPlayer').text(playerId);
|
||||
$controls.show();
|
||||
},
|
||||
|
||||
_hideSpectatorControls: function() {
|
||||
$('#spectatorControls').hide();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Configuration Persistence
|
||||
*
|
||||
* Provides a mechanism to persist and reload payment configuration changes
|
||||
* without requiring a full server restart. Configuration is stored in the
|
||||
* database and merged with environment variables on load.
|
||||
*
|
||||
* Priority order (highest to lowest):
|
||||
* 1. Database overrides (hot-reloadable)
|
||||
* 2. Environment variables (.env)
|
||||
* 3. Default values
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
// Allowed configuration keys that can be persisted
|
||||
const ALLOWED_KEYS = new Set([
|
||||
// Direct mode
|
||||
'DIRECT_GAME_PRICE',
|
||||
'DIRECT_PAYOUT_ESCAPE',
|
||||
'DIRECT_PAYOUT_TREASURE',
|
||||
'DIRECT_PAYOUTS_ENABLED',
|
||||
|
||||
// Credits mode
|
||||
'CREDITS_PER_GAME',
|
||||
'CREDITS_PACKAGES',
|
||||
'CREDITS_PAYOUT_BASE',
|
||||
'CREDITS_PAYOUT_ESCAPE',
|
||||
'CREDITS_PAYOUT_TREASURE',
|
||||
'CREDITS_PAYOUTS_ENABLED',
|
||||
|
||||
// Limits
|
||||
'MAX_GAMES_PER_HOUR',
|
||||
'MAX_PAYOUTS_PER_DAY',
|
||||
'GAME_COOLDOWN_SECONDS',
|
||||
'PAYOUT_MIN_AMOUNT',
|
||||
'PAYOUT_MAX_PER_GAME',
|
||||
|
||||
// Difficulty
|
||||
'DIFFICULTY_PRESET'
|
||||
]);
|
||||
|
||||
class ConfigPersistence {
|
||||
constructor({ db, debugManager }) {
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this._cache = new Map();
|
||||
this._initialized = false;
|
||||
this._listeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the persistence layer, creating table if needed
|
||||
*/
|
||||
async initialize() {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
// Create config table if it doesn't exist
|
||||
await this.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS config_overrides (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_by VARCHAR(100) DEFAULT 'system'
|
||||
)
|
||||
`);
|
||||
|
||||
// Load existing overrides into cache
|
||||
await this._loadFromDatabase();
|
||||
|
||||
this._initialized = true;
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Initialized with ${this._cache.size} overrides`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to initialize config persistence');
|
||||
console.error('[ConfigPersistence] Initialization error:', normalized.message);
|
||||
// Don't throw - system should work without persistence
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all overrides from database into cache
|
||||
*/
|
||||
async _loadFromDatabase() {
|
||||
try {
|
||||
const result = await this.db.query('SELECT key, value FROM config_overrides');
|
||||
this._cache.clear();
|
||||
for (const row of result.rows) {
|
||||
if (ALLOWED_KEYS.has(row.key)) {
|
||||
this._cache.set(row.key, row.value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to load config from database');
|
||||
console.error('[ConfigPersistence] Load error:', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value with priority: DB override > env > default
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} defaultValue - Default value if not found
|
||||
* @returns {string|null} Configuration value
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
// Check database override first
|
||||
if (this._cache.has(key)) {
|
||||
return this._cache.get(key);
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
if (process.env[key] !== undefined) {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a numeric configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {number} defaultValue - Default value
|
||||
* @returns {number} Parsed numeric value
|
||||
*/
|
||||
getNumber(key, defaultValue = 0) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {boolean} defaultValue - Default value
|
||||
* @returns {boolean} Parsed boolean value
|
||||
*/
|
||||
getBoolean(key, defaultValue = false) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JSON configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} defaultValue - Default value
|
||||
* @returns {*} Parsed JSON value
|
||||
*/
|
||||
getJSON(key, defaultValue = null) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration override (persisted to database)
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} value - Value to set
|
||||
* @param {string} updatedBy - Who made the change
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
async set(key, value, updatedBy = 'admin') {
|
||||
if (!ALLOWED_KEYS.has(key)) {
|
||||
console.warn(`[ConfigPersistence] Attempted to set non-allowed key: ${key}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await this.db.query(`
|
||||
INSERT INTO config_overrides (key, value, updated_at, updated_by)
|
||||
VALUES ($1, $2, NOW(), $3)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = NOW(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
`, [key, stringValue, updatedBy]);
|
||||
|
||||
// Update cache
|
||||
this._cache.set(key, stringValue);
|
||||
|
||||
// Notify listeners
|
||||
this._notifyListeners(key, stringValue);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Set ${key} = ${stringValue.substring(0, 50)}... by ${updatedBy}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to set config value');
|
||||
console.error('[ConfigPersistence] Set error:', normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a configuration override (reverts to env/default)
|
||||
* @param {string} key - Configuration key
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
async remove(key) {
|
||||
try {
|
||||
await this.db.query('DELETE FROM config_overrides WHERE key = $1', [key]);
|
||||
this._cache.delete(key);
|
||||
this._notifyListeners(key, null);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Removed override for ${key}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to remove config value');
|
||||
console.error('[ConfigPersistence] Remove error:', normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current overrides
|
||||
* @returns {Object} Key-value pairs of all overrides
|
||||
*/
|
||||
getAllOverrides() {
|
||||
return Object.fromEntries(this._cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration from database
|
||||
* Call this to pick up external changes
|
||||
*/
|
||||
async reload() {
|
||||
await this._loadFromDatabase();
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Reloaded ${this._cache.size} overrides`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for configuration changes
|
||||
* @param {Function} callback - (key, value) => void
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
onConfigChange(callback) {
|
||||
this._listeners.add(callback);
|
||||
return () => this._listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a configuration change
|
||||
*/
|
||||
_notifyListeners(key, value) {
|
||||
for (const listener of this._listeners) {
|
||||
try {
|
||||
listener(key, value);
|
||||
} catch (e) {
|
||||
console.error('[ConfigPersistence] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration snapshot for display/API
|
||||
* @returns {Object} Current effective configuration
|
||||
*/
|
||||
getSnapshot() {
|
||||
const snapshot = {};
|
||||
for (const key of ALLOWED_KEYS) {
|
||||
snapshot[key] = {
|
||||
value: this.get(key),
|
||||
source: this._cache.has(key) ? 'database' : (process.env[key] ? 'environment' : 'default'),
|
||||
hasOverride: this._cache.has(key)
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigPersistence;
|
||||
@@ -37,6 +37,7 @@ const DIFFICULTY_PRESETS = {
|
||||
startDistanceFromPlayer: 0.9, // 90% of max distance (far from player)
|
||||
movesPerPlayerMove: 0.5, // Monster moves every other player move
|
||||
chaseAggressiveness: 0.6, // 60% chance to chase, 40% random
|
||||
visionRange: 6, // Short vision - easy to evade
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
@@ -60,6 +61,7 @@ const DIFFICULTY_PRESETS = {
|
||||
startDistanceFromPlayer: 0.6, // 60% of max distance
|
||||
movesPerPlayerMove: 1.0, // Monster moves every player move
|
||||
chaseAggressiveness: 0.8, // 80% chase, 20% random
|
||||
visionRange: 10, // Medium vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
@@ -83,6 +85,7 @@ const DIFFICULTY_PRESETS = {
|
||||
startDistanceFromPlayer: 0.4, // Closer to player
|
||||
movesPerPlayerMove: 1.0,
|
||||
chaseAggressiveness: 0.9, // Very aggressive
|
||||
visionRange: 14, // Good vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
@@ -106,6 +109,7 @@ const DIFFICULTY_PRESETS = {
|
||||
startDistanceFromPlayer: 0.35, // Close to player
|
||||
movesPerPlayerMove: 1.0,
|
||||
chaseAggressiveness: 0.95, // Nearly always chases
|
||||
visionRange: 18, // Excellent vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
|
||||
+3
-1
@@ -108,7 +108,9 @@ class Game {
|
||||
this.gameState = 'waiting';
|
||||
this.startBlock = null;
|
||||
this.player = new Player();
|
||||
this.monster = new Monster();
|
||||
this.monster = new Monster(0, 0, {
|
||||
visionRange: difficultyConfig.monster.visionRange || 12
|
||||
});
|
||||
this.monsterMoveAccumulator = 0; // For fractional monster moves
|
||||
this.fee = 0;
|
||||
|
||||
|
||||
+28
-3
@@ -6,12 +6,21 @@
|
||||
const Game = require('../game/game');
|
||||
|
||||
class GameManager {
|
||||
constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager }) {
|
||||
constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager, spectatorManager = null }) {
|
||||
this.activeGames = activeGames;
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.spectatorManager = spectatorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the spectator manager (allows late binding after initialization)
|
||||
* @param {SpectatorManager} spectatorManager
|
||||
*/
|
||||
setSpectatorManager(spectatorManager) {
|
||||
this.spectatorManager = spectatorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +97,9 @@ class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit game over event
|
||||
// Emit game over event with provably fair reveal
|
||||
const proofReveal = game.getProofReveal ? game.getProofReveal() : null;
|
||||
|
||||
this.io.to(socketId).emit('game_over', {
|
||||
status: status,
|
||||
reason: reason,
|
||||
@@ -97,9 +108,23 @@ class GameManager {
|
||||
moves,
|
||||
durationSeconds,
|
||||
payout: payoutInfo,
|
||||
treasure: game.player.hasTreasure || false
|
||||
treasure: game.player.hasTreasure || false,
|
||||
proof: proofReveal
|
||||
});
|
||||
|
||||
// Notify spectators that the game has ended
|
||||
if (this.spectatorManager) {
|
||||
this.spectatorManager.notifyGameEnded(game.id, {
|
||||
status,
|
||||
reason,
|
||||
message,
|
||||
score: finalScore,
|
||||
moves,
|
||||
durationSeconds,
|
||||
treasure: game.player.hasTreasure || false
|
||||
});
|
||||
}
|
||||
|
||||
// Persist completion details if DB available
|
||||
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
|
||||
|
||||
|
||||
+177
-26
@@ -1,12 +1,25 @@
|
||||
const ROT = require('./rot.js');
|
||||
|
||||
/**
|
||||
* Monster class - Manages monster state and AI
|
||||
* Monster class - Manages monster state and AI with improved pathfinding
|
||||
*
|
||||
* AI Behavior:
|
||||
* - Uses A* pathfinding when player is visible (line of sight)
|
||||
* - Patrols toward last known player position when player not visible
|
||||
* - Falls back to wandering when no target
|
||||
* - Vision limited to configurable range (not omniscient)
|
||||
*/
|
||||
class Monster {
|
||||
constructor(x = 0, y = 0) {
|
||||
constructor(x = 0, y = 0, options = {}) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
|
||||
// AI state
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this.visionRange = options.visionRange || 12; // tiles the monster can "see"
|
||||
this.pathCache = null;
|
||||
this.pathCacheTarget = null;
|
||||
}
|
||||
|
||||
// Move monster to new position
|
||||
@@ -19,38 +32,161 @@ class Monster {
|
||||
isAt(x, y) {
|
||||
return this.x === x && this.y === y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if monster has line of sight to a position
|
||||
* Uses Bresenham's line algorithm
|
||||
*/
|
||||
hasLineOfSight(targetX, targetY, dungeon, isPassable) {
|
||||
const dx = Math.abs(targetX - this.x);
|
||||
const dy = Math.abs(targetY - this.y);
|
||||
const sx = this.x < targetX ? 1 : -1;
|
||||
const sy = this.y < targetY ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
let x = this.x;
|
||||
let y = this.y;
|
||||
|
||||
while (x !== targetX || y !== targetY) {
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
|
||||
// Check if we hit the target
|
||||
if (x === targetX && y === targetY) return true;
|
||||
|
||||
// Check if blocked by wall
|
||||
if (!dungeon.map[y] || !isPassable(dungeon.map[y][x])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance to a point
|
||||
*/
|
||||
distanceTo(x, y) {
|
||||
return Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find path using A* pathfinding
|
||||
* Returns array of [x, y] positions or null if no path
|
||||
*/
|
||||
findPath(targetX, targetY, dungeon, isPassable) {
|
||||
// Use ROT.js A* pathfinding
|
||||
const passableCallback = (x, y) => {
|
||||
if (!dungeon.map[y]) return false;
|
||||
return isPassable(dungeon.map[y][x]);
|
||||
};
|
||||
|
||||
const path = [];
|
||||
const astar = new ROT.Path.AStar(targetX, targetY, passableCallback, { topology: 4 });
|
||||
|
||||
astar.compute(this.x, this.y, (x, y) => {
|
||||
path.push([x, y]);
|
||||
});
|
||||
|
||||
// Remove the starting position from path
|
||||
if (path.length > 0 && path[0][0] === this.x && path[0][1] === this.y) {
|
||||
path.shift();
|
||||
}
|
||||
|
||||
return path.length > 0 ? path : null;
|
||||
}
|
||||
|
||||
// Simple AI to move toward player (or randomly if player is null)
|
||||
/**
|
||||
* Improved AI to move toward player using A* pathfinding with limited vision
|
||||
*/
|
||||
moveTowardPlayer(player, dungeon) {
|
||||
if (!dungeon || !dungeon.map) return;
|
||||
|
||||
// Check if a tile is passable (floor)
|
||||
// Check if a tile is passable (floor, stairs, treasure)
|
||||
const isPassable = (tile) => {
|
||||
return tile === "'1" || tile === "'2" || tile === 0; // Support both new and legacy floor types
|
||||
return tile === "'1" || tile === "'2" || tile === 0 ||
|
||||
tile === '>' || tile === '$M'; // Can walk on stairs and treasure
|
||||
};
|
||||
|
||||
// If no player target, move randomly
|
||||
// If no player target, wander randomly
|
||||
if (!player) {
|
||||
const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
|
||||
const shuffledDirs = ROT.RNG.shuffle(dirs.slice());
|
||||
|
||||
for (const [dx, dy] of shuffledDirs) {
|
||||
const nx = this.x + dx;
|
||||
const ny = this.y + dy;
|
||||
if (dungeon.map[ny] && isPassable(dungeon.map[ny][nx])) {
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._wander(dungeon, isPassable);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = Math.sign(player.x - this.x);
|
||||
const dy = Math.sign(player.y - this.y);
|
||||
|
||||
// Try horizontal move first
|
||||
if (dx !== 0) {
|
||||
const distToPlayer = this.distanceTo(player.x, player.y);
|
||||
let canSeePlayer = false;
|
||||
|
||||
// Check if player is within vision range AND line of sight
|
||||
if (distToPlayer <= this.visionRange) {
|
||||
canSeePlayer = this.hasLineOfSight(player.x, player.y, dungeon, isPassable);
|
||||
}
|
||||
|
||||
if (canSeePlayer) {
|
||||
// Player visible - update last known position and use A* pathfinding
|
||||
this.lastKnownPlayerX = player.x;
|
||||
this.lastKnownPlayerY = player.y;
|
||||
|
||||
// Find path to player
|
||||
const path = this.findPath(player.x, player.y, dungeon, isPassable);
|
||||
if (path && path.length > 0) {
|
||||
// Move to next position in path
|
||||
const [nextX, nextY] = path[0];
|
||||
this.x = nextX;
|
||||
this.y = nextY;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: direct move toward player
|
||||
this._moveDirectToward(player.x, player.y, dungeon, isPassable);
|
||||
|
||||
} else if (this.lastKnownPlayerX !== null) {
|
||||
// Player not visible but we have last known position - patrol toward it
|
||||
const distToLastKnown = this.distanceTo(this.lastKnownPlayerX, this.lastKnownPlayerY);
|
||||
|
||||
if (distToLastKnown < 1) {
|
||||
// Reached last known position, clear it and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable);
|
||||
} else {
|
||||
// Path toward last known position
|
||||
const path = this.findPath(this.lastKnownPlayerX, this.lastKnownPlayerY, dungeon, isPassable);
|
||||
if (path && path.length > 0) {
|
||||
const [nextX, nextY] = path[0];
|
||||
this.x = nextX;
|
||||
this.y = nextY;
|
||||
} else {
|
||||
// Can't reach last known position, clear and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No player info - wander randomly
|
||||
this._wander(dungeon, isPassable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple direct movement toward a target (fallback)
|
||||
*/
|
||||
_moveDirectToward(targetX, targetY, dungeon, isPassable) {
|
||||
const dx = Math.sign(targetX - this.x);
|
||||
const dy = Math.sign(targetY - this.y);
|
||||
|
||||
// Try both directions, prioritize the one with more distance to cover
|
||||
const xDist = Math.abs(targetX - this.x);
|
||||
const yDist = Math.abs(targetY - this.y);
|
||||
|
||||
if (xDist >= yDist && dx !== 0) {
|
||||
// Try horizontal first
|
||||
const newX = this.x + dx;
|
||||
if (dungeon.map[this.y] && isPassable(dungeon.map[this.y][newX])) {
|
||||
this.x = newX;
|
||||
@@ -58,7 +194,6 @@ class Monster {
|
||||
}
|
||||
}
|
||||
|
||||
// Try vertical move
|
||||
if (dy !== 0) {
|
||||
const newY = this.y + dy;
|
||||
if (dungeon.map[newY] && isPassable(dungeon.map[newY][this.x])) {
|
||||
@@ -67,7 +202,23 @@ class Monster {
|
||||
}
|
||||
}
|
||||
|
||||
// Try random direction if direct path is blocked
|
||||
// Try horizontal if we haven't yet
|
||||
if (xDist < yDist && dx !== 0) {
|
||||
const newX = this.x + dx;
|
||||
if (dungeon.map[this.y] && isPassable(dungeon.map[this.y][newX])) {
|
||||
this.x = newX;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked in preferred directions, try diagonals (A* backup)
|
||||
this._wander(dungeon, isPassable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random wandering behavior
|
||||
*/
|
||||
_wander(dungeon, isPassable) {
|
||||
const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
|
||||
const shuffledDirs = ROT.RNG.shuffle(dirs.slice());
|
||||
|
||||
@@ -77,7 +228,7 @@ class Monster {
|
||||
if (dungeon.map[ny] && isPassable(dungeon.map[ny][nx])) {
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* Handles rate limiting and applying player movement to games.
|
||||
*/
|
||||
class MovementManager {
|
||||
constructor({ activeGames, io, debugManager, moveCooldown = 100, postMoveHook = null }) {
|
||||
constructor({ activeGames, io, debugManager, moveCooldown = 100, postMoveHook = null, spectatorManager = null }) {
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.moveCooldown = moveCooldown;
|
||||
this._lastMove = new Map(); // socketId -> timestamp
|
||||
this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null;
|
||||
this.spectatorManager = spectatorManager; // For broadcasting to spectators
|
||||
}
|
||||
|
||||
handleMove(socketId, moveData) {
|
||||
@@ -58,6 +59,11 @@ class MovementManager {
|
||||
}
|
||||
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Broadcast to spectators
|
||||
if (this.spectatorManager && game.id) {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
|
||||
// Handle special events from moveResult (escape / treasure)
|
||||
if (moveResult && moveResult.event) {
|
||||
@@ -80,6 +86,11 @@ class MovementManager {
|
||||
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
||||
}
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Broadcast to spectators
|
||||
if (this.spectatorManager && game.id) {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+187
-2
@@ -22,6 +22,7 @@ const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
|
||||
const BroadcastManager = require('./network/broadcastManager');
|
||||
const DebugManager = require('./debug/debugManager');
|
||||
const SocketHandlers = require('./network/socketHandlers');
|
||||
const { verifyGame, hashSeed } = require('./game/provablyFair');
|
||||
|
||||
// Initialize payment configuration
|
||||
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
||||
@@ -191,13 +192,58 @@ app.post('/api/user/:socketId/address', asyncHandler(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// Health check endpoint
|
||||
// Health check endpoint - enhanced for monitoring dashboard
|
||||
app.get('/health', (req, res) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
const health = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
|
||||
// Memory details
|
||||
memory: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
rss: memUsage.rss,
|
||||
external: memUsage.external || 0
|
||||
},
|
||||
|
||||
// Game statistics
|
||||
games: {
|
||||
active: activeGames.size,
|
||||
queued: socketHandlers?.queueManager?.getQueueLength?.() || 0,
|
||||
connected: io.sockets.sockets.size || 0,
|
||||
mode: gameModeManager.gameMode
|
||||
},
|
||||
|
||||
// Blockchain
|
||||
blockHeight: debugManager.getCurrentBlockHeight(),
|
||||
blockSource: process.env.BLOCK_SOURCE || 'daemon',
|
||||
network: process.env.MONERO_NETWORK || 'mainnet',
|
||||
|
||||
// Wallet status
|
||||
wallet: {
|
||||
available: gameModeManager.paymentsEnabled,
|
||||
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
|
||||
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
|
||||
pendingPayouts: 0 // Could track this in gameModeManager
|
||||
},
|
||||
|
||||
// Rate limiter stats (if available)
|
||||
rateLimiter: socketHandlers?.rateLimiter ? {
|
||||
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
|
||||
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
|
||||
} : null,
|
||||
|
||||
// Limits
|
||||
limits: {
|
||||
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
|
||||
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
|
||||
},
|
||||
|
||||
// Legacy fields for compatibility
|
||||
gameMode: gameModeManager.gameMode,
|
||||
paymentsEnabled: gameModeManager.paymentsEnabled,
|
||||
directModeEnabled: gameModeManager.directModeEnabled,
|
||||
@@ -270,6 +316,145 @@ app.get('/api/game-modes', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Provably Fair Verification Endpoint
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Verify a game's fairness by checking if the seed matches the commitment hash
|
||||
*
|
||||
* GET /verify/:gameId - Returns HTML verification page
|
||||
* GET /api/verify - Verify seed and commitment (query params: seed, commitment)
|
||||
*/
|
||||
app.get('/verify/:gameId', asyncHandler(async (req, res) => {
|
||||
const { gameId } = req.params;
|
||||
|
||||
// Try to look up the game in the database
|
||||
let gameRecord = null;
|
||||
try {
|
||||
const result = await databaseManager.query(
|
||||
'SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE dungeon_seed = $1',
|
||||
[gameId]
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
gameRecord = result.rows[0];
|
||||
}
|
||||
} catch (err) {
|
||||
// Database lookup failed, continue without game record
|
||||
}
|
||||
|
||||
// Return a simple HTML verification page
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Wownerogue - Game Verification</title>
|
||||
<style>
|
||||
body { background: #0a0a0a; color: #0f0; font-family: monospace; padding: 20px; max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #0ff; }
|
||||
.box { background: #001a00; border: 1px solid #0f0; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
input, button { font-family: monospace; padding: 8px; margin: 5px 0; }
|
||||
input { background: #001a00; color: #0f0; border: 1px solid #0f0; width: 100%; box-sizing: border-box; }
|
||||
button { background: #0a5c0a; color: #ff0; border: 2px solid #0f0; cursor: pointer; }
|
||||
button:hover { background: #0f0; color: #000; }
|
||||
.success { color: #4ade80; }
|
||||
.error { color: #f00; }
|
||||
#result { margin-top: 15px; padding: 10px; }
|
||||
code { background: #002200; padding: 2px 6px; border-radius: 3px; }
|
||||
.info { color: #888; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔐 Wownerogue Provably Fair Verification</h1>
|
||||
|
||||
<div class="box">
|
||||
<h3>How it works:</h3>
|
||||
<ol>
|
||||
<li>Before game start, you received a <strong>commitment hash</strong> (SHA-256)</li>
|
||||
<li>After the game, you received the <strong>seed</strong></li>
|
||||
<li>This page verifies: <code>SHA256(seed) === commitment</code></li>
|
||||
<li>The seed deterministically generated your dungeon layout</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Verify Game</h3>
|
||||
<label>Game Seed (revealed after game):</label>
|
||||
<input type="text" id="seed" placeholder="64 character hex string">
|
||||
|
||||
<label>Commitment Hash (shown before game):</label>
|
||||
<input type="text" id="commitment" placeholder="64 character hex string">
|
||||
|
||||
<button onclick="verify()">🔍 Verify</button>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
|
||||
${gameRecord ? `
|
||||
<div class="box">
|
||||
<h3>Game Record</h3>
|
||||
<p><strong>Game ID:</strong> ${gameId}</p>
|
||||
<p><strong>Status:</strong> ${gameRecord.status}</p>
|
||||
<p><strong>Treasure Found:</strong> ${gameRecord.treasure_found ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Moves:</strong> ${gameRecord.moves_made || 'N/A'}</p>
|
||||
<p><strong>Duration:</strong> ${gameRecord.duration_seconds ? gameRecord.duration_seconds + 's' : 'N/A'}</p>
|
||||
<p><strong>Created:</strong> ${gameRecord.created_at}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="box info">
|
||||
<p>For technical verification, you can also use the API:</p>
|
||||
<code>GET /api/verify?seed=YOUR_SEED&commitment=YOUR_COMMITMENT</code>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function verify() {
|
||||
const seed = document.getElementById('seed').value.trim();
|
||||
const commitment = document.getElementById('commitment').value.trim();
|
||||
const result = document.getElementById('result');
|
||||
|
||||
if (!seed || !commitment) {
|
||||
result.innerHTML = '<p class="error">Please enter both seed and commitment.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/verify?seed=' + encodeURIComponent(seed) + '&commitment=' + encodeURIComponent(commitment));
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
result.innerHTML = '<p class="success">✅ ' + data.message + '</p>' +
|
||||
'<p>Computed hash: <code>' + data.computedHash + '</code></p>';
|
||||
} else {
|
||||
result.innerHTML = '<p class="error">❌ ' + data.message + '</p>' +
|
||||
'<p>Expected: <code>' + data.expectedCommitment + '</code></p>' +
|
||||
'<p>Got: <code>' + data.computedHash + '</code></p>';
|
||||
}
|
||||
} catch (err) {
|
||||
result.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}));
|
||||
|
||||
// API endpoint for programmatic verification
|
||||
app.get('/api/verify', (req, res) => {
|
||||
const { seed, commitment } = req.query;
|
||||
|
||||
if (!seed || !commitment) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
error: 'Missing required parameters: seed and commitment'
|
||||
});
|
||||
}
|
||||
|
||||
const result = verifyGame(seed, commitment);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Initialize debug manager with new block callbacks
|
||||
debugManager.onNewBlockCallback((blockHeight) => {
|
||||
// Start games for waiting players when new block detected
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Chat History Migration
|
||||
-- Persistent storage for chat messages
|
||||
|
||||
-- Create chat_messages table
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
socket_id VARCHAR(255),
|
||||
username VARCHAR(50),
|
||||
message TEXT NOT NULL,
|
||||
message_type VARCHAR(20) DEFAULT 'chat', -- 'chat', 'system', 'event'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient retrieval of recent messages
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at DESC);
|
||||
|
||||
-- Optional: Index for user-specific history lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_socket_id ON chat_messages(socket_id);
|
||||
|
||||
-- Comment on table
|
||||
COMMENT ON TABLE chat_messages IS 'Stores chat message history for display to new users';
|
||||
COMMENT ON COLUMN chat_messages.message_type IS 'Type of message: chat (user), system (server), event (game events)';
|
||||
@@ -3,8 +3,10 @@
|
||||
* Handles chat messages, commands, and chat-related rate limiting
|
||||
*/
|
||||
|
||||
const ChatHistoryManager = require('./chatHistoryManager');
|
||||
|
||||
class ChatHandler {
|
||||
constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter }) {
|
||||
constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter, db }) {
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
@@ -14,6 +16,13 @@ class ChatHandler {
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
|
||||
// Initialize chat history manager for persistent message storage
|
||||
this.chatHistory = new ChatHistoryManager({
|
||||
db: db || (gameModeManager?.db),
|
||||
debugManager: debugManager,
|
||||
maxHistoryMessages: 50
|
||||
});
|
||||
|
||||
// Memory leak prevention - cleanup old timestamps
|
||||
this._chatLastSent = new Map();
|
||||
this.chatCleanupInterval = setInterval(() => this.cleanupChatTimestamps(), 300000); // 5 minutes
|
||||
@@ -21,6 +30,32 @@ class ChatHandler {
|
||||
this._awaitingAddressFor = new Set(); // Track users waiting for address confirmation
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the chat handler (must be called after construction)
|
||||
*/
|
||||
async initialize() {
|
||||
await this.chatHistory.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chat history to a newly connected user
|
||||
* @param {string} socketId - Socket ID of the new user
|
||||
* @param {number} messageCount - Number of messages to send (default 50)
|
||||
*/
|
||||
async sendChatHistoryToUser(socketId, messageCount = 50) {
|
||||
try {
|
||||
const messages = await this.chatHistory.getRecentMessages(messageCount);
|
||||
if (messages.length > 0) {
|
||||
this.io.to(socketId).emit('chat_history', { messages });
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📜 Sent ${messages.length} chat history messages to ${socketId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send chat history:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming chat messages with rate limiting
|
||||
*/
|
||||
@@ -69,10 +104,12 @@ class ChatHandler {
|
||||
/**
|
||||
* Get chat statistics
|
||||
*/
|
||||
getStats() {
|
||||
async getStats() {
|
||||
const historyStats = await this.chatHistory.getStats();
|
||||
return {
|
||||
chatTimestamps: this._chatLastSent.size,
|
||||
awaitingAddress: this._awaitingAddressFor.size
|
||||
awaitingAddress: this._awaitingAddressFor.size,
|
||||
chatHistory: historyStats
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +121,9 @@ class ChatHandler {
|
||||
clearInterval(this.chatCleanupInterval);
|
||||
this.chatCleanupInterval = null;
|
||||
}
|
||||
if (this.chatHistory) {
|
||||
this.chatHistory.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +238,19 @@ class ChatHandler {
|
||||
|
||||
// Very light sanitization (escape < >)
|
||||
const safe = trimmed.replace(/[<>]/g, c => c === '<' ? '<' : '>').slice(0, 300);
|
||||
this.broadcastManager.broadcastChatMessage(socket.id.substring(0,6), safe, now, socket.id);
|
||||
const username = socket.id.substring(0, 6);
|
||||
|
||||
// Save message to history (async, don't block broadcast)
|
||||
this.chatHistory.saveMessage({
|
||||
socketId: socket.id,
|
||||
username: username,
|
||||
message: safe,
|
||||
type: 'chat'
|
||||
}).catch(err => {
|
||||
console.error('Failed to save chat message:', err.message);
|
||||
});
|
||||
|
||||
this.broadcastManager.broadcastChatMessage(username, safe, now, socket.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Chat History Manager
|
||||
* Handles persistent storage and retrieval of chat messages
|
||||
*/
|
||||
|
||||
class ChatHistoryManager {
|
||||
constructor({ db, debugManager, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) {
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this.maxHistoryMessages = maxHistoryMessages;
|
||||
|
||||
// In-memory fallback for when DB is not available
|
||||
this._inMemoryHistory = [];
|
||||
this._inMemoryFallbackSize = inMemoryFallbackSize;
|
||||
|
||||
// Cache recent messages in memory for fast access
|
||||
this._recentMessagesCache = [];
|
||||
this._cacheSize = maxHistoryMessages;
|
||||
this._cacheInitialized = false;
|
||||
|
||||
// Cleanup old messages periodically (once per day)
|
||||
this._cleanupInterval = null;
|
||||
this._maxMessageAge = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager - load recent messages into cache
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
if (this.db) {
|
||||
// Ensure table exists
|
||||
await this._ensureTable();
|
||||
|
||||
// Load recent messages into cache
|
||||
await this._refreshCache();
|
||||
|
||||
// Start cleanup interval (run once per day)
|
||||
this._cleanupInterval = setInterval(() => {
|
||||
this.cleanupOldMessages().catch(err => {
|
||||
console.error('Chat history cleanup error:', err.message);
|
||||
});
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 ChatHistoryManager initialized with ${this._recentMessagesCache.length} cached messages`);
|
||||
}
|
||||
} else {
|
||||
console.warn('ChatHistoryManager: No database available, using in-memory fallback');
|
||||
}
|
||||
this._cacheInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('ChatHistoryManager initialization error:', error.message);
|
||||
// Fall back to in-memory mode
|
||||
this._cacheInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chat_messages table exists
|
||||
*/
|
||||
async _ensureTable() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
await this.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
socket_id VARCHAR(255),
|
||||
username VARCHAR(50),
|
||||
message TEXT NOT NULL,
|
||||
message_type VARCHAR(20) DEFAULT 'chat',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index if it doesn't exist
|
||||
await this.db.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at
|
||||
ON chat_messages(created_at DESC)
|
||||
`);
|
||||
} catch (error) {
|
||||
// Table might already exist, that's fine
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('Chat table check:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the in-memory cache from database
|
||||
*/
|
||||
async _refreshCache() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT id, socket_id, username, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, [this._cacheSize]);
|
||||
|
||||
// Reverse to get chronological order (oldest first)
|
||||
this._recentMessagesCache = result.rows.reverse().map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh chat cache:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a chat message
|
||||
* @param {Object} messageData - Message data
|
||||
* @param {string} messageData.socketId - Socket ID of sender
|
||||
* @param {string} messageData.username - Display username
|
||||
* @param {string} messageData.message - Message content
|
||||
* @param {string} messageData.type - Message type (chat, system, event)
|
||||
* @returns {Object} Saved message with id and timestamp
|
||||
*/
|
||||
async saveMessage({ socketId, username, message, type = 'chat' }) {
|
||||
const timestamp = Date.now();
|
||||
const messageObj = {
|
||||
socketId,
|
||||
username,
|
||||
message,
|
||||
type,
|
||||
timestamp
|
||||
};
|
||||
|
||||
// Add to in-memory cache immediately
|
||||
this._recentMessagesCache.push(messageObj);
|
||||
if (this._recentMessagesCache.length > this._cacheSize) {
|
||||
this._recentMessagesCache.shift();
|
||||
}
|
||||
|
||||
// Also add to fallback array
|
||||
this._inMemoryHistory.push(messageObj);
|
||||
if (this._inMemoryHistory.length > this._inMemoryFallbackSize) {
|
||||
this._inMemoryHistory.shift();
|
||||
}
|
||||
|
||||
// Persist to database asynchronously (best effort)
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
INSERT INTO chat_messages (socket_id, username, message, message_type, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING id
|
||||
`, [socketId, username, message, type]);
|
||||
|
||||
messageObj.id = result.rows[0]?.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to persist chat message:', error.message);
|
||||
// Message is still in memory, so users will see it
|
||||
}
|
||||
}
|
||||
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent messages for a new user
|
||||
* @param {number} count - Number of messages to retrieve (default: 50)
|
||||
* @returns {Array} Array of recent messages
|
||||
*/
|
||||
async getRecentMessages(count = 50) {
|
||||
const limit = Math.min(count, this._cacheSize);
|
||||
|
||||
// If cache is initialized, use it
|
||||
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
|
||||
return this._recentMessagesCache.slice(-limit);
|
||||
}
|
||||
|
||||
// If cache is empty, try in-memory fallback
|
||||
if (this._inMemoryHistory.length > 0) {
|
||||
return this._inMemoryHistory.slice(-limit);
|
||||
}
|
||||
|
||||
// If nothing in memory, try database
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT id, socket_id, username, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
return result.rows.reverse().map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get recent messages from DB:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old messages (older than 30 days by default)
|
||||
* @param {number} maxAgeMs - Maximum message age in milliseconds
|
||||
* @returns {number} Number of messages deleted
|
||||
*/
|
||||
async cleanupOldMessages(maxAgeMs = this._maxMessageAge) {
|
||||
if (!this.db) return 0;
|
||||
|
||||
try {
|
||||
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
||||
const result = await this.db.query(`
|
||||
DELETE FROM chat_messages
|
||||
WHERE created_at < $1
|
||||
`, [cutoffDate]);
|
||||
|
||||
const deletedCount = result.rowCount || 0;
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING && deletedCount > 0) {
|
||||
console.log(`🧹 Cleaned up ${deletedCount} old chat messages`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
console.error('Chat cleanup error:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message count for statistics
|
||||
* @returns {Object} Statistics about chat messages
|
||||
*/
|
||||
async getStats() {
|
||||
const stats = {
|
||||
cachedMessages: this._recentMessagesCache.length,
|
||||
inMemoryFallback: this._inMemoryHistory.length,
|
||||
totalInDb: null
|
||||
};
|
||||
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query('SELECT COUNT(*) as count FROM chat_messages');
|
||||
stats.totalInDb = parseInt(result.rows[0]?.count || 0);
|
||||
} catch (error) {
|
||||
// Ignore errors for stats
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown - cleanup intervals
|
||||
*/
|
||||
shutdown() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
this._cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHistoryManager;
|
||||
@@ -3,6 +3,8 @@
|
||||
* Handles game queue operations, validation, and queue management
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
class QueueHandler {
|
||||
constructor({ queueManager, gameModeManager, paymentHandlers, activeGames, broadcastManager, debugManager, rateLimiter }) {
|
||||
this.queueManager = queueManager;
|
||||
@@ -87,8 +89,9 @@ class QueueHandler {
|
||||
console.log(`🕒 QUEUE ENTRY: Player ${socket.id} queued for block ${nextBlock}. Queue length: ${this.queueManager.getQueueLength()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('handleGameQueue error:', error);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to join queue. Please try again.');
|
||||
const normalized = normalizeError(error, 'Failed to join queue');
|
||||
console.error('handleGameQueue error:', normalized.message);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* - Starting games when a new block arrives
|
||||
* - Preserving unconfirmed (mempool-only) entries across blocks
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
class QueueManager {
|
||||
constructor({ debugManager, broadcastManager, io, createGameForUser, getUserBySocket, activeGames, gameModeManager, consoleLogging }) {
|
||||
this.debugManager = debugManager;
|
||||
@@ -84,11 +87,16 @@ class QueueManager {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
// Include provably fair commitment
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Game started for ${serverId}`);
|
||||
} catch (error) {
|
||||
console.error('[QueueManager] Error creating game:', error);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + error.message);
|
||||
const normalized = normalizeError(error, 'Failed to start game');
|
||||
console.error('[QueueManager] Error creating game:', normalized.message);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message);
|
||||
}
|
||||
}
|
||||
if (remainingQueue.length > 0) {
|
||||
@@ -132,12 +140,17 @@ class QueueManager {
|
||||
const game = this.createGameForUser(currentUser, 'standard');
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
// Include provably fair commitment
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] (immediate) Game started for ${serverId} at block ${blockHeight}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[QueueManager] Error starting immediate game:', err);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + err.message);
|
||||
const normalized = normalizeError(err, 'Failed to start immediate game');
|
||||
console.error('[QueueManager] Error starting immediate game:', normalized.message);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,28 @@ class RateLimiter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tracked entries (for dashboard)
|
||||
*/
|
||||
getTrackedCount() {
|
||||
return this.storage.size + this.ipStorage.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of currently blocked users (rough estimate)
|
||||
*/
|
||||
getBlockedCount() {
|
||||
const now = Date.now();
|
||||
let blocked = 0;
|
||||
for (const [key, data] of this.storage.entries()) {
|
||||
const limit = this._getLimitFromKey(key);
|
||||
if (limit && data.count >= limit.max && (now - data.firstAttempt) < limit.window) {
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the rate limiter (cleanup interval)
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Issues and resumes anonymous session tokens to persist credits & payout address across reconnects.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
class SessionManager {
|
||||
constructor({ db, debugManager, gameModeManager }) {
|
||||
@@ -72,8 +73,9 @@ class SessionManager {
|
||||
user: newUser
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Error in resumeOrCreate:', error);
|
||||
throw error;
|
||||
const normalized = normalizeError(error, 'Failed to resume or create session');
|
||||
console.error('[SessionManager] Error in resumeOrCreate:', normalized.message);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +148,8 @@ class SessionManager {
|
||||
console.log(`[SessionManager] Cleaned up ${result.rowCount} expired sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Error cleaning up sessions:', error);
|
||||
const normalized = normalizeError(error, 'Failed to cleanup expired sessions');
|
||||
console.error('[SessionManager] Error cleaning up sessions:', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const RateLimiter = require('./rateLimiter');
|
||||
const ConnectionHandler = require('./connectionHandler');
|
||||
const ChatHandler = require('./chatHandler');
|
||||
const QueueHandler = require('./queueHandler');
|
||||
const SpectatorManager = require('./spectatorManager');
|
||||
const MemoryManager = require('../utils/memoryManager');
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
@@ -120,7 +121,7 @@ class SocketHandlers {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize chat handler
|
||||
// Initialize chat handler with database for persistent history
|
||||
this.chatHandler = new ChatHandler({
|
||||
io: this.io,
|
||||
broadcastManager: this.broadcastManager,
|
||||
@@ -129,7 +130,13 @@ class SocketHandlers {
|
||||
paymentHandlers: this.paymentHandlers,
|
||||
queueManager: this.queueManager,
|
||||
gameModeManager: this.gameModeManager,
|
||||
rateLimiter: this.rateLimiter
|
||||
rateLimiter: this.rateLimiter,
|
||||
db: this.gameModeManager?.db
|
||||
});
|
||||
|
||||
// Initialize chat history (async, non-blocking)
|
||||
this.chatHandler.initialize().catch(err => {
|
||||
console.error('Failed to initialize chat handler:', err.message);
|
||||
});
|
||||
|
||||
// Initialize queue handler
|
||||
@@ -143,6 +150,18 @@ class SocketHandlers {
|
||||
rateLimiter: this.rateLimiter
|
||||
});
|
||||
|
||||
// Initialize spectator manager for live game viewing
|
||||
this.spectatorManager = new SpectatorManager({
|
||||
io: this.io,
|
||||
activeGames: this.activeGames,
|
||||
broadcastManager: this.broadcastManager,
|
||||
debugManager: this.debugManager
|
||||
});
|
||||
this.spectatorManager.initialize();
|
||||
|
||||
// Wire spectator manager to game manager (for game over notifications)
|
||||
this.gameManager.setSpectatorManager(this.spectatorManager);
|
||||
|
||||
// Movement manager abstraction with memory cleanup
|
||||
this.playerMoveTimestamps = new Map();
|
||||
this.movementManager = new MovementManager({
|
||||
@@ -150,7 +169,8 @@ class SocketHandlers {
|
||||
io: this.io,
|
||||
debugManager: this.debugManager,
|
||||
moveCooldown: this.MOVE_COOLDOWN,
|
||||
postMoveHook: ({ socketId, game, moveResult }) => this.afterPlayerMove(socketId, game, moveResult)
|
||||
postMoveHook: ({ socketId, game, moveResult }) => this.afterPlayerMove(socketId, game, moveResult),
|
||||
spectatorManager: this.spectatorManager // Pass for spectator broadcasts
|
||||
});
|
||||
|
||||
// Register memory cleanup functions
|
||||
@@ -318,6 +338,21 @@ class SocketHandlers {
|
||||
socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo());
|
||||
}
|
||||
|
||||
// Send chat history to new user
|
||||
if (this.chatHandler && typeof this.chatHandler.sendChatHistoryToUser === 'function') {
|
||||
this.chatHandler.sendChatHistoryToUser(socket.id).catch(err => {
|
||||
console.error('Failed to send chat history:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Join the lobby room for game list broadcasts
|
||||
if (this.spectatorManager) {
|
||||
this.spectatorManager.joinLobby(socket.id);
|
||||
// Send initial game list
|
||||
const gameList = this.spectatorManager.getActiveGamesList({ page: 1, pageSize: 20 });
|
||||
socket.emit('active_games', gameList);
|
||||
}
|
||||
|
||||
// Register event handlers
|
||||
socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
|
||||
handleGameQueue: (socket) => this.queueHandler.handleGameQueue(socket, (socketId) => this.connectionHandler.getUserBySocket(socketId)),
|
||||
@@ -336,6 +371,11 @@ class SocketHandlers {
|
||||
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
|
||||
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
|
||||
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
|
||||
|
||||
// Spectator handlers
|
||||
socket.on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
|
||||
socket.on('spectate_game', (data) => this.handleSpectateGame(socket, data));
|
||||
socket.on('leave_spectate', () => this.handleLeaveSpectate(socket));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,6 +451,11 @@ class SocketHandlers {
|
||||
const game = this.gameManager.createGameForUser(memUser, 'standard');
|
||||
const state = game.getState();
|
||||
state.blockHeight = blockHeight;
|
||||
|
||||
// Include provably fair commitment
|
||||
if (game.getProofCommitment) {
|
||||
state.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// Process start (credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
@@ -570,6 +615,11 @@ class SocketHandlers {
|
||||
if (this.chatHandler) {
|
||||
this.chatHandler.clearAddressConfirmation(socket.id);
|
||||
}
|
||||
|
||||
// Clean up spectator state
|
||||
if (this.spectatorManager) {
|
||||
this.spectatorManager.handleDisconnect(socket.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -661,6 +711,86 @@ class SocketHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== SPECTATOR HANDLERS ======
|
||||
|
||||
/**
|
||||
* Handle request for active games list
|
||||
*/
|
||||
handleGetActiveGames(socket, options = {}) {
|
||||
try {
|
||||
const gameList = this.spectatorManager.getActiveGamesList(options);
|
||||
socket.emit('active_games', gameList);
|
||||
} catch (err) {
|
||||
console.error('handleGetActiveGames error:', err);
|
||||
socket.emit('error', { message: 'Failed to get games list' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle request to spectate a specific game
|
||||
*/
|
||||
handleSpectateGame(socket, data) {
|
||||
try {
|
||||
if (!data || !data.gameId) {
|
||||
socket.emit('spectate_error', { message: 'Game ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is currently in a game (can't spectate while playing)
|
||||
if (this.activeGames.has(socket.id)) {
|
||||
socket.emit('spectate_error', { message: 'Cannot spectate while in a game' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Leave lobby when starting to spectate
|
||||
this.spectatorManager.leaveLobby(socket.id);
|
||||
|
||||
const result = this.spectatorManager.addSpectator(socket.id, data.gameId);
|
||||
|
||||
if (result.success) {
|
||||
socket.emit('spectate_start', {
|
||||
gameId: result.gameId,
|
||||
playerId: result.playerSocketId?.substring(0, 6),
|
||||
initialState: result.initialState,
|
||||
spectatorCount: result.spectatorCount
|
||||
});
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
`👁️ Now spectating game by player ${result.playerSocketId?.substring(0, 6)}`);
|
||||
} else {
|
||||
socket.emit('spectate_error', { message: result.reason });
|
||||
// Rejoin lobby if spectating failed
|
||||
this.spectatorManager.joinLobby(socket.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('handleSpectateGame error:', err);
|
||||
socket.emit('spectate_error', { message: 'Failed to join game as spectator' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle request to leave spectating
|
||||
*/
|
||||
handleLeaveSpectate(socket) {
|
||||
try {
|
||||
const wasSpectating = this.spectatorManager.removeSpectator(socket.id);
|
||||
|
||||
if (wasSpectating) {
|
||||
socket.emit('spectate_ended', { reason: 'user_left' });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', '👁️ Left spectator mode');
|
||||
}
|
||||
|
||||
// Rejoin lobby
|
||||
this.spectatorManager.joinLobby(socket.id);
|
||||
|
||||
// Send updated games list
|
||||
const gameList = this.spectatorManager.getActiveGamesList({ page: 1, pageSize: 20 });
|
||||
socket.emit('active_games', gameList);
|
||||
} catch (err) {
|
||||
console.error('handleLeaveSpectate error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive statistics
|
||||
*/
|
||||
@@ -672,7 +802,8 @@ class SocketHandlers {
|
||||
connections: this.connectionHandler.getStats(),
|
||||
chat: this.chatHandler.getStats(),
|
||||
games: this.gameManager.getStats(),
|
||||
queue: this.queueHandler.getStats()
|
||||
queue: this.queueHandler.getStats(),
|
||||
spectators: this.spectatorManager ? this.spectatorManager.getStats() : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -685,6 +816,10 @@ class SocketHandlers {
|
||||
}
|
||||
|
||||
// Shutdown components in reverse order of initialization
|
||||
if (this.spectatorManager) {
|
||||
this.spectatorManager.shutdown();
|
||||
}
|
||||
|
||||
if (this.chatHandler) {
|
||||
this.chatHandler.shutdown();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Spectator Manager
|
||||
* Manages spectator connections and game broadcasts
|
||||
*
|
||||
* Design considerations for scalability:
|
||||
* - Efficient tracking of spectators per game
|
||||
* - Periodic game list broadcasts instead of per-update
|
||||
* - Room-based broadcasting using Socket.IO rooms
|
||||
* - Memory-efficient cleanup of stale spectators
|
||||
*/
|
||||
|
||||
class SpectatorManager {
|
||||
constructor({ io, activeGames, broadcastManager, debugManager }) {
|
||||
this.io = io;
|
||||
this.activeGames = activeGames;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
|
||||
// Map of gameId -> Set of spectator socket IDs
|
||||
this._spectatorsByGame = new Map();
|
||||
|
||||
// Map of socketId -> gameId (for quick lookup of what a spectator is watching)
|
||||
this._spectatorWatching = new Map();
|
||||
|
||||
// Cached game list for efficient broadcasting
|
||||
this._cachedGameList = [];
|
||||
this._gameListLastUpdate = 0;
|
||||
this._gameListCacheTimeout = 2000; // 2 second cache
|
||||
|
||||
// Periodic game list broadcast interval
|
||||
this._broadcastInterval = null;
|
||||
this._broadcastIntervalMs = 3000; // Broadcast every 3 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager - start periodic broadcasts
|
||||
*/
|
||||
initialize() {
|
||||
// Start periodic game list broadcasting
|
||||
this._broadcastInterval = setInterval(() => {
|
||||
this._broadcastGameListToLobby();
|
||||
}, this._broadcastIntervalMs);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('👁️ SpectatorManager initialized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active games with spectator-safe info
|
||||
* @param {Object} options - Filtering/pagination options
|
||||
* @returns {Array} List of active games with metadata
|
||||
*/
|
||||
getActiveGamesList(options = {}) {
|
||||
const { page = 1, pageSize = 20, sortBy = 'newest' } = options;
|
||||
|
||||
// Check cache
|
||||
const now = Date.now();
|
||||
if (now - this._gameListLastUpdate < this._gameListCacheTimeout && this._cachedGameList.length > 0) {
|
||||
return this._applyPagination(this._cachedGameList, page, pageSize);
|
||||
}
|
||||
|
||||
// Build fresh list
|
||||
const games = [];
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
if (game && game.gameState === 'active') {
|
||||
games.push(this._buildGameSummary(socketId, game));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort games
|
||||
this._sortGames(games, sortBy);
|
||||
|
||||
// Cache the list
|
||||
this._cachedGameList = games;
|
||||
this._gameListLastUpdate = now;
|
||||
|
||||
return this._applyPagination(games, page, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a spectator-safe game summary
|
||||
* @private
|
||||
*/
|
||||
_buildGameSummary(socketId, game) {
|
||||
const spectatorCount = this._spectatorsByGame.get(game.id)?.size || 0;
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
playerId: socketId.substring(0, 6), // Anonymized player ID
|
||||
startedAt: game.startedAt,
|
||||
moveCount: game.moveCount || 0,
|
||||
hasTreasure: game.player?.hasTreasure || false,
|
||||
spectatorCount: spectatorCount,
|
||||
// Don't expose exact positions - let spectators get that when they join
|
||||
dungeonSize: {
|
||||
width: game.width || 25,
|
||||
height: game.height || 19
|
||||
},
|
||||
difficulty: game.difficultyConfig?.presetName || 'normal',
|
||||
durationSeconds: game.startedAt ? Math.floor((Date.now() - game.startedAt) / 1000) : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort games array by criteria
|
||||
* @private
|
||||
*/
|
||||
_sortGames(games, sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
games.sort((a, b) => b.startedAt - a.startedAt);
|
||||
break;
|
||||
case 'oldest':
|
||||
games.sort((a, b) => a.startedAt - b.startedAt);
|
||||
break;
|
||||
case 'spectators':
|
||||
games.sort((a, b) => b.spectatorCount - a.spectatorCount);
|
||||
break;
|
||||
case 'moves':
|
||||
games.sort((a, b) => b.moveCount - a.moveCount);
|
||||
break;
|
||||
default:
|
||||
games.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination to games list
|
||||
* @private
|
||||
*/
|
||||
_applyPagination(games, page, pageSize) {
|
||||
const totalGames = games.length;
|
||||
const totalPages = Math.ceil(totalGames / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
return {
|
||||
games: games.slice(startIndex, endIndex),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalGames,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a spectator to a game
|
||||
* @param {string} spectatorSocketId - Socket ID of the spectator
|
||||
* @param {string} gameId - ID of the game to spectate
|
||||
* @returns {Object} Result with success status and initial game state
|
||||
*/
|
||||
addSpectator(spectatorSocketId, gameId) {
|
||||
// Find the game by gameId
|
||||
let targetGame = null;
|
||||
let playerSocketId = null;
|
||||
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
if (game && game.id === gameId) {
|
||||
targetGame = game;
|
||||
playerSocketId = socketId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetGame) {
|
||||
return { success: false, reason: 'Game not found or has ended' };
|
||||
}
|
||||
|
||||
if (targetGame.gameState !== 'active') {
|
||||
return { success: false, reason: 'Game is no longer active' };
|
||||
}
|
||||
|
||||
// Check if already spectating this game
|
||||
const currentlyWatching = this._spectatorWatching.get(spectatorSocketId);
|
||||
if (currentlyWatching === gameId) {
|
||||
return { success: false, reason: 'Already spectating this game' };
|
||||
}
|
||||
|
||||
// If spectating another game, leave it first
|
||||
if (currentlyWatching) {
|
||||
this.removeSpectator(spectatorSocketId);
|
||||
}
|
||||
|
||||
// Add to spectators set for this game
|
||||
if (!this._spectatorsByGame.has(gameId)) {
|
||||
this._spectatorsByGame.set(gameId, new Set());
|
||||
}
|
||||
this._spectatorsByGame.get(gameId).add(spectatorSocketId);
|
||||
|
||||
// Track what this spectator is watching
|
||||
this._spectatorWatching.set(spectatorSocketId, gameId);
|
||||
|
||||
// Join Socket.IO room for this game
|
||||
const spectatorSocket = this._getSocket(spectatorSocketId);
|
||||
if (spectatorSocket) {
|
||||
spectatorSocket.join(`spectate:${gameId}`);
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Spectator ${spectatorSocketId.substring(0,6)} joined game ${gameId.substring(0,8)}`);
|
||||
}
|
||||
|
||||
// Get initial game state for spectator
|
||||
const initialState = this._getSpectatorGameState(targetGame, playerSocketId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
gameId: gameId,
|
||||
playerSocketId: playerSocketId,
|
||||
initialState: initialState,
|
||||
spectatorCount: this._spectatorsByGame.get(gameId).size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a spectator from their current game
|
||||
* @param {string} spectatorSocketId - Socket ID of the spectator
|
||||
* @returns {boolean} Whether removal was successful
|
||||
*/
|
||||
removeSpectator(spectatorSocketId) {
|
||||
const gameId = this._spectatorWatching.get(spectatorSocketId);
|
||||
if (!gameId) return false;
|
||||
|
||||
// Remove from game's spectator set
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
if (spectators) {
|
||||
spectators.delete(spectatorSocketId);
|
||||
if (spectators.size === 0) {
|
||||
this._spectatorsByGame.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from watching map
|
||||
this._spectatorWatching.delete(spectatorSocketId);
|
||||
|
||||
// Leave Socket.IO room
|
||||
const spectatorSocket = this._getSocket(spectatorSocketId);
|
||||
if (spectatorSocket) {
|
||||
spectatorSocket.leave(`spectate:${gameId}`);
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Spectator ${spectatorSocketId.substring(0,6)} left game ${gameId.substring(0,8)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what game a socket is currently spectating
|
||||
* @param {string} socketId - Socket ID to check
|
||||
* @returns {string|null} Game ID or null
|
||||
*/
|
||||
getSpectatingGame(socketId) {
|
||||
return this._spectatorWatching.get(socketId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a socket is currently spectating any game
|
||||
* @param {string} socketId - Socket ID to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSpectating(socketId) {
|
||||
return this._spectatorWatching.has(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spectator count for a game
|
||||
* @param {string} gameId - Game ID
|
||||
* @returns {number}
|
||||
*/
|
||||
getSpectatorCount(gameId) {
|
||||
return this._spectatorsByGame.get(gameId)?.size || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game update to all spectators of a game
|
||||
* @param {string} gameId - Game ID
|
||||
* @param {Object} gameState - Updated game state
|
||||
*/
|
||||
broadcastToSpectators(gameId, gameState) {
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
if (!spectators || spectators.size === 0) return;
|
||||
|
||||
// Use Socket.IO room for efficient broadcasting
|
||||
this.io.to(`spectate:${gameId}`).emit('spectator_update', {
|
||||
gameId: gameId,
|
||||
gameState: gameState,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify spectators that a game has ended
|
||||
* @param {string} gameId - Game ID
|
||||
* @param {Object} gameOverData - Game over information
|
||||
*/
|
||||
notifyGameEnded(gameId, gameOverData) {
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
if (!spectators || spectators.size === 0) return;
|
||||
|
||||
// Emit game over to spectators
|
||||
this.io.to(`spectate:${gameId}`).emit('spectate_ended', {
|
||||
gameId: gameId,
|
||||
reason: 'game_over',
|
||||
gameOverData: gameOverData
|
||||
});
|
||||
|
||||
// Remove all spectators from this game's tracking
|
||||
for (const spectatorId of spectators) {
|
||||
this._spectatorWatching.delete(spectatorId);
|
||||
const sock = this._getSocket(spectatorId);
|
||||
if (sock) sock.leave(`spectate:${gameId}`);
|
||||
}
|
||||
this._spectatorsByGame.delete(gameId);
|
||||
|
||||
// Invalidate cache
|
||||
this._gameListLastUpdate = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spectator-safe game state
|
||||
* @private
|
||||
*/
|
||||
_getSpectatorGameState(game, playerSocketId) {
|
||||
// Return a sanitized version of the game state
|
||||
if (typeof game.getState === 'function') {
|
||||
const state = game.getState();
|
||||
return {
|
||||
...state,
|
||||
playerId: playerSocketId.substring(0, 6),
|
||||
isSpectating: true
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for legacy game structure
|
||||
return {
|
||||
player: game.player ? {
|
||||
x: game.player.x,
|
||||
y: game.player.y,
|
||||
hasTreasure: game.player.hasTreasure
|
||||
} : null,
|
||||
monster: game.monster ? {
|
||||
x: game.monster.x,
|
||||
y: game.monster.y
|
||||
} : null,
|
||||
visibleTiles: game.visibleTiles,
|
||||
lighting: game.lightingAndFov?.getLightingData ? game.lightingAndFov.getLightingData() : null,
|
||||
torches: game.dungeon?.torches || [],
|
||||
playerId: playerSocketId.substring(0, 6),
|
||||
isSpectating: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game list to users in the lobby (not in a game, not spectating)
|
||||
* @private
|
||||
*/
|
||||
_broadcastGameListToLobby() {
|
||||
// Get fresh game list
|
||||
const gameListData = this.getActiveGamesList({ page: 1, pageSize: 50 });
|
||||
|
||||
// Only broadcast if there are active games
|
||||
if (gameListData.pagination.totalGames === 0) return;
|
||||
|
||||
// Broadcast to a "lobby" room - users join this room when not in game
|
||||
this.io.to('lobby').emit('active_games', gameListData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a socket to the lobby room
|
||||
* @param {string} socketId - Socket ID
|
||||
*/
|
||||
joinLobby(socketId) {
|
||||
const sock = this._getSocket(socketId);
|
||||
if (sock) {
|
||||
sock.join('lobby');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the lobby room
|
||||
* @param {string} socketId - Socket ID
|
||||
*/
|
||||
leaveLobby(socketId) {
|
||||
const sock = this._getSocket(socketId);
|
||||
if (sock) {
|
||||
sock.leave('lobby');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a socket by ID
|
||||
* @private
|
||||
*/
|
||||
_getSocket(socketId) {
|
||||
return this.io.sockets.sockets.get(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about spectator system
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalSpectators: this._spectatorWatching.size,
|
||||
gamesBeingWatched: this._spectatorsByGame.size,
|
||||
cachedGameListSize: this._cachedGameList.length,
|
||||
spectatorsByGame: Object.fromEntries(
|
||||
Array.from(this._spectatorsByGame.entries()).map(([gameId, set]) => [
|
||||
gameId.substring(0, 8),
|
||||
set.size
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when a socket disconnects
|
||||
* @param {string} socketId - Socket ID that disconnected
|
||||
*/
|
||||
handleDisconnect(socketId) {
|
||||
// Remove from spectating if applicable
|
||||
this.removeSpectator(socketId);
|
||||
// Leave lobby is automatic when socket disconnects
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the manager
|
||||
*/
|
||||
shutdown() {
|
||||
if (this._broadcastInterval) {
|
||||
clearInterval(this._broadcastInterval);
|
||||
this._broadcastInterval = null;
|
||||
}
|
||||
this._spectatorsByGame.clear();
|
||||
this._spectatorWatching.clear();
|
||||
this._cachedGameList = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SpectatorManager;
|
||||
Reference in New Issue
Block a user