'use strict'; /** * bot.js — OracleBot: mineflayer spectator bot for Mortdecai's live vision system. * * Connects to the Minecraft dev server in spectator mode and exposes * observation methods (players, area scan, entities, world info) for * the oracle-bot HTTP/WebSocket layer (server.js, Task 2). */ const EventEmitter = require('events'); const mineflayer = require('mineflayer'); const DEFAULT_HOST = process.env.MC_HOST || '192.168.0.244'; const DEFAULT_PORT = parseInt(process.env.MC_PORT || '25568', 10); const DEFAULT_USERNAME = process.env.BOT_USERNAME || 'OracleBot'; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; class OracleBot extends EventEmitter { constructor(options = {}) { super(); this.host = options.host || DEFAULT_HOST; this.port = options.port || DEFAULT_PORT; this.username = options.username || DEFAULT_USERNAME; this._bot = null; this._reconnectDelay = RECONNECT_BASE_MS; this._reconnectTimer = null; this._destroyed = false; this._spawned = false; } // ────────────────────────────────────────────── // Public API // ────────────────────────────────────────────── /** * Create and connect the mineflayer bot. * Safe to call multiple times — will no-op if already connected. */ connect() { if (this._bot) return; if (this._destroyed) { console.warn('[OracleBot] Cannot connect — bot has been destroyed.'); return; } console.log(`[OracleBot] Connecting to ${this.host}:${this.port} as ${this.username} ...`); const bot = mineflayer.createBot({ host: this.host, port: this.port, username: this.username, auth: 'offline', version: '1.21.11', hideErrors: false, }); this._bot = bot; this._bindEvents(bot); this.emit('connected'); } /** * Teleport the bot to a player's position (spectator /tp). * @param {string} playerName */ followPlayer(playerName) { this._requireSpawned('followPlayer'); this._bot.chat(`/tp ${this.username} ${playerName}`); } /** * Return an array of online players with their positions. * @returns {Array<{name: string, x: number, y: number, z: number, health: number|null}>} */ getPlayers() { this._requireSpawned('getPlayers'); const players = []; for (const [name, playerRef] of Object.entries(this._bot.players)) { if (!playerRef || name === this.username) continue; const entity = playerRef.entity; players.push({ name, x: entity ? parseFloat(entity.position.x.toFixed(2)) : null, y: entity ? parseFloat(entity.position.y.toFixed(2)) : null, z: entity ? parseFloat(entity.position.z.toFixed(2)) : null, health: playerRef.entity ? playerRef.entity.metadata : null, ping: playerRef.ping, }); } return players; } /** * Scan blocks in a top-down horizontal slice centered at (x, y, z). * Returns non-air blocks within the radius on the given Y level. * @param {number} x * @param {number} y * @param {number} z * @param {number} radius - half-width of the scan square * @returns {Array<{x, y, z, name}>} */ scanArea(x, y, z, radius = 16) { this._requireSpawned('scanArea'); const bot = this._bot; const results = []; const r = Math.min(radius, 64); // hard cap to prevent OOM for (let bx = x - r; bx <= x + r; bx++) { for (let bz = z - r; bz <= z + r; bz++) { const block = bot.blockAt(bot.vec3(bx, y, bz)); if (block && block.name !== 'air' && block.name !== 'cave_air' && block.name !== 'void_air') { results.push({ x: bx, y, z: bz, name: block.name }); } } } return results; } /** * Get entities near a position within a given radius. * @param {number} x * @param {number} y * @param {number} z * @param {number} radius * @returns {Array<{id, type, name, x, y, z, dx, dy, dz}>} */ getNearbyEntities(x, y, z, radius = 32) { this._requireSpawned('getNearbyEntities'); const results = []; const r2 = radius * radius; for (const entity of Object.values(this._bot.entities)) { if (!entity || entity === this._bot.entity) continue; const pos = entity.position; const dx = pos.x - x; const dy = pos.y - y; const dz = pos.z - z; const dist2 = dx * dx + dy * dy + dz * dz; if (dist2 <= r2) { results.push({ id: entity.id, type: entity.type, name: entity.name || entity.username || entity.displayName || entity.type, x: parseFloat(pos.x.toFixed(2)), y: parseFloat(pos.y.toFixed(2)), z: parseFloat(pos.z.toFixed(2)), distance: parseFloat(Math.sqrt(dist2).toFixed(2)), }); } } results.sort((a, b) => a.distance - b.distance); return results; } /** * Return general world / server info. * @returns {{timeOfDay: number, isDay: boolean, raining: boolean, thundering: boolean, gameMode: string}} */ getWorldInfo() { this._requireSpawned('getWorldInfo'); const bot = this._bot; return { timeOfDay: bot.time.timeOfDay, isDay: bot.time.isDay, raining: bot.isRaining, thundering: bot.thunderState > 0, gameMode: bot.game.gameMode, dimension: bot.game.dimension, }; } /** * Cleanly disconnect and prevent further reconnects. */ destroy() { this._destroyed = true; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } if (this._bot) { try { this._bot.quit('OracleBot shutting down'); } catch (_) { // ignore errors during shutdown } this._bot = null; } this._spawned = false; console.log('[OracleBot] Destroyed.'); } // ────────────────────────────────────────────── // Internal helpers // ────────────────────────────────────────────── _requireSpawned(methodName) { if (!this._bot || !this._spawned) { throw new Error(`[OracleBot] ${methodName}() called before bot spawned`); } } _bindEvents(bot) { bot.once('login', () => { console.log(`[OracleBot] Logged in as ${bot.username}`); }); bot.once('spawn', () => { this._spawned = true; this._reconnectDelay = RECONNECT_BASE_MS; // reset backoff on successful spawn console.log( `[OracleBot] Spawned at x=${bot.entity.position.x.toFixed(1)} ` + `y=${bot.entity.position.y.toFixed(1)} ` + `z=${bot.entity.position.z.toFixed(1)} ` + `gamemode=${bot.game.gameMode}` ); // If not already spectator, become spectator via server command if (bot.game.gameMode !== 'spectator') { console.log('[OracleBot] Not in spectator mode — requesting /gamemode spectator'); bot.chat('/gamemode spectator'); } this.emit('spawned', { position: bot.entity.position, gameMode: bot.game.gameMode, }); }); bot.on('playerJoined', (player) => { console.log(`[OracleBot] Player joined: ${player.username}`); this.emit('playerJoined', player.username); }); bot.on('playerLeft', (player) => { console.log(`[OracleBot] Player left: ${player.username}`); this.emit('playerLeft', player.username); }); bot.on('error', (err) => { console.error(`[OracleBot] Error: ${err.message}`); // Don't schedule reconnect here — 'end' will fire afterward }); bot.on('kicked', (reason) => { let reasonText = reason; try { // reason may be a JSON chat component const parsed = JSON.parse(reason); reasonText = parsed.text || reason; } catch (_) { // use raw string } console.warn(`[OracleBot] Kicked: ${reasonText}`); this.emit('disconnected', { reason: 'kicked', message: reasonText }); }); bot.on('end', (reason) => { console.log(`[OracleBot] Connection ended: ${reason}`); this._bot = null; this._spawned = false; this.emit('disconnected', { reason: 'end', message: reason }); this._scheduleReconnect(); }); } _scheduleReconnect() { if (this._destroyed) return; const delay = this._reconnectDelay; console.log(`[OracleBot] Reconnecting in ${delay}ms ...`); this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; // Exponential backoff: double delay up to max this._reconnectDelay = Math.min(this._reconnectDelay * 2, RECONNECT_MAX_MS); this.connect(); }, delay); } } module.exports = OracleBot;