feat(oracle): scaffold project + mineflayer spectator bot

Adds oracle-bot/ with package.json and bot.js. OracleBot connects to
the Paper 1.21.11 dev server (offline auth), auto-enters spectator mode,
exposes getPlayers/scanArea/getNearbyEntities/getWorldInfo/followPlayer,
and reconnects with exponential backoff (1s→30s max).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Seth
2026-03-22 04:07:06 -04:00
parent 924f16b9da
commit 3510f0f571
3 changed files with 2144 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
'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;
+1841
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "oracle-bot",
"version": "0.1.0",
"description": "Mortdecai Mind's Eye — live AI vision viewport",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js --dev"
},
"dependencies": {
"mineflayer": "^4.23.0",
"express": "^4.21.0",
"ws": "^8.18.0"
}
}