'use strict'; /** * server.js — OracleBot entrypoint. * * Express HTTP server + WebSocket server providing: * POST /trace — receive tool traces from the gateway * POST /command — accept follow/scan commands * GET /health — liveness / status check * Static files from public/ * WS /ws — live push to dashboard clients */ const http = require('http'); const path = require('path'); const express = require('express'); const { WebSocketServer } = require('ws'); const OracleBot = require('./bot.js'); const WorldState = require('./world-state.js'); // ────────────────────────────────────────────── // Config // ────────────────────────────────────────────── const PORT = parseInt(process.env.PORT || '3333', 10); const HEARTBEAT_INTERVAL_MS = 5000; // idle pulse const ACTIVE_INTERVAL_MS = 1000; // active world-snapshot push const MAX_WS_TOTAL = 100; // global WebSocket cap const MAX_WS_PER_IP = 5; // per-IP WebSocket cap // ────────────────────────────────────────────── // Instances // ────────────────────────────────────────────── const bot = new OracleBot(); const state = new WorldState(bot); // ────────────────────────────────────────────── // Express app // ────────────────────────────────────────────── const app = express(); app.use(express.json({ limit: '64kb' })); app.use(express.static(path.join(__dirname, 'public'))); // ── POST /trace ────────────────────────────── app.post('/trace', (req, res) => { const body = req.body; if (!body || typeof body.tool !== 'string') { return res.status(400).json({ ok: false, error: 'Missing required field: tool' }); } const prevPlayer = state.activePlayer; state.onTrace(body); // Broadcast: trace event, mode change, then world snapshot broadcast(state.buildTraceEvent(body)); broadcast(state.buildModeChange()); const snapshot = state.buildWorldSnapshot(); if (snapshot) broadcast(snapshot); // If the active player changed, follow them if (state.activePlayer && state.activePlayer !== prevPlayer) { try { bot.followPlayer(state.activePlayer); } catch (err) { // Bot may not be spawned yet — non-fatal console.warn(`[server] followPlayer failed: ${err.message}`); } } return res.json({ ok: true }); }); // ── POST /stream ────────────────────────────── app.post('/stream', (req, res) => { const data = req.body; if (!data) return res.status(400).json({ ok: false }); // Broadcast stream token to all WebSocket clients broadcast({ v: 1, type: 'stream', token: data.token || '', accumulated: data.accumulated || '', step: data.step, mode: data.mode || state.mode, player: data.player || state.activePlayer, ts: Date.now(), }); res.json({ ok: true }); }); // ── POST /command ───────────────────────────── app.post('/command', (req, res) => { const { action, target, center, radius } = req.body || {}; if (!action) { return res.status(400).json({ ok: false, error: 'Missing required field: action' }); } if (action === 'follow') { if (!target || typeof target !== 'string') { return res.status(400).json({ ok: false, error: 'follow requires target (player name)' }); } try { bot.followPlayer(target); return res.json({ ok: true, action: 'follow', target }); } catch (err) { return res.status(503).json({ ok: false, error: err.message }); } } if (action === 'scan') { if (!center || typeof center.x !== 'number' || typeof center.y !== 'number' || typeof center.z !== 'number') { return res.status(400).json({ ok: false, error: 'scan requires center {x, y, z}' }); } const r = typeof radius === 'number' ? radius : 16; try { const blocks = bot.scanArea(center.x, center.y, center.z, r); const entities = bot.getNearbyEntities(center.x, center.y, center.z, r); return res.json({ ok: true, action: 'scan', center, radius: r, blocks, entities }); } catch (err) { return res.status(503).json({ ok: false, error: err.message }); } } return res.status(400).json({ ok: false, error: `Unknown action: ${action}` }); }); // ── GET /health ─────────────────────────────── app.get('/health', (_req, res) => { res.json({ ok: true, botConnected: bot._spawned === true, wsClients: wss.clients.size, mode: state.mode, activePlayer: state.activePlayer, }); }); // ────────────────────────────────────────────── // HTTP server // ────────────────────────────────────────────── const server = http.createServer(app); // ────────────────────────────────────────────── // WebSocket server // ────────────────────────────────────────────── const wss = new WebSocketServer({ server, path: '/ws' }); // Track connections per IP: Map const ipCounts = new Map(); wss.on('connection', (ws, req) => { // Resolve client IP (trust x-forwarded-for from Caddy) const forwarded = req.headers['x-forwarded-for']; const ip = forwarded ? forwarded.split(',')[0].trim() : (req.socket.remoteAddress || 'unknown'); // Enforce total cap if (wss.clients.size > MAX_WS_TOTAL) { ws.close(1008, 'Server at capacity'); return; } // Enforce per-IP cap const currentCount = ipCounts.get(ip) || 0; if (currentCount >= MAX_WS_PER_IP) { ws.close(1008, 'Too many connections from your IP'); return; } ipCounts.set(ip, currentCount + 1); // Store IP on the socket for cleanup ws._remoteIp = ip; // Send current state immediately on connect ws.send(JSON.stringify(state.buildHeartbeat())); // If we're not idle, also send current mode if (state.mode !== 'idle') { ws.send(JSON.stringify(state.buildModeChange())); } ws.on('close', () => { const count = ipCounts.get(ws._remoteIp) || 1; if (count <= 1) { ipCounts.delete(ws._remoteIp); } else { ipCounts.set(ws._remoteIp, count - 1); } }); }); /** * Broadcast a message object to all connected WebSocket clients. * @param {Object} msg */ function broadcast(msg) { if (wss.clients.size === 0) return; const payload = JSON.stringify(msg); for (const client of wss.clients) { if (client.readyState === client.OPEN) { client.send(payload); } } } // ────────────────────────────────────────────── // Periodic updates // ────────────────────────────────────────────── // Heartbeat every 5s (idle mode pulse + idle timeout check) setInterval(() => { if (wss.clients.size === 0) return; // Check if session has timed out const wentIdle = state.checkIdle(); if (wentIdle) { broadcast(state.buildModeChange()); } if (state.mode === 'idle') { broadcast(state.buildHeartbeat()); } }, HEARTBEAT_INTERVAL_MS); // World snapshot every 1s (active mode only) setInterval(() => { if (wss.clients.size === 0) return; if (state.mode === 'idle') return; const snapshot = state.buildWorldSnapshot(); if (snapshot) broadcast(snapshot); }, ACTIVE_INTERVAL_MS); // ────────────────────────────────────────────── // Bot event handlers // ────────────────────────────────────────────── bot.on('connected', () => { broadcast(state.buildStatus(true)); }); bot.on('spawned', () => { // Spectator is handled by bot.js internally (self-sets via chat after 2s delay) }); bot.on('disconnected', () => { broadcast(state.buildStatus(false)); }); bot.on('playerJoined', () => { broadcast(state.buildHeartbeat()); }); bot.on('playerLeft', () => { broadcast(state.buildHeartbeat()); }); // ────────────────────────────────────────────── // Startup // ────────────────────────────────────────────── bot.connect(); server.listen(PORT, () => { console.log(`[oracle-bot] HTTP + WS server listening on port ${PORT}`); console.log(`[oracle-bot] WebSocket path: ws://localhost:${PORT}/ws`); console.log(`[oracle-bot] Health check: http://localhost:${PORT}/health`); console.log(`[oracle-bot] Connecting bot to Minecraft at ${bot.host}:${bot.port} ...`); });