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