fix chat and payments and add spectator mode

This commit is contained in:
jwinterm
2025-12-22 10:36:46 -05:00
parent fbf5bd5b17
commit 24f46fa35e
26 changed files with 2742 additions and 7302 deletions
+282 -6
View File
@@ -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
+17 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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!");
}
+2
View File
@@ -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
+31
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+368 -10
View File
@@ -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();
}
};
+289
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+12 -1
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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)';
+56 -4
View File
@@ -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 === '<' ? '&lt;' : '&gt;').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);
}
/**
+274
View File
@@ -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;
+5 -2
View File
@@ -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);
}
}
+17 -4
View File
@@ -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;
}
}
+22
View File
@@ -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)
*/
+6 -3
View File
@@ -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);
}
}
+139 -4
View File
@@ -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();
}
+447
View File
@@ -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;
View File