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:
@@ -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;
|
||||
Generated
+1841
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user