From 08aff00b7f34bc372f21771bfcb357832d23441d Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 2 Mar 2026 05:57:06 -0500 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20ensemble=20decision=20engine=20?= =?UTF-8?q?=E2=80=94=20panel,=20arbiter,=20LLM-as-judge,=20ChromaDB=20memo?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 3-phase ensemble pipeline used by CloudGrok: - panel.js: queries 4 Grok models in parallel (60s timeout) - arbiter.js: heuristic scoring (length, completeness, action quality, latency); escalates to judge when top-2 within 0.08 margin - judge.js: LLM-as-judge (grok-4) picks best response; 30s timeout with fallback - feedback.js: ChromaDB vector memory (3072-dim Gemini embeddings); retrieves similar past decisions (similarity > 0.6) and injects as [PAST EXPERIENCE] - logger.js: writes every decision to bots/{BotName}/ensemble_log.json - controller.js: EnsembleModel class — drop-in replacement for any single model Integration: profile.model = 'ensemble' routes through EnsembleModel, which implements the same sendRequest() interface as all other model providers. --- src/ensemble/arbiter.js | 156 ++++++++++++++++++++++++++++++ src/ensemble/controller.js | 190 +++++++++++++++++++++++++++++++++++++ src/ensemble/feedback.js | 178 ++++++++++++++++++++++++++++++++++ src/ensemble/judge.js | 92 ++++++++++++++++++ src/ensemble/logger.js | 114 ++++++++++++++++++++++ src/ensemble/panel.js | 146 ++++++++++++++++++++++++++++ 6 files changed, 876 insertions(+) create mode 100644 src/ensemble/arbiter.js create mode 100644 src/ensemble/controller.js create mode 100644 src/ensemble/feedback.js create mode 100644 src/ensemble/judge.js create mode 100644 src/ensemble/logger.js create mode 100644 src/ensemble/panel.js diff --git a/src/ensemble/arbiter.js b/src/ensemble/arbiter.js new file mode 100644 index 000000000..245528a4c --- /dev/null +++ b/src/ensemble/arbiter.js @@ -0,0 +1,156 @@ +import { commandExists } from '../agent/commands/index.js'; + +export class Arbiter { + /** + * @param {Object} config + * @param {string} config.strategy - "heuristic" (Phase 1) or "llm_judge" (Phase 2) + * @param {number} config.majority_bonus - score boost for majority command (default 0.2) + * @param {number} config.latency_penalty_per_sec - penalty per second of latency (default 0.02) + */ + constructor(config = {}) { + this.strategy = config.strategy || 'heuristic'; + this.majorityBonus = config.majority_bonus ?? 0.2; + this.latencyPenalty = config.latency_penalty_per_sec ?? 0.02; + this._confidenceThreshold = config.confidence_threshold ?? 0.08; + this._lastConfidence = 1.0; // set after each pick() + } + + /** + * Confidence threshold for triggering LLM judge. + * If top 2 scores are within this margin, it's "low confidence". + */ + get confidenceThreshold() { + return this._confidenceThreshold ?? 0.08; + } + + /** + * Pick the best proposal from the panel's responses. + * Also sets `this._lastConfidence` for the controller to check. + * @param {Proposal[]} proposals - all proposals (may include failures) + * @returns {Proposal} - the winning proposal with `score` and `winReason` set + */ + pick(proposals) { + const successful = proposals.filter(p => p.status === 'success'); + + if (successful.length === 0) { + return { + agentId: 'none', + modelName: 'none', + response: "I'm having trouble thinking right now. Let me try again in a moment.", + command: null, + commandArgs: null, + preCommandText: '', + latencyMs: 0, + status: 'error', + error: 'All panel members failed', + score: 0, + winReason: 'fallback' + }; + } + + // Score each proposal + for (const p of successful) { + p.score = this._scoreProposal(p); + } + + // Find majority command and apply bonus + const majorityCommand = this._findMajorityCommand(successful); + if (majorityCommand) { + for (const p of successful) { + if (p.command === majorityCommand) { + p.score += this.majorityBonus; + } + } + } + + // Apply latency penalty (tiebreaker) + for (const p of successful) { + p.score -= this.latencyPenalty * (p.latencyMs / 1000); + } + + // Sort: highest score first, then fastest (lowest latency) + successful.sort((a, b) => { + if (Math.abs(b.score - a.score) > 0.001) return b.score - a.score; + return a.latencyMs - b.latencyMs; + }); + + const winner = successful[0]; + winner.winReason = majorityCommand && winner.command === majorityCommand + ? 'majority+highest_score' + : 'highest_score'; + + // Compute confidence: margin between top 2 scores + this._lastConfidence = successful.length >= 2 + ? successful[0].score - successful[1].score + : 1.0; + + return winner; + } + + /** + * Returns true if the last pick() result had low confidence + * and an LLM judge should be consulted. + */ + isLowConfidence() { + return this._lastConfidence < this._confidenceThreshold; + } + + /** + * Compute heuristic score for a proposal. + * @param {Proposal} proposal + * @returns {number} score between 0.0 and ~1.0 + */ + _scoreProposal(proposal) { + let score = 0; + const r = proposal.response || ''; + + // Non-empty response + if (r.trim().length > 0) score += 0.10; + + // Contains a command + if (proposal.command) score += 0.25; + + // Command exists in the game's registry + if (proposal.command && commandExists(proposal.command)) score += 0.15; + + // No hallucination markers + const hallucinations = ['(FROM OTHER BOT)', 'My brain disconnected', 'Error:']; + if (!hallucinations.some(h => r.includes(h))) score += 0.15; + + // Reasonable length (not too short, not too long) + if (r.length > 5 && r.length < 2000) score += 0.10; + + // Not a tab-only or whitespace-only response + if (r.trim().length > 1) score += 0.10; + + // Has pre-command reasoning text (shows the model "thought") + if (proposal.preCommandText && proposal.preCommandText.trim().length > 0) score += 0.05; + + // Response contains actual content words (not just a command) + if (r.replace(/![a-zA-Z]+\(.*?\)/g, '').trim().length > 3) score += 0.10; + + return score; + } + + /** + * Find the command that appears most among proposals. + * @param {Proposal[]} proposals - successful proposals only + * @returns {string|null} majority command or null + */ + _findMajorityCommand(proposals) { + const commands = proposals.map(p => p.command).filter(Boolean); + if (commands.length === 0) return null; + + const counts = {}; + for (const c of commands) { + counts[c] = (counts[c] || 0) + 1; + } + + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); + // Majority: top command appears more than once AND strictly more than runner-up + if (sorted[0][1] > 1 && (sorted.length === 1 || sorted[0][1] > sorted[1][1])) { + return sorted[0][0]; + } + return null; + } +} diff --git a/src/ensemble/controller.js b/src/ensemble/controller.js new file mode 100644 index 000000000..01c96f2f1 --- /dev/null +++ b/src/ensemble/controller.js @@ -0,0 +1,190 @@ +import { Panel } from './panel.js'; +import { Arbiter } from './arbiter.js'; +import { LLMJudge } from './judge.js'; +import { EnsembleLogger } from './logger.js'; +import { FeedbackCollector } from './feedback.js'; + +/** + * EnsembleModel — implements the same interface as any single model class + * (Gemini, Grok, etc.) so it can be used as a drop-in replacement for chat_model + * in the Prompter class. + * + * Instead of a single LLM call, it queries a panel of models in parallel, + * runs an arbiter to pick the best response, and returns the winning string. + */ +export class EnsembleModel { + static prefix = 'ensemble'; + + /** + * @param {Object} ensembleConfig - the profile.ensemble configuration block + * @param {Object} profile - the full bot profile (for context/name) + */ + constructor(ensembleConfig, profile) { + this.model_name = 'ensemble'; + this.profile = profile; + + this.panel = new Panel( + ensembleConfig.panel, + ensembleConfig.timeout_ms || 15000 + ); + this.arbiter = new Arbiter(ensembleConfig.arbiter || {}); + this.judge = ensembleConfig.judge !== false + ? new LLMJudge(ensembleConfig.judge || {}) + : null; + this.logger = new EnsembleLogger(profile.name); + this.feedback = new FeedbackCollector(); + + this.minResponses = ensembleConfig.min_responses || 2; + this.logDecisions = ensembleConfig.log_decisions !== false; + + // Usage tracking compatibility (Prompter reads this after each call) + this._lastUsage = null; + this._lastUsageByModel = null; + + console.log(`[Ensemble] Initialized for ${profile.name}: ${this.panel.members.length} panel members`); + } + + /** + * Phase 3: inject the shared embedding model into FeedbackCollector. + * Called by Prompter after both chat_model and embedding_model are ready. + */ + setEmbeddingModel(embeddingModel) { + this.feedback.setEmbeddingModel(embeddingModel); + } + + /** + * Standard model interface — called by Prompter.promptConvo(). + * Queries all panel members, arbitrates, returns winning response. + * + * @param {Array<{role:string, content:string}>} turns - conversation history + * @param {string} systemMessage - the built system prompt + * @returns {Promise} - the winning response text + */ + async sendRequest(turns, systemMessage) { + const startTime = Date.now(); + + // Phase 3: retrieve similar past experiences to augment context + let augmentedSystem = systemMessage; + if (this.feedback.isReady) { + const situationText = turns.filter(t => t.role === 'user').slice(-2) + .map(t => t.content).join(' '); + const experiences = await this.feedback.getSimilar(situationText, 3); + if (experiences.length > 0) { + const memBlock = experiences.map(e => { + const m = e.metadata; + const outcome = m.outcome && m.outcome !== 'pending' ? ` (outcome: ${m.outcome})` : ''; + return `- Situation: "${e.document.slice(0, 120)}" → action: ${m.winner_command || 'chat'}${outcome}`; + }).join('\n'); + augmentedSystem = systemMessage + `\n\n[PAST EXPERIENCE - similar situations]\n${memBlock}`; + console.log(`[Ensemble] Injected ${experiences.length} past experience(s) into context`); + } + } + + // Query all panel members in parallel + const proposals = await this.panel.queryAll(turns, augmentedSystem); + + const successful = proposals.filter(p => p.status === 'success'); + const failed = proposals.filter(p => p.status !== 'success'); + + if (failed.length > 0) { + const failSummary = failed.map(p => `${p.agentId}:${p.status}`).join(', '); + console.log(`[Ensemble] Panel failures: ${failSummary}`); + } + + if (successful.length < this.minResponses) { + console.warn(`[Ensemble] Only ${successful.length}/${this.panel.members.length} responses (need ${this.minResponses})`); + if (successful.length === 0) { + this._lastUsage = null; + return "I'm having trouble processing right now. Let me try again."; + } + } + + // Heuristic arbiter — always runs first + let winner = this.arbiter.pick(proposals); + let judgeUsed = false; + + // Phase 2: LLM judge fallback when heuristic confidence is low + if (this.judge && this.arbiter.isLowConfidence() && successful.length >= 2) { + console.log(`[Ensemble] Low confidence (margin=${this.arbiter._lastConfidence.toFixed(3)}), consulting LLM judge...`); + try { + const judgeId = await this.judge.judge(successful, systemMessage, turns); + if (judgeId) { + const judgeWinner = successful.find(p => p.agentId === judgeId); + if (judgeWinner) { + judgeWinner.winReason = 'llm_judge'; + winner = judgeWinner; + judgeUsed = true; + console.log(`[Ensemble] Judge overruled heuristic: winner=${judgeId}`); + } + } + } catch (err) { + console.warn(`[Ensemble] Judge error, keeping heuristic winner: ${err.message}`); + } + } + + const totalMs = Date.now() - startTime; + console.log( + `[Ensemble] Decision in ${totalMs}ms: ` + + `${successful.length}/${this.panel.members.length} responded, ` + + `winner=${winner.agentId} (${winner.command || 'chat'}, score=${winner.score?.toFixed(2)})` + + (judgeUsed ? ' [judge]' : '') + ); + + // Log decision + if (this.logDecisions) { + this.logger.logDecision(proposals, winner); + } + + // Phase 3: Record decision in ChromaDB for continuous learning + const situationText = turns.filter(t => t.role === 'user').slice(-2) + .map(t => t.content).join(' '); + this.feedback.recordDecision({ + winner, + proposals, + timestamp: Date.now(), + situationText + }); + + // Aggregate usage from all successful members + this._lastUsage = this._aggregateUsage(successful); + this._lastUsageByModel = this._buildUsageBreakdown(successful); + + return winner.response; + } + + /** + * Embeddings are not supported by the ensemble — the Prompter uses + * a separate embedding model configured in the profile. + */ + async embed(_text) { + throw new Error('Embeddings not supported by EnsembleModel. Configure a separate embedding model in the profile.'); + } + + /** + * Sum token usage across all panel members for cost tracking. + */ + _aggregateUsage(proposals) { + let prompt = 0, completion = 0; + for (const p of proposals) { + if (p.usage) { + prompt += p.usage.prompt_tokens || 0; + completion += p.usage.completion_tokens || 0; + } + } + if (prompt === 0 && completion === 0) return null; + return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion }; + } + + _buildUsageBreakdown(proposals) { + const breakdown = []; + for (const p of proposals) { + if (!p.usage) continue; + breakdown.push({ + modelName: p.modelName || 'unknown', + provider: p.provider || 'unknown', + usage: p.usage + }); + } + return breakdown.length > 0 ? breakdown : null; + } +} diff --git a/src/ensemble/feedback.js b/src/ensemble/feedback.js new file mode 100644 index 000000000..f784d2050 --- /dev/null +++ b/src/ensemble/feedback.js @@ -0,0 +1,178 @@ +import { ChromaClient } from 'chromadb'; + +const COLLECTION_NAME = 'ensemble_memory'; +const CHROMADB_URL = process.env.CHROMADB_URL || 'http://localhost:8000'; + +/** Ensure embedding is a flat array of numbers */ +function flattenEmbedding(raw) { + if (!raw) return null; + // Already a flat number array + if (Array.isArray(raw) && (raw.length === 0 || typeof raw[0] === 'number')) return raw; + // Array of objects with values (e.g. [{values:[...]}]) + if (Array.isArray(raw) && raw[0]?.values) return raw[0].values; + // Object with values + if (raw.values && Array.isArray(raw.values)) return raw.values; + // Object with embedding.values + if (raw.embedding?.values) return raw.embedding.values; + return null; +} + +export class FeedbackCollector { + constructor() { + this._client = null; + this._collection = null; + this._ready = false; + this._embedFn = null; + this._decisionCount = 0; + this._lastDecisionId = null; + this._initAsync(); + } + + setEmbeddingModel(model) { + this._embedFn = async (text) => { + const raw = await model.embed(text); + return flattenEmbedding(raw); + }; + } + + async _initAsync() { + try { + this._client = new ChromaClient({ path: CHROMADB_URL }); + this._collection = await this._client.getOrCreateCollection({ + name: COLLECTION_NAME, + metadata: { 'hnsw:space': 'cosine' } + }); + this._ready = true; + console.log(`[Feedback] ChromaDB ready at ${CHROMADB_URL}, collection: ${COLLECTION_NAME}`); + } catch (err) { + console.warn(`[Feedback] ChromaDB unavailable (${err.message}). Running without vector memory.`); + this._ready = false; + } + } + + async recordDecision(decision) { + if (!this._ready || !this._embedFn) return; + + // Hoist variables so the catch block can access them for retry + let id = null; + let cleanEmb = null; + let text = ''; + let meta = null; + + try { + const { winner, proposals, situationText } = decision; + text = situationText || ''; + if (text.trim().length < 5) return; + + const embedding = await this._embedFn(text.slice(0, 512)); + if (!embedding || !Array.isArray(embedding) || embedding.length === 0) { + console.warn('[Feedback] Invalid embedding, skipping storage'); + return; + } + // Ensure all values are numbers + cleanEmb = embedding.map(v => Number(v)); + if (cleanEmb.some(v => !isFinite(v))) { + console.warn('[Feedback] Embedding contains non-finite values, skipping'); + return; + } + + this._decisionCount++; + const ts = Date.now(); + id = 'dec_' + ts + '_' + this._decisionCount; + this._lastDecisionId = id; + + const successful = proposals.filter(p => p.status === 'success'); + const rawCmd = winner.command; + const rawScore = winner.score; + + meta = { + winner_id: String(winner.agentId || 'unknown'), + winner_command: (rawCmd && typeof rawCmd === 'string') ? rawCmd : '', + winner_score: (typeof rawScore === 'number' && isFinite(rawScore)) ? rawScore : 0, + win_reason: String(winner.winReason || 'highest_score'), + panel_size: Number(proposals.length), + responders: Number(successful.length), + timestamp: Number(ts), + outcome: 'pending' + }; + + await this._collection.add({ + ids: [id], + embeddings: [cleanEmb], + documents: [text.slice(0, 512)], + metadatas: [meta] + }); + console.log('[Feedback] Decision stored in ChromaDB:', id); + } catch (err) { + if (err.message?.includes('already exists')) { + // Skip duplicate IDs + } else if (err.message?.includes('dimension') || err.message?.includes('shape') || err.message?.includes('mismatch')) { + // Dimension mismatch: delete and recreate collection + console.warn('[Feedback] Embedding dimension mismatch, recreating collection'); + try { + await this._client.deleteCollection({ name: COLLECTION_NAME }); + this._collection = await this._client.createCollection({ + name: COLLECTION_NAME, + metadata: { 'hnsw:space': 'cosine' } + }); + // Retry the add + await this._collection.add({ + ids: [id], + embeddings: [cleanEmb], + documents: [text.slice(0, 512)], + metadatas: [meta] + }); + console.log('[Feedback] Decision stored after collection recreation:', id); + } catch (retryErr) { + console.warn('[Feedback] Failed to recreate collection and store decision:', retryErr.message); + } + } else { + console.warn('[Feedback] Failed to record decision:', err.message); + } + } + } + + async recordOutcome(outcome, details) { + if (!this._ready || !this._lastDecisionId) return; + try { + await this._collection.update({ + ids: [this._lastDecisionId], + metadatas: [{ outcome: String(outcome), outcome_detail: String(details || '').slice(0, 200) }] + }); + } catch (err) { + console.warn('[Feedback] Failed to update outcome:', err.message); + } + } + + async getSimilar(situationText, topK) { + if (!this._ready || !this._embedFn) return []; + if (!situationText || situationText.trim().length < 5) return []; + try { + const k = topK || 3; + const embedding = await this._embedFn(situationText.slice(0, 512)); + if (!embedding || !Array.isArray(embedding)) return []; + const cleanEmb = embedding.map(v => Number(v)); + + const results = await this._collection.query({ + queryEmbeddings: [cleanEmb], + nResults: Math.min(k, 10), + include: ['documents', 'metadatas', 'distances'] + }); + + const docs = results.documents?.[0] || []; + const metas = results.metadatas?.[0] || []; + const dists = results.distances?.[0] || []; + + return docs.map((doc, i) => ({ + document: doc, + metadata: metas[i] || {}, + similarity: 1 - (dists[i] || 0) + })).filter(r => r.similarity > 0.6); + } catch (err) { + console.warn('[Feedback] Failed to query similar:', err.message); + return []; + } + } + + get isReady() { return this._ready; } +} diff --git a/src/ensemble/judge.js b/src/ensemble/judge.js new file mode 100644 index 000000000..a8edbe420 --- /dev/null +++ b/src/ensemble/judge.js @@ -0,0 +1,92 @@ +import { selectAPI, createModel } from '../models/_model_map.js'; + +/** + * LLM-as-Judge: when the heuristic arbiter has low confidence, + * a fast judge model reviews all proposals and picks the best one. + */ +export class LLMJudge { + /** + * @param {Object} config + * @param {string} config.model - model name to use as judge (e.g. "gemini-2.5-flash") + * @param {number} config.timeout_ms - max ms to wait for judge (default 10000) + */ + constructor(config = {}) { + this.modelName = config.model || 'gemini-2.5-flash'; + this.timeoutMs = config.timeout_ms || 10000; + this._model = null; + } + + _getModel() { + if (!this._model) { + const profile = selectAPI(this.modelName); + this._model = createModel(profile); + } + return this._model; + } + + /** + * Ask the judge to pick the best proposal. + * @param {Proposal[]} proposals - successful proposals only + * @param {string} systemMessage - the original system prompt (abbreviated) + * @param {Array} turns - last few conversation turns for context + * @returns {Promise} winning agentId, or null if judge fails + */ + async judge(proposals, systemMessage, turns) { + if (proposals.length === 0) return null; + if (proposals.length === 1) return proposals[0].agentId; + + const model = this._getModel(); + + // Build a concise judgment prompt + const lastUserMsg = [...turns].reverse().find(t => t.role === 'user')?.content || ''; + + const proposalText = proposals.map((p, _i) => + `[${p.agentId}] (${p.modelName})\n${p.response}` + ).join('\n\n---\n\n'); + + const judgeSystem = [ + 'You are an expert judge evaluating Minecraft bot AI responses.', + 'Pick the SINGLE best response for the current game situation.', + 'Consider: command correctness, relevance to context, clarity, and safety.', + 'Respond with ONLY the agent ID (e.g. "gemini_a"). No explanation.' + ].join('\n'); + + const judgePrompt = [ + `Current situation: ${lastUserMsg.slice(0, 300)}`, + '', + 'Responses to evaluate:', + proposalText, + '', + `Valid agent IDs: ${proposals.map(p => p.agentId).join(', ')}`, + 'Which response is best? Reply with only the agent ID.' + ].join('\n'); + + const judgeTurns = [{ role: 'user', content: judgePrompt }]; + + try { + const result = await Promise.race([ + model.sendRequest(judgeTurns, judgeSystem), + new Promise((_, reject) => + setTimeout(() => reject(new Error('judge timeout')), this.timeoutMs) + ) + ]); + + // Parse: extract first matching agent ID from the response + const validIds = proposals.map(p => p.agentId); + const trimmedResult = result.trim(); + // Try exact match first (model responded with just the ID) + if (validIds.includes(trimmedResult)) return trimmedResult; + // Fall back to word-boundary regex match + for (const id of validIds) { + const pattern = new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); + if (pattern.test(result)) return id; + } + + console.warn(`[Judge] Could not parse agent ID from response: "${result.slice(0, 100)}"`); + return null; + } catch (err) { + console.warn(`[Judge] Failed: ${err.message}`); + return null; + } + } +} diff --git a/src/ensemble/logger.js b/src/ensemble/logger.js new file mode 100644 index 000000000..50b67bc67 --- /dev/null +++ b/src/ensemble/logger.js @@ -0,0 +1,114 @@ +import { writeFile, readFile, mkdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; + +const MAX_ENTRIES = 500; +const TRIM_TO = 400; + +export class EnsembleLogger { + constructor(agentName) { + this.agentName = agentName; + this.dir = `./bots/${agentName}`; + this.filePath = path.join(this.dir, 'ensemble_log.json'); + this.decisionCount = 0; + + this._ready = !existsSync(this.dir) + ? mkdir(this.dir, { recursive: true }) + : Promise.resolve(); + } + + async logDecision(allProposals, winner) { + this.decisionCount++; + + const successful = allProposals.filter(p => p.status === 'success'); + const commands = successful.map(p => p.command).filter(Boolean); + const uniqueCommands = [...new Set(commands)]; + const agreement = uniqueCommands.length <= 1 && commands.length > 0 + ? 1.0 + : commands.length > 0 + ? Math.max(...uniqueCommands.map(c => commands.filter(x => x === c).length)) / commands.length + : 0; + + const entry = { + timestamp: new Date().toISOString(), + decision_id: this.decisionCount, + proposals: allProposals.map(p => ({ + agent_id: p.agentId, + model: p.modelName, + status: p.status, + command: p.command || null, + pre_text: p.preCommandText ? p.preCommandText.slice(0, 100) : '', + score: p.score ?? null, + latency_ms: p.latencyMs, + error: p.error || null + })), + winner: winner ? { + agent_id: winner.agentId, + command: winner.command, + score: winner.score, + reason: winner.winReason || 'highest_score' + } : null, + majority_command: this._findMajority(commands), + panel_agreement: Math.round(agreement * 100) / 100 + }; + + await this._ready; + let log = await this._readLog(); + log.push(entry); + + if (log.length > MAX_ENTRIES) { + log = log.slice(log.length - TRIM_TO); + } + + try { + await writeFile(this.filePath, JSON.stringify(log, null, 2)); + } catch (err) { + console.error(`[Ensemble] Failed to write log: ${err.message}`); + } + } + + async getStats() { + const log = await this._readLog(); + const wins = {}; + let totalLatency = 0; + let latencyCount = 0; + + for (const entry of log) { + if (entry.winner?.agent_id) { + wins[entry.winner.agent_id] = (wins[entry.winner.agent_id] || 0) + 1; + } + for (const p of entry.proposals) { + if (p.status === 'success' && p.latency_ms) { + totalLatency += p.latency_ms; + latencyCount++; + } + } + } + + return { + total_decisions: log.length, + per_member_wins: wins, + avg_latency_ms: latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0 + }; + } + + async _readLog() { + try { + const raw = await readFile(this.filePath, 'utf8'); + return JSON.parse(raw); + } catch { + // file missing or corrupted — start fresh + } + return []; + } + + _findMajority(commands) { + if (commands.length === 0) return null; + const counts = {}; + for (const c of commands) { + counts[c] = (counts[c] || 0) + 1; + } + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); + return sorted[0][1] > 1 ? sorted[0][0] : null; + } +} diff --git a/src/ensemble/panel.js b/src/ensemble/panel.js new file mode 100644 index 000000000..4a7732722 --- /dev/null +++ b/src/ensemble/panel.js @@ -0,0 +1,146 @@ +import { selectAPI, createModel } from '../models/_model_map.js'; +import { containsCommand } from '../agent/commands/index.js'; + +/** + * @typedef {Object} Proposal + * @property {string} agentId - Panel member ID (e.g., "gemini_a") + * @property {string} modelName - Model name (e.g., "gemini-2.5-pro") + * @property {string} provider - Provider prefix (e.g., "gemini", "xai") + * @property {string} response - Raw response string from the model + * @property {string|null} command - Extracted command (e.g., "!attackEntity") or null + * @property {string} preCommandText - Text before the first command + * @property {number} latencyMs - Time taken for this model's response + * @property {string} status - "success" | "error" | "timeout" + * @property {string|null} error - Error message if status !== "success" + * @property {number|null} score - Set by Arbiter + */ + +export class Panel { + /** + * @param {Array<{id: string, model: string}>} memberConfigs - panel member definitions + * @param {number} timeoutMs - per-model timeout in ms (default 15000) + */ + constructor(memberConfigs, timeoutMs = 15000) { + this.timeoutMs = timeoutMs; + this.members = []; + + for (const config of memberConfigs) { + try { + const profile = selectAPI(config.model); + const model = createModel(profile); + this.members.push({ + id: config.id, + model: model, + modelName: config.model + }); + console.log(`[Ensemble Panel] Loaded: ${config.id} → ${config.model}`); + } catch (err) { + console.error(`[Ensemble Panel] Failed to load ${config.id} (${config.model}): ${err.message}`); + } + } + + if (this.members.length === 0) { + throw new Error('[Ensemble Panel] No panel members loaded. Check profile.ensemble.panel config.'); + } + + console.log(`[Ensemble Panel] Ready: ${this.members.length} members, ${this.timeoutMs}ms timeout`); + } + + /** + * Query all panel members in parallel with timeout. + * Uses Promise.allSettled — one failure won't block others. + * + * @param {Array} turns - conversation turns [{role, content}] + * @param {string} systemMessage - the system prompt + * @returns {Promise} - all proposals (includes failures) + */ + async queryAll(turns, systemMessage) { + const promises = this.members.map(member => this._queryMember(member, turns, systemMessage)); + const results = await Promise.allSettled(promises); + + return results.map((result, i) => { + if (result.status === 'fulfilled') { + return result.value; + } + // Promise rejected (shouldn't happen since _queryMember catches, but just in case) + return { + agentId: this.members[i].id, + modelName: this.members[i].modelName, + response: '', + command: null, + preCommandText: '', + latencyMs: this.timeoutMs, + status: 'error', + error: result.reason?.message || 'Unknown error', + score: null + }; + }); + } + + /** + * Query a single panel member with timeout protection. + * @param {Object} member - {id, model, modelName} + * @param {Array} turns + * @param {string} systemMessage + * @returns {Promise} + */ + async _queryMember(member, turns, systemMessage) { + const startTime = Date.now(); + let timer = null; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timeout')), this.timeoutMs); + }); + + try { + const response = await Promise.race([ + member.model.sendRequest(turns, systemMessage), + timeoutPromise + ]); + + clearTimeout(timer); + const latencyMs = Date.now() - startTime; + const responseStr = typeof response === 'string' ? response : String(response || ''); + const command = containsCommand(responseStr); + + // Extract text before the command (pre-command reasoning) + let preCommandText = ''; + if (command) { + const cmdIndex = responseStr.indexOf(command); + if (cmdIndex > 0) { + preCommandText = responseStr.slice(0, cmdIndex).trim(); + } + } + + return { + agentId: member.id, + modelName: member.modelName, + provider: member.model.constructor?.prefix || 'unknown', + response: responseStr, + command: command, + preCommandText: preCommandText, + latencyMs: latencyMs, + status: 'success', + error: null, + score: null, + usage: member.model._lastUsage || null + }; + } catch (err) { + clearTimeout(timer); + const latencyMs = Date.now() - startTime; + const isTimeout = err.message === 'timeout'; + + return { + agentId: member.id, + modelName: member.modelName, + response: '', + command: null, + preCommandText: '', + latencyMs: latencyMs, + status: isTimeout ? 'timeout' : 'error', + error: err.message, + score: null + }; + } + } +} From 20227e7e5d799e5f9515abe0d0a92c61810e2e3d Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 2 Mar 2026 05:57:35 -0500 Subject: [PATCH 2/7] feat: Ender Dragon automation, RC13-RC29 skill improvements, and Baritone pathfinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ender Dragon automation (dragon_runner.js + dragon_progress.js): - 6 gameplay chunks: getDiamondPickaxe, buildNetherPortal, collectBlazeRods, collectEnderPearls, locateStronghold, defeatEnderDragon - Persistent state (dragon_progress.json) with atomic writes and corruption recovery - 5 retries per chunk with exponential backoff; death recovery returns to drop coords - !beatMinecraft / !dragonProgression commands (120-180 min timeout) Baritone A* pathfinding (RC25+): - Custom A* pathfinder replacing mineflayer-pathfinder; distance-adaptive timeouts - Ghost block handling for Paper servers (re-fetch block after nav) - isClearPath() with Baritone integration; null-guard on isWalkable for respawn Skills RC13-RC29 (skills.js + world.js): - RC13: safeToBreak filter fix for tree logs - RC14-17: multi-hop smart explore (200-block relocation), water avoidance, wider collect range - RC18-20: resilient collectBlock — retry on combat, exclude failed positions - RC22: Aikar GC flags; RC23: bypass bot.collectBlock.collect() for Paper - RC24: timeout-protected dig (goToPosition 15s, dig 10s, pickup 8s) - RC26: prefer doors over block-breaking; stale dig fix (re-fetch after nav) - RC27: 9 runtime bug fixes from log review --- docs/DRAGON_SLAYER_RC29.md | 173 ++ ...@miner-org+mineflayer-baritone+4.5.0.patch | 12 + src/agent/commands/actions.js | 155 +- src/agent/library/dragon_progress.js | 358 ++++ src/agent/library/dragon_runner.js | 1549 +++++++++++++++++ src/agent/library/progress_reporter.js | 231 +++ src/agent/library/skills.js | 1517 ++++++++++++++-- src/agent/library/world.js | 31 +- tasks/dragon/blaze_rods.json | 15 + tasks/dragon/diamond_pickaxe.json | 17 + tasks/dragon/ender_dragon.json | 22 + tasks/dragon/ender_pearls.json | 15 + tasks/dragon/full_run.json | 13 + tasks/dragon/nether_portal.json | 13 + tasks/dragon/stronghold.json | 13 + 15 files changed, 3960 insertions(+), 174 deletions(-) create mode 100644 docs/DRAGON_SLAYER_RC29.md create mode 100644 patches/@miner-org+mineflayer-baritone+4.5.0.patch create mode 100644 src/agent/library/dragon_progress.js create mode 100644 src/agent/library/dragon_runner.js create mode 100644 src/agent/library/progress_reporter.js create mode 100644 tasks/dragon/blaze_rods.json create mode 100644 tasks/dragon/diamond_pickaxe.json create mode 100644 tasks/dragon/ender_dragon.json create mode 100644 tasks/dragon/ender_pearls.json create mode 100644 tasks/dragon/full_run.json create mode 100644 tasks/dragon/nether_portal.json create mode 100644 tasks/dragon/stronghold.json diff --git a/docs/DRAGON_SLAYER_RC29.md b/docs/DRAGON_SLAYER_RC29.md new file mode 100644 index 000000000..c001ecc11 --- /dev/null +++ b/docs/DRAGON_SLAYER_RC29.md @@ -0,0 +1,173 @@ +# Dragon Slayer RC29 — Autonomous Ender Dragon System + +> **Status: Live** — DragonSlayer is running on local Windows PC (RTX 3090, `sweaterdog/andy-4:q8_0` via Ollama) connected to Paper 1.21.11 on AWS EC2 (`54.152.239.117:19565`). RC29 persistent state saving active. MindServer HUD: `http://localhost:8080`. + +## Executive Summary + +RC29 upgrades Mindcraft's dragon progression system from a fragile single-run pipeline into a **persistent, death-surviving, restart-resilient autonomous Ender Dragon slayer**. + +Key improvements: +- **Persistent state** (`dragon_progress.json`) survives crashes, restarts, and deaths via atomic JSON writes +- **Smart orchestrator** with exponential backoff (5 retries per chunk), death recovery, gear re-acquisition +- **Dimension awareness** — tracks overworld/nether/end transitions +- **Pre-chunk preparation** — proactive food stockpiling, gear checks, inventory management +- **`!beatMinecraft` command** — single-command alias for the full autonomous run +- **Milestone tracking** — records highest resource counts ever achieved (not just current inventory) + +--- + +## New Files + +### `src/agent/library/dragon_progress.js` (~360 lines) +Persistent Dragon Progression state machine. + +| Feature | Detail | +|---------|--------| +| **State schema** | version, chunks status map (6 chunks), coords (7 named positions), milestones (7 items), stats (deaths, retries, dimension), dragonFight state | +| **Persistence** | Atomic `.tmp` + `renameSync` pattern (same as `history.js` RC27) | +| **Corruption recovery** | Renames corrupted save to `.corrupted.`, starts fresh | +| **API** | `load()`, `save()`, `currentChunk()`, `markChunkActive/Done/Failed()`, `setCoord()`, `updateMilestones()`, `recordDeath()`, `getSummary()` | +| **LLM integration** | `getSummary()` returns compact text for prompt injection | + +### `docs/DRAGON_SLAYER_RC29.md` (this file) +Documentation, testing plan, and quick-start guide. + +--- + +## Modified Files + +### `src/agent/library/dragon_runner.js` +**Header/imports:** Added `import { DragonProgress, CHUNKS }` from `dragon_progress.js`. Added `getDimension()` helper. + +**New functions:** +- `prepareForChunk(bot, chunkName, progress)` — adapts gear/food prep to target chunk +- `recoverFromDeath(bot, progress)` — goes to death location, picks up items, re-crafts lost tools + +**Orchestrator rewrite** (`runDragonProgression`): +- Loads `DragonProgress` on entry, saves after each chunk transition +- Registers `bot.on('death')` handler to record death position + save state +- 5 retries per chunk (up from 3) with exponential backoff (1s → 2s → 4s → 8s → 16s, max 30s) +- `runner.check()` consults both inventory AND persistent state (e.g., milestones) +- `runner.onSuccess()` hooks save key coordinates (portal, fortress, stronghold, end portal positions) +- Death recovery between retries: respawn wait → go to death pos → pickup items → re-craft tools +- Explore to fresh area on retry (100 + 50*retryCount blocks) +- `finally` block always removes death listener + +**Chunk functions:** Unchanged (proven gameplay logic preserved). + +### `src/agent/commands/actions.js` +- Updated `!dragonProgression` timeout from 120min to 180min, description updated +- Added `!beatMinecraft` command (alias for `runDragonProgression`, 180min timeout) + +--- + +## Updated Profiles + +### `profiles/dragon-slayer.json` +- System prompt mentions `!beatMinecraft` and persistent progress +- Death recovery example updated: "Died! Progress is saved. !beatMinecraft" +- Self-prompt updated to lead with `!beatMinecraft` +- All conversation examples using `!dragonProgression` → `!beatMinecraft` + +### `profiles/local-research.json` +- System prompt rule 19 updated to mention `!beatMinecraft` + persistent progress +- Added rule 22: "After dying, progress is saved — just re-run !beatMinecraft to resume." +- Conversation example for "defeat the ender dragon" → `!beatMinecraft` +- Self-prompt updated to lead with `!beatMinecraft` + +--- + +## Testing Plan + +### Unit-level Verification + +| Test | How | Expected | +|------|-----|----------| +| **JSON parse** | `node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json'))"` | No error | +| **Import chain** | `node -e "import('./src/agent/library/dragon_runner.js').then(m => console.log(Object.keys(m)))"` | Exports: `buildNetherPortal`, `collectBlazeRods`, `collectEnderPearls`, `locateStronghold`, `defeatEnderDragon`, `runDragonProgression` | +| **Progress persistence** | Create DragonProgress, save, reload, verify state matches | State round-trips correctly | +| **Lint** | `npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js` | 0 errors, 0 warnings | +| **Command registration** | Start bot, check `!help` output includes `!beatMinecraft` | Listed with description | + +### Integration Tests (Manual) + +1. **Fresh start**: New world → `!beatMinecraft` → observe Chunk 1 (diamond pickaxe) begins +2. **Persistence**: Kill bot process mid-chunk → restart → `!beatMinecraft` → resumes from last incomplete chunk (not from scratch) +3. **Death recovery**: Let bot die during Chunk 3 (blaze rods) → observe death handler fires → on retry, bot goes to death pos, recovers items +4. **Exponential backoff**: Make chunk fail (e.g., block all iron spawns) → observe increasing backoff delays in logs +5. **Full run**: Fresh world → `!beatMinecraft` → dragon defeated (target: < 3 hours game time) +6. **Individual chunks**: `!getDiamondPickaxe` → `!buildNetherPortal` → etc. still work independently +7. **Interrupt**: Mid-run `!stop` → bot stops → `!beatMinecraft` → resumes from saved state + +### Smoke Test Script + +```bash +# 1. Validate all files +npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js + +# 2. Validate profiles +node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json','utf8')); console.log('OK')" +node -e "JSON.parse(require('fs').readFileSync('profiles/local-research.json','utf8')); console.log('OK')" + +# 3. Validate imports +node --input-type=module -e "import { runDragonProgression, buildNetherPortal, collectBlazeRods, collectEnderPearls, locateStronghold, defeatEnderDragon } from './src/agent/library/dragon_runner.js'; console.log('All exports OK')" + +# 4. Run bot with dragon-slayer profile +node main.js --profiles ./profiles/dragon-slayer.json +``` + +--- + +## Quick-Start Guide + +### Prerequisites +- Node.js v18+ (v20 LTS recommended) +- Minecraft server running — EC2 at `54.152.239.117:19565` (Paper 1.21.11), or any Paper 1.21.x server with `settings.js` updated to match +- Ollama running locally with `sweaterdog/andy-4:q8_0`, `nomic-embed-text`, and `llava` pulled: `ollama pull sweaterdog/andy-4:q8_0 && ollama pull nomic-embed-text && ollama pull llava` +- `npm install` completed + +### Option A: DragonSlayer Bot (dedicated profile) +```bash +node main.js --profiles ./profiles/dragon-slayer.json +``` +The bot will self-prompt and begin `!beatMinecraft` automatically. + +### Option B: Any Bot, Manual Trigger +```bash +# Start your preferred bot +node main.js --profiles ./profiles/local-research.json + +# In Minecraft chat: +DragonSlayer, !beatMinecraft +``` + +### Option C: Individual Chunks +``` +!getDiamondPickaxe # Chunk 1 +!buildNetherPortal # Chunk 2 +!collectBlazeRods(12) # Chunk 3 +!collectEnderPearls(12) # Chunk 4 +!locateStronghold # Chunk 5 +!defeatEnderDragon # Chunk 6 +``` + +### Monitoring Progress +The persistent state is saved at `bots//dragon_progress.json`. You can inspect it: +```bash +cat bots/DragonSlayer/dragon_progress.json | python -m json.tool +``` + +### Resetting Progress +Delete the state file to start fresh: +```bash +rm bots/DragonSlayer/dragon_progress.json +``` + +### Troubleshooting +| Issue | Fix | +|-------|-----| +| Bot stuck in a loop | `!stop` then `!beatMinecraft` to resume from saved state | +| Bot keeps dying | Check food supply; modes `auto_eat` and `panic_defense` must be `true` | +| "Chunk X failed after 5 attempts" | Manual intervention needed: explore to better biome, ensure pickaxe/food, then `!beatMinecraft` | +| Bot won't enter Nether | Ensure `flint_and_steel` + obsidian portal exists; try `!buildNetherPortal` individually | +| State file corrupted | Delete `dragon_progress.json` and restart | diff --git a/patches/@miner-org+mineflayer-baritone+4.5.0.patch b/patches/@miner-org+mineflayer-baritone+4.5.0.patch new file mode 100644 index 000000000..9c9557019 --- /dev/null +++ b/patches/@miner-org+mineflayer-baritone+4.5.0.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js b/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js +index ae7decf..9c615ad 100644 +--- a/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js ++++ b/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js +@@ -323,6 +323,7 @@ class Move { + const block = this.getBlock(node); + if (!block) return false; + const above = this.getBlock(node.offset(0, 1, 0)); ++ if (!above) return false; + return ( + !this.isScaffolding(node) && + block.boundingBox === "empty" && diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index f348487ed..b73860626 100644 --- a/src/agent/commands/actions.js +++ b/src/agent/commands/actions.js @@ -1,4 +1,5 @@ import * as skills from '../library/skills.js'; +import * as dragonRunner from '../library/dragon_runner.js'; import settings from '../settings.js'; import convoManager from '../conversation.js'; @@ -20,7 +21,7 @@ function runAsAction (actionFn, resume = false, timeout = -1) { if (code_return.interrupted && !code_return.timedout) return; return code_return.message; - } + }; return wrappedAction; } @@ -32,7 +33,7 @@ export const actionsList = [ params: { 'prompt': { type: 'string', description: 'A natural language prompt to guide code generation. Make a detailed step-by-step plan.' } }, - perform: async function(agent, prompt) { + perform: async function(agent, _prompt) { // just ignore prompt - it is now in context in chat history if (!settings.allow_insecure_coding) { agent.openChat('newAction is disabled. Enable with allow_insecure_coding=true in settings.js'); @@ -69,7 +70,7 @@ export const actionsList = [ description: 'Stop all chatting and self prompting, but continue current action.', perform: async function (agent) { agent.openChat('Shutting up.'); - agent.shutUp(); + await agent.shutUp(); return; } }, @@ -132,7 +133,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, block_type, range) => { if (range < 32) { - log(agent.bot, `Minimum search range is 32.`); + skills.log(agent.bot, `Minimum search range is 32.`); range = 32; } await skills.goToNearestBlock(agent.bot, block_type, 4, range); @@ -157,6 +158,14 @@ export const actionsList = [ await skills.moveAway(agent.bot, distance); }) }, + { + name: '!explore', + description: 'Move to a random location in a new direction to find fresh resources. Use when current area is depleted or you keep collecting 0.', + params: {'distance': { type: 'float', description: 'The distance to explore. Default 40. Use 60-100 if nearby areas are empty.', domain: [10, 200, '[]'] }}, + perform: runAsAction(async (agent, distance) => { + await skills.explore(agent.bot, distance); + }) + }, { name: '!rememberHere', description: 'Save the current location with a given name.', @@ -260,7 +269,14 @@ export const actionsList = [ 'num': { type: 'int', description: 'The number of blocks to collect.', domain: [1, Number.MAX_SAFE_INTEGER] } }, perform: runAsAction(async (agent, type, num) => { - await skills.collectBlock(agent.bot, type, num); + try { + const result = await skills.collectBlock(agent.bot, type, num); + console.log(`[DEBUG] collectBlock returned: ${result}`); + return result; + } catch (err) { + console.error(`[DEBUG] collectBlock error: ${err.message}`); + throw err; + } }, false, 10) // 10 minute timeout }, { @@ -320,6 +336,10 @@ export const actionsList = [ description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', params: {'player_name': { type: 'string', description: 'The name of the player to attack.'}}, perform: runAsAction(async (agent, player_name) => { + if (convoManager.isOtherAgent(player_name)) { + skills.log(agent.bot, `Cannot attack teammate bot ${player_name}! Team-killing is blocked.`); + return false; + } let player = agent.bot.players[player_name]?.entity; if (!player) { skills.log(agent.bot, `Could not find player ${player_name}.`); @@ -477,7 +497,7 @@ export const actionsList = [ description: 'Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.', params: {'distance': { type: 'int', description: 'Distance to dig down', domain: [1, Number.MAX_SAFE_INTEGER] }}, perform: runAsAction(async (agent, distance) => { - await skills.digDown(agent.bot, distance) + await skills.digDown(agent.bot, distance); }) }, { @@ -499,4 +519,127 @@ export const actionsList = [ await skills.useToolOn(agent.bot, tool_name, target); }) }, + + // ═══════════════════════════════════════════════════════════════════════ + // GENERAL IMPROVEMENT COMMANDS + // ═══════════════════════════════════════════════════════════════════════ + { + name: '!safeMoveTo', + description: 'Navigate to coordinates safely, avoiding lava and placing torches underground. Includes fall protection.', + params: { + 'x': { type: 'float', description: 'The x coordinate.', domain: [-Infinity, Infinity] }, + 'y': { type: 'float', description: 'The y coordinate.', domain: [-64, 320] }, + 'z': { type: 'float', description: 'The z coordinate.', domain: [-Infinity, Infinity] } + }, + perform: runAsAction(async (agent, x, y, z) => { + await skills.safeMoveTo(agent.bot, x, y, z, { avoidLava: true, lightPath: true }); + }) + }, + { + name: '!rangedAttack', + description: 'Attack the nearest entity of a type with bow (if available), falling back to melee.', + params: { + 'type': { type: 'string', description: 'The entity type to attack (e.g. blaze, skeleton).' } + }, + perform: runAsAction(async (agent, type) => { + await skills.rangedAttack(agent.bot, type); + }) + }, + { + name: '!buildPanicRoom', + description: 'Build an emergency 3x3x3 cobblestone shelter, eat food, and wait to heal.', + perform: runAsAction(async (agent) => { + await skills.buildPanicRoom(agent.bot); + }) + }, + { + name: '!autoManageInventory', + description: 'Clean up inventory: drop junk, store excess in chests, keep 8 empty slots.', + perform: runAsAction(async (agent) => { + await skills.autoManageInventory(agent.bot); + }) + }, + { + name: '!stockpileFood', + description: 'Hunt animals and cook meat to build a food supply.', + params: { + 'quantity': { type: 'int', description: 'Target number of food items.', domain: [1, 128] } + }, + perform: runAsAction(async (agent, quantity) => { + await skills.stockpileFood(agent.bot, quantity); + }, false, 10) + }, + { + name: '!ensureFed', + description: 'Eat the best available food if hungry.', + perform: runAsAction(async (agent) => { + await skills.ensureFed(agent.bot); + }) + }, + + // ═══════════════════════════════════════════════════════════════════════ + // DRAGON PROGRESSION COMMANDS (Gameplay Chunks) + // ═══════════════════════════════════════════════════════════════════════ + { + name: '!getDiamondPickaxe', + description: 'Automatically progress through tool tiers: wooden → stone → iron → diamond pickaxe.', + perform: runAsAction(async (agent) => { + await skills.getDiamondPickaxe(agent.bot); + }, false, 30) // 30 minute timeout + }, + { + name: '!buildNetherPortal', + description: 'Build and light a nether portal. Casts obsidian from water+lava or mines it directly.', + perform: runAsAction(async (agent) => { + await dragonRunner.buildNetherPortal(agent.bot); + }, false, 30) + }, + { + name: '!collectBlazeRods', + description: 'Enter the Nether, find a fortress, and collect blaze rods from blazes.', + params: { + 'count': { type: 'int', description: 'Number of blaze rods to collect.', domain: [1, 64] } + }, + perform: runAsAction(async (agent, count) => { + await dragonRunner.collectBlazeRods(agent.bot, count); + }, false, 30) + }, + { + name: '!collectEnderPearls', + description: 'Hunt Endermen for ender pearls in the overworld or nether.', + params: { + 'count': { type: 'int', description: 'Number of ender pearls to collect.', domain: [1, 64] } + }, + perform: runAsAction(async (agent, count) => { + await dragonRunner.collectEnderPearls(agent.bot, count); + }, false, 30) + }, + { + name: '!locateStronghold', + description: 'Use eyes of ender to find the stronghold, dig down, and activate the end portal.', + perform: runAsAction(async (agent) => { + await dragonRunner.locateStronghold(agent.bot); + }, false, 30) + }, + { + name: '!defeatEnderDragon', + description: 'Enter The End and defeat the Ender Dragon. Destroys crystals then attacks dragon.', + perform: runAsAction(async (agent) => { + await dragonRunner.defeatEnderDragon(agent.bot); + }, false, 30) + }, + { + name: '!dragonProgression', + description: 'Full autonomous run: fresh world → diamond pickaxe → nether → blaze rods → ender pearls → stronghold → defeat Ender Dragon. Skips completed steps. Persistent — survives restarts.', + perform: runAsAction(async (agent) => { + await dragonRunner.runDragonProgression(agent.bot); + }, false, 180) // 3 hour timeout + }, + { + name: '!beatMinecraft', + description: 'Alias for !dragonProgression. Full autonomous Ender Dragon run with persistent progress. Survives deaths and restarts — picks up where it left off.', + perform: runAsAction(async (agent) => { + await dragonRunner.runDragonProgression(agent.bot); + }, false, 180) // 3 hour timeout + }, ]; diff --git a/src/agent/library/dragon_progress.js b/src/agent/library/dragon_progress.js new file mode 100644 index 000000000..6e9d2a91d --- /dev/null +++ b/src/agent/library/dragon_progress.js @@ -0,0 +1,358 @@ +/** + * dragon_progress.js — Persistent Dragon Progression State + * + * Survives restarts, deaths, and crashes via atomic JSON writes. + * Tracks: completed chunks, key coordinates, inventory milestones, + * death count, current dimension, retry counts, and timestamps. + * + * Uses the same safeWriteFile pattern as history.js (RC27). + */ + +import { readFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs'; +import { writeFile } from 'fs/promises'; + +// ── RC27: Atomic write — .tmp + rename ───────────────────────────────── +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await writeFile(tmpPath, content, 'utf8'); + try { + renameSync(tmpPath, filepath); + } catch (renameErr) { + console.warn(`[DragonProgress] Atomic rename failed for ${filepath}, falling back:`, renameErr.message); + await writeFile(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch (_e) { /* ignore */ } + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + +// ── Chunk definitions ────────────────────────────────────────────────── +export const CHUNKS = Object.freeze({ + DIAMOND_PICKAXE: 'diamond_pickaxe', + NETHER_PORTAL: 'nether_portal', + BLAZE_RODS: 'blaze_rods', + ENDER_PEARLS: 'ender_pearls', + STRONGHOLD: 'stronghold', + DRAGON_FIGHT: 'dragon_fight', +}); + +const CHUNK_ORDER = [ + CHUNKS.DIAMOND_PICKAXE, + CHUNKS.NETHER_PORTAL, + CHUNKS.BLAZE_RODS, + CHUNKS.ENDER_PEARLS, + CHUNKS.STRONGHOLD, + CHUNKS.DRAGON_FIGHT, +]; + +function defaultState() { + return { + version: 2, + startedAt: new Date().toISOString(), + lastUpdated: null, + + // Which chunks are done / in-progress / failed + chunks: Object.fromEntries(CHUNK_ORDER.map(c => [c, { + status: 'pending', // pending | active | done | failed + attempts: 0, + lastAttempt: null, + completedAt: null, + }])), + + // Key coordinates discovered during the run + coords: { + overworldPortal: null, // [x, y, z] + netherPortal: null, + netherFortress: null, + stronghold: null, + endPortal: null, + lastDeathPos: null, + basecamp: null, // safe surface base + }, + + // Inventory milestones — tracks highest counts ever achieved + milestones: { + hasDiamondPick: false, + hasIronArmor: false, + hasDiamondSword: false, + hasBow: false, + blazeRods: 0, + enderPearls: 0, + eyesOfEnder: 0, + }, + + // Run statistics + stats: { + deaths: 0, + totalRetries: 0, + currentChunkIndex: 0, // index into CHUNK_ORDER + dimension: 'overworld', // overworld | the_nether | the_end + }, + + // Dragon fight specific + dragonFight: { + crystalsDestroyed: 0, + dragonHitsLanded: 0, + enteredEnd: false, + }, + }; +} + +export class DragonProgress { + /** + * @param {string} botName — used to derive file path under bots/ + */ + constructor(botName) { + this.botName = botName; + this.filePath = `./bots/${botName}/dragon_progress.json`; + this.state = defaultState(); + this._dirty = false; + } + + // ── Persistence ──────────────────────────────────────────────────── + + load() { + try { + if (!existsSync(this.filePath)) { + console.log(`[DragonProgress] No save file for ${this.botName}, starting fresh.`); + return this.state; + } + const raw = readFileSync(this.filePath, 'utf8'); + if (!raw || !raw.trim()) { + console.warn(`[DragonProgress] Empty save file, starting fresh.`); + return this.state; + } + const loaded = JSON.parse(raw); + // Merge with defaults to handle schema upgrades + this.state = { ...defaultState(), ...loaded }; + // Ensure all chunks exist (in case new ones were added) + for (const c of CHUNK_ORDER) { + if (!this.state.chunks[c]) { + this.state.chunks[c] = { status: 'pending', attempts: 0, lastAttempt: null, completedAt: null }; + } + } + console.log(`[DragonProgress] Loaded state for ${this.botName}: chunk ${this.currentChunkIndex()}/${CHUNK_ORDER.length}`); + return this.state; + } catch (err) { + console.error(`[DragonProgress] Failed to load for ${this.botName}:`, err.message); + // Rename corrupted file + if (existsSync(this.filePath)) { + const backup = this.filePath + '.corrupted.' + Date.now(); + try { renameSync(this.filePath, backup); } catch (_e) { /* ignore */ } + } + return this.state; + } + } + + async save() { + try { + const dir = `./bots/${this.botName}`; + mkdirSync(dir, { recursive: true }); + this.state.lastUpdated = new Date().toISOString(); + await safeWriteFile(this.filePath, JSON.stringify(this.state, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[DragonProgress] Failed to save:`, err.message); + } + } + + // ── Chunk State ──────────────────────────────────────────────────── + + /** Get the current chunk name (the first non-done chunk) */ + currentChunk() { + for (const c of CHUNK_ORDER) { + if (this.state.chunks[c].status !== 'done') return c; + } + return null; // all done! + } + + /** Get the 0-based index of current chunk */ + currentChunkIndex() { + const current = this.currentChunk(); + return current ? CHUNK_ORDER.indexOf(current) : CHUNK_ORDER.length; + } + + /** Is a specific chunk complete? */ + isChunkDone(chunkName) { + return this.state.chunks[chunkName]?.status === 'done'; + } + + /** Mark a chunk as started */ + markChunkActive(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'active'; + chunk.attempts++; + chunk.lastAttempt = new Date().toISOString(); + this.state.stats.totalRetries++; + this.state.stats.currentChunkIndex = CHUNK_ORDER.indexOf(chunkName); + this._dirty = true; + } + + /** Mark a chunk as successfully completed */ + markChunkDone(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'done'; + chunk.completedAt = new Date().toISOString(); + this._dirty = true; + } + + /** Mark a chunk as failed (for this attempt) */ + markChunkFailed(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'failed'; + this._dirty = true; + } + + /** Get how many attempts a chunk has had */ + getChunkAttempts(chunkName) { + return this.state.chunks[chunkName]?.attempts || 0; + } + + /** Is everything complete? */ + isComplete() { + return CHUNK_ORDER.every(c => this.state.chunks[c].status === 'done'); + } + + /** Reset a specific chunk back to pending (for retry after recovery) */ + resetChunk(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'pending'; + this._dirty = true; + } + + // ── Coordinates ──────────────────────────────────────────────────── + + setCoord(name, x, y, z) { + if (this.state.coords[name] !== undefined) { + this.state.coords[name] = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this._dirty = true; + } + } + + getCoord(name) { + return this.state.coords[name]; + } + + // ── Milestones ───────────────────────────────────────────────────── + + updateMilestones(bot) { + const inv = {}; + if (bot.inventory) { + for (const item of bot.inventory.items()) { + inv[item.name] = (inv[item.name] || 0) + item.count; + } + } + const m = this.state.milestones; + m.hasDiamondPick = m.hasDiamondPick || !!(inv['diamond_pickaxe']); + m.hasIronArmor = m.hasIronArmor || !!(inv['iron_chestplate']); + m.hasDiamondSword = m.hasDiamondSword || !!(inv['diamond_sword']); + m.hasBow = m.hasBow || !!(inv['bow']); + m.blazeRods = Math.max(m.blazeRods, inv['blaze_rod'] || 0); + m.enderPearls = Math.max(m.enderPearls, inv['ender_pearl'] || 0); + m.eyesOfEnder = Math.max(m.eyesOfEnder, inv['ender_eye'] || 0); + this._dirty = true; + } + + // ── Death tracking ───────────────────────────────────────────────── + + recordDeath(x, y, z, dimension) { + this.state.stats.deaths++; + this.state.coords.lastDeathPos = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this.state.stats.dimension = dimension || 'overworld'; + this._dirty = true; + } + + // ── Dimension ────────────────────────────────────────────────────── + + setDimension(dim) { + this.state.stats.dimension = dim; + this._dirty = true; + } + + getDimension() { + return this.state.stats.dimension; + } + + // ── Dragon fight state ───────────────────────────────────────────── + + recordCrystalDestroyed() { + this.state.dragonFight.crystalsDestroyed++; + this._dirty = true; + } + + recordDragonHit() { + this.state.dragonFight.dragonHitsLanded++; + this._dirty = true; + } + + setEnteredEnd(val = true) { + this.state.dragonFight.enteredEnd = val; + this._dirty = true; + } + + // ── Summary for LLM prompt injection ─────────────────────────────── + + getSummary() { + const s = this.state; + const idx = this.currentChunkIndex(); + const current = this.currentChunk(); + const parts = []; + + parts.push(`[DRAGON PROGRESS ${idx}/${CHUNK_ORDER.length}]`); + + // Completed chunks + const done = CHUNK_ORDER.filter(c => s.chunks[c].status === 'done'); + if (done.length > 0) { + parts.push(`Done: ${done.join(', ')}`); + } + + // Current chunk + attempts + if (current) { + const attempts = s.chunks[current].attempts; + parts.push(`Current: ${current} (attempt ${attempts + 1})`); + } else { + parts.push('ALL CHUNKS COMPLETE — dragon defeated!'); + } + + // Key coords + const coordEntries = Object.entries(s.coords) + .filter(([, v]) => v !== null) + .map(([k, v]) => `${k}: ${v.join(',')}`); + if (coordEntries.length > 0) { + parts.push(`Coords: ${coordEntries.join(' | ')}`); + } + + // Stats + parts.push(`Deaths: ${s.stats.deaths} | Dim: ${s.stats.dimension}`); + + // Dragon fight + if (s.dragonFight.enteredEnd) { + parts.push(`End fight: ${s.dragonFight.crystalsDestroyed} crystals, ${s.dragonFight.dragonHitsLanded} hits`); + } + + return parts.join('\n'); + } + + // ── Static helpers ───────────────────────────────────────────────── + + static get CHUNK_ORDER() { + return CHUNK_ORDER; + } + + static get CHUNKS() { + return CHUNKS; + } +} diff --git a/src/agent/library/dragon_runner.js b/src/agent/library/dragon_runner.js new file mode 100644 index 000000000..3b5831ec5 --- /dev/null +++ b/src/agent/library/dragon_runner.js @@ -0,0 +1,1549 @@ +/** + * dragon_runner.js — Autonomous Ender Dragon progression system (RC29). + * + * Six modular gameplay chunks that chain together for a full + * fresh-world → Ender Dragon defeat run: + * Chunk 1: getDiamondPickaxe (already in skills.js) + * Chunk 2: buildNetherPortal + * Chunk 3: collectBlazeRods + * Chunk 4: collectEnderPearls + * Chunk 5: locateStronghold + * Chunk 6: defeatEnderDragon + * + * Plus the meta-orchestrator: runDragonProgression() + * + * RC29 upgrades: + * - Persistent state via DragonProgress (survives restarts/deaths) + * - Smart orchestrator with exponential backoff + * - Death recovery with gear re-acquisition + * - Dimension-aware navigation + * - Proactive food/gear management between chunks + * + * All functions use existing skill primitives from skills.js and world.js. + * Each is idempotent — safe to call multiple times (skips completed steps). + */ + +import * as skills from './skills.js'; +import * as world from './world.js'; +import { DragonProgress, CHUNKS } from './dragon_progress.js'; +import { ProgressReporter } from './progress_reporter.js'; +import Vec3 from 'vec3'; + +function log(bot, msg) { + skills.log(bot, msg); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** Count a specific item in inventory */ +function countItem(bot, name) { + return (world.getInventoryCounts(bot)[name] || 0); +} + +/** Check if we have at least `n` of item */ +function hasItem(bot, name, n = 1) { + return countItem(bot, name) >= n; +} + +/** Ensure the bot has food and eats if hungry */ +async function eatIfNeeded(bot) { + if (bot.food < 14) { + await skills.ensureFed(bot); + } +} + +/** Get the bot's current dimension */ +function getDimension(bot) { + const dim = bot.game?.dimension || 'overworld'; + if (dim.includes('nether')) return 'the_nether'; + if (dim.includes('end')) return 'the_end'; + return 'overworld'; +} + +/** Ensure we have enough of an item, trying to craft then collect */ +async function _ensureItem(bot, itemName, count, craftFrom = null) { + let have = countItem(bot, itemName); + if (have >= count) return true; + + if (craftFrom) { + const needed = count - have; + for (let i = 0; i < needed; i++) { + if (bot.interrupt_code) return false; + if (!await skills.craftRecipe(bot, itemName, 1)) break; + } + have = countItem(bot, itemName); + if (have >= count) return true; + } + + const needed = count - countItem(bot, itemName); + if (needed > 0) { + await skills.collectBlock(bot, itemName, needed); + } + return countItem(bot, itemName) >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PRE-CHUNK PREPARATION & DEATH RECOVERY +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Ensure minimum gear and food before starting a chunk. + * Adapts requirements based on which chunk is next. + */ +async function prepareForChunk(bot, chunkName, progress) { + log(bot, `Preparing for chunk: ${chunkName}`); + if (bot.interrupt_code) return; + + // Always eat first + await eatIfNeeded(bot); + + // Ensure food stockpile (min 12 cooked meat) + const foodItems = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'cooked_salmon', 'cooked_cod', 'apple', 'carrot']; + let totalFood = 0; + const inv = world.getInventoryCounts(bot); + for (const f of foodItems) totalFood += (inv[f] || 0); + + if (totalFood < 12) { + log(bot, `Food low (${totalFood}). Stockpiling...`); + await skills.stockpileFood(bot, 20); + } + + // RC30: Proactive inventory overflow — place a chest and store junk if nearly full + const emptySlots = bot.inventory.emptySlotCount(); + if (emptySlots < 6 && getDimension(bot) === 'overworld') { + log(bot, `Inventory nearly full (${emptySlots} empty). Placing chest for overflow...`); + const nearbyChest = world.getNearestBlock(bot, 'chest', 32); + if (!nearbyChest && hasItem(bot, 'chest')) { + // Place a chest at current position + const pos = bot.entity.position; + try { + await skills.placeBlock(bot, 'chest', + Math.floor(pos.x) + 1, Math.floor(pos.y), Math.floor(pos.z), 'side'); + } catch (_e) { + log(bot, 'Could not place overflow chest.'); + } + } + } + + // Manage inventory (stores in nearby chests or discards junk) + await skills.autoManageInventory(bot); + + // Chunk-specific prep + switch (chunkName) { + case CHUNKS.BLAZE_RODS: + case CHUNKS.ENDER_PEARLS: + // Need a sword for combat chunks + if (!hasItem(bot, 'diamond_sword') && !hasItem(bot, 'iron_sword')) { + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + break; + + case CHUNKS.DRAGON_FIGHT: + // Max out gear before the End + if (!hasItem(bot, 'diamond_sword') && hasItem(bot, 'diamond', 2)) { + await skills.craftRecipe(bot, 'diamond_sword', 1); + } + // Collect cobblestone for pillaring + if (countItem(bot, 'cobblestone') < 64 && getDimension(bot) === 'overworld') { + await skills.collectBlock(bot, 'cobblestone', 64); + } + break; + } + + // Update milestones + progress.updateMilestones(bot); + await progress.save(); +} + +/** + * After death: try to recover items by going to death location. + * Then re-acquire minimum gear if recovery failed. + * RC30: Hardened recovery — full tool chain re-crafting, dimension-aware + * portal linking safety, armor re-equip. + */ +async function recoverFromDeath(bot, progress) { + log(bot, 'Death recovery initiated...'); + + const deathPos = progress.getCoord('lastDeathPos'); + const deathDim = progress.getDimension(); + const currentDim = getDimension(bot); + + // RC30: Portal linking safety — if we died in the Nether but respawned in Overworld, + // and we have a saved portal coord, navigate to portal first before attempting recovery + if (deathDim === 'the_nether' && currentDim === 'overworld') { + const portalCoord = progress.getCoord('overworldPortal'); + if (portalCoord) { + log(bot, `Died in Nether, respawned in Overworld. Heading to saved portal: ${portalCoord.join(', ')}`); + try { + await skills.goToPosition(bot, portalCoord[0], portalCoord[1], portalCoord[2], 3); + // Wait for portal transition + await skills.wait(bot, 5000); + } catch (_e) { + log(bot, 'Could not reach Overworld portal. Will re-acquire gear in Overworld.'); + } + } + } + + // Attempt item recovery only if in the same dimension as death + if (deathPos && getDimension(bot) === deathDim) { + log(bot, `Heading to death location: ${deathPos.join(', ')}`); + try { + await skills.goToPosition(bot, deathPos[0], deathPos[1], deathPos[2], 3); + await skills.pickupNearbyItems(bot); + log(bot, 'Picked up items near death location.'); + } catch (_e) { + log(bot, 'Could not reach death location.'); + } + } else if (deathPos) { + log(bot, `Death was in ${deathDim} but currently in ${getDimension(bot)}. Skipping item recovery.`); + } + + // RC30: Full inventory check and re-acquisition chain + const inv = world.getInventoryCounts(bot); + const hasPickaxe = inv['diamond_pickaxe'] || inv['iron_pickaxe'] || inv['stone_pickaxe']; + const hasSword = inv['diamond_sword'] || inv['iron_sword'] || inv['stone_sword']; + + if (!hasPickaxe) { + log(bot, 'Lost pickaxe! Full tool chain re-crafting...'); + // Step 1: Get wood (try multiple tree types) + let gotWood = false; + for (const logType of ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', 'acacia_log', 'jungle_log']) { + if (bot.interrupt_code) return; + if (hasItem(bot, logType, 1)) { gotWood = true; break; } + try { + await skills.collectBlock(bot, logType, 4); + if (countItem(bot, logType) > 0) { gotWood = true; break; } + } catch (_e) { /* try next type */ } + } + if (gotWood) { + // Step 2: Craft basic tools + const planksType = Object.keys(world.getInventoryCounts(bot)) + .find(k => k.endsWith('_log')); + if (planksType) { + const planksName = planksType.replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'crafting_table', 1); + await skills.craftRecipe(bot, 'wooden_pickaxe', 1); + // Step 3: Upgrade to stone + try { + await skills.collectBlock(bot, 'cobblestone', 8); + await skills.craftRecipe(bot, 'stone_pickaxe', 1); + await skills.craftRecipe(bot, 'stone_sword', 1); + } catch (_e) { + log(bot, 'Could not gather cobblestone for stone tools.'); + } + // Step 4: Try for iron if we have time + if (!bot.interrupt_code && getDimension(bot) === 'overworld') { + try { + await skills.collectBlock(bot, 'iron_ore', 3); + if (countItem(bot, 'raw_iron') >= 3 || countItem(bot, 'iron_ore') >= 3) { + await skills.smeltItem(bot, 'raw_iron', 3); + await skills.craftRecipe(bot, 'iron_pickaxe', 1); + } + } catch (_e) { + log(bot, 'Could not upgrade to iron. Stone tools will suffice.'); + } + } + } + } + } + + if (!hasSword) { + log(bot, 'Lost sword! Crafting replacement...'); + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2) || hasItem(bot, 'cobbled_deepslate', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } else { + // Desperate: craft wooden sword + const logTypes = Object.keys(world.getInventoryCounts(bot)).filter(k => k.endsWith('_log')); + if (logTypes.length > 0) { + const planksName = logTypes[0].replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'wooden_sword', 1); + } + } + } + + // RC30: Re-equip armor if we have any + const armorSlots = ['head', 'torso', 'legs', 'feet']; + const armorPriority = { + head: ['diamond_helmet', 'iron_helmet', 'chainmail_helmet', 'leather_helmet'], + torso: ['diamond_chestplate', 'iron_chestplate', 'chainmail_chestplate', 'leather_chestplate'], + legs: ['diamond_leggings', 'iron_leggings', 'chainmail_leggings', 'leather_leggings'], + feet: ['diamond_boots', 'iron_boots', 'chainmail_boots', 'leather_boots'], + }; + for (const slot of armorSlots) { + for (const armorName of armorPriority[slot]) { + const armorItem = bot.inventory.items().find(i => i.name === armorName); + if (armorItem) { + try { await bot.equip(armorItem, slot); } catch (_e) { /* best effort */ } + break; + } + } + } + + // Stock up food + await skills.stockpileFood(bot, 16); + await eatIfNeeded(bot); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 2: Build a Nether Portal +// ═══════════════════════════════════════════════════════════════════════════ + +export async function buildNetherPortal(bot) { + /** + * Build a nether portal using one of two methods: + * (A) If the bot already has 10+ obsidian and flint_and_steel, build directly. + * (B) Otherwise, use water bucket + lava source to cast obsidian in place. + * Requires: iron_pickaxe or diamond_pickaxe, bucket, flint_and_steel. + * @param {MinecraftBot} bot + * @returns {Promise} true if nether portal is built and lit. + **/ + log(bot, '=== CHUNK 2: Build Nether Portal ==='); + + // Check prerequisites + const inv = world.getInventoryCounts(bot); + const hasPickaxe = (inv['diamond_pickaxe'] || 0) > 0 || (inv['iron_pickaxe'] || 0) > 0; + if (!hasPickaxe) { + log(bot, 'Need at least an iron pickaxe first. Running getDiamondPickaxe...'); + if (!await skills.getDiamondPickaxe(bot)) { + log(bot, 'Cannot get a pickaxe. Aborting nether portal.'); + return false; + } + } + + await eatIfNeeded(bot); + + // Ensure we have flint and steel + if (!hasItem(bot, 'flint_and_steel')) { + // Need iron ingot + flint + if (!hasItem(bot, 'iron_ingot')) { + // Mine and smelt iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 1); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 1); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 1); + } + if (!hasItem(bot, 'flint')) { + await skills.collectBlock(bot, 'gravel', 5); // flint drops from gravel + // Check if we got flint from mining gravel + if (!hasItem(bot, 'flint')) { + // Mine more gravel + for (let i = 0; i < 10 && !hasItem(bot, 'flint'); i++) { + if (bot.interrupt_code) return false; + await skills.collectBlock(bot, 'gravel', 3); + } + } + } + if (hasItem(bot, 'iron_ingot') && hasItem(bot, 'flint')) { + await skills.craftRecipe(bot, 'flint_and_steel', 1); + } + if (!hasItem(bot, 'flint_and_steel')) { + log(bot, 'Cannot craft flint_and_steel. Need iron_ingot + flint.'); + return false; + } + } + + // Ensure we have a bucket + if (!hasItem(bot, 'bucket') && !hasItem(bot, 'water_bucket') && !hasItem(bot, 'lava_bucket')) { + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } else { + // Need more iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 3); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 3); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 3); + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } + } + } + + // Method A: If we already have 10 obsidian, build directly + if (hasItem(bot, 'obsidian', 10)) { + return await buildPortalFromObsidian(bot); + } + + // Method B: Cast obsidian portal using water + lava + log(bot, 'Casting obsidian portal with water + lava method...'); + + // Get water bucket + if (!hasItem(bot, 'water_bucket')) { + const waterBlock = world.getNearestBlock(bot, 'water', 64); + if (waterBlock) { + await skills.goToPosition(bot, waterBlock.position.x, waterBlock.position.y, waterBlock.position.z, 2); + // Equip bucket and right-click water + const bucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (bucket) { + await bot.equip(bucket, 'hand'); + try { + const wBlock = bot.blockAt(waterBlock.position); + if (wBlock) await bot.activateBlock(wBlock); + } catch (_e) { /* try useOn fallback */ } + } + } + if (!hasItem(bot, 'water_bucket')) { + log(bot, 'Cannot find water source for bucket. Attempting direct mining of obsidian...'); + return await mineObsidianDirect(bot); + } + } + + // Find or create a lava source underground + log(bot, 'Finding lava source for portal casting...'); + + // Dig down to find lava (common near Y=10) + const currentY = Math.floor(bot.entity.position.y); + if (currentY > 15) { + const digDist = currentY - 11; + await skills.digDown(bot, digDist); + } + + // Find lava nearby + let lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) { + log(bot, 'No lava found nearby. Exploring at depth...'); + await skills.explore(bot, 40); + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + } + if (!lavaBlock) { + log(bot, 'Could not find lava. Try a different location.'); + return false; + } + + // Cast obsidian: pour water on lava source blocks + log(bot, 'Found lava! Casting obsidian...'); + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + + // Mine the obsidian we create — need at least 10 blocks + let obsidianCount = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidianCount < 10 && attempts < 25) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + // Pour water on lava + lavaBlock = world.getNearestBlock(bot, 'lava', 8); + if (!lavaBlock) { + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) break; + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + } + + // Place water near lava + const waterBucket = bot.inventory.items().find(i => i.name === 'water_bucket'); + if (waterBucket) { + await bot.equip(waterBucket, 'hand'); + try { + const aboveLava = bot.blockAt(lavaBlock.position.offset(0, 1, 0)); + if (aboveLava) await bot.activateBlock(aboveLava); + } catch (_e) { /* best effort */ } + await new Promise(r => setTimeout(r, 1000)); + + // Pick water back up + const waterBlock = world.getNearestBlock(bot, 'water', 5); + if (waterBlock) { + const emptyBucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (emptyBucket) { + await bot.equip(emptyBucket, 'hand'); + try { + const wb = bot.blockAt(waterBlock.position); + if (wb) await bot.activateBlock(wb); + } catch (_e) { /* best effort */ } + } + } + } + + // Mine newly created obsidian + const obsidian = world.getNearestBlock(bot, 'obsidian', 8); + if (obsidian) { + // Need diamond pickaxe to mine obsidian + const diamPick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (diamPick) { + await bot.equip(diamPick, 'hand'); + await skills.breakBlockAt(bot, obsidian.position.x, obsidian.position.y, obsidian.position.z); + await skills.pickupNearbyItems(bot); + } else { + log(bot, 'Need diamond pickaxe to mine obsidian!'); + return false; + } + } + + obsidianCount = countItem(bot, 'obsidian'); + log(bot, `Obsidian progress: ${obsidianCount}/10`); + } + + if (obsidianCount < 10) { + log(bot, `Only got ${obsidianCount} obsidian. Need 10. Try again.`); + return false; + } + + // Go to surface and build the portal + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function mineObsidianDirect(bot) { + /** Mine 10 obsidian directly (slow — need diamond pickaxe) */ + if (!hasItem(bot, 'diamond_pickaxe')) { + log(bot, 'Need diamond pickaxe to mine obsidian.'); + return false; + } + + log(bot, 'Mining obsidian directly...'); + let obsidian = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidian < 10 && attempts < 30) { + if (bot.interrupt_code) return false; + attempts++; + const block = world.getNearestBlock(bot, 'obsidian', 32); + if (!block) { + await skills.explore(bot, 40); + continue; + } + const pick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (pick) await bot.equip(pick, 'hand'); + await skills.breakBlockAt(bot, block.position.x, block.position.y, block.position.z); + await skills.pickupNearbyItems(bot); + obsidian = countItem(bot, 'obsidian'); + } + + if (obsidian < 10) return false; + + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function buildPortalFromObsidian(bot) { + /** Build a standard 4x5 nether portal frame and light it */ + log(bot, 'Building nether portal frame...'); + const pos = bot.entity.position; + const bx = Math.floor(pos.x) + 2; + const by = Math.floor(pos.y); + const bz = Math.floor(pos.z); + + // Standard portal frame: 4 wide x 5 tall, only the frame blocks + // Bottom row (2 blocks) + const portalBlocks = [ + // Bottom + [bx + 1, by, bz], [bx + 2, by, bz], + // Left column + [bx, by + 1, bz], [bx, by + 2, bz], [bx, by + 3, bz], + // Right column + [bx + 3, by + 1, bz], [bx + 3, by + 2, bz], [bx + 3, by + 3, bz], + // Top row + [bx + 1, by + 4, bz], [bx + 2, by + 4, bz], + ]; + + for (const [px, py, pz] of portalBlocks) { + if (bot.interrupt_code) return false; + const block = bot.blockAt(new Vec3(px, py, pz)); + if (block && block.name !== 'obsidian') { + // Clear the block first if not air + if (block.name !== 'air') { + await skills.breakBlockAt(bot, px, py, pz); + } + await skills.placeBlock(bot, 'obsidian', px, py, pz, 'bottom', true); + } + } + + // Clear the portal interior (2x3) + for (let dx = 1; dx <= 2; dx++) { + for (let dy = 1; dy <= 3; dy++) { + const block = bot.blockAt(new Vec3(bx + dx, by + dy, bz)); + if (block && block.name !== 'air') { + await skills.breakBlockAt(bot, bx + dx, by + dy, bz); + } + } + } + + // Light the portal with flint and steel + log(bot, 'Lighting nether portal...'); + const flintSteel = bot.inventory.items().find(i => i.name === 'flint_and_steel'); + if (flintSteel) { + await bot.equip(flintSteel, 'hand'); + const insideBlock = bot.blockAt(new Vec3(bx + 1, by + 1, bz)); + if (insideBlock) { + try { + await bot.activateBlock(insideBlock); + } catch (_e) { + // Try activating the bottom obsidian + const bottomBlock = bot.blockAt(new Vec3(bx + 1, by, bz)); + if (bottomBlock) await bot.activateBlock(bottomBlock); + } + } + } + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const portalBlock = world.getNearestBlock(bot, 'nether_portal', 8); + if (portalBlock) { + log(bot, 'Nether portal built and activated!'); + // Remember portal location + bot.memory_bank?.rememberPlace?.('overworld_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frame built but not activated. May need to manually light it.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 3: Collect Blaze Rods +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectBlazeRods(bot, count = 12) { + /** + * Travel to the Nether, find a Nether Fortress, and kill Blazes for rods. + * Prerequisites: nether portal exists, good gear + food. + * @param {MinecraftBot} bot + * @param {number} count - number of blaze rods to collect. Default 12. + * @returns {Promise} true if enough blaze rods collected. + **/ + log(bot, `=== CHUNK 3: Collect ${count} Blaze Rods ===`); + + const currentRods = countItem(bot, 'blaze_rod'); + if (currentRods >= count) { + log(bot, `Already have ${currentRods} blaze rods!`); + return true; + } + + // Ensure we have gear + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Check we're prepared + const inv = world.getInventoryCounts(bot); + if (!inv['iron_sword'] && !inv['diamond_sword'] && !inv['stone_sword']) { + log(bot, 'Need a sword before entering the Nether.'); + // Try to craft one + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (inv['cobblestone'] >= 2) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + + // Enter nether portal + const portal = world.getNearestBlock(bot, 'nether_portal', 64); + if (!portal) { + log(bot, 'No nether portal found! Build one first with !buildNetherPortal.'); + return false; + } + + log(bot, 'Entering nether portal...'); + await skills.goToPosition(bot, portal.position.x, portal.position.y, portal.position.z, 0); + + // Wait for dimension change + await new Promise(r => setTimeout(r, 8000)); + + // Check if we're in the nether + const dimension = bot.game?.dimension || 'overworld'; + if (!dimension.includes('nether') && !dimension.includes('the_nether')) { + log(bot, 'Failed to enter the Nether. Standing on portal...'); + // Try stepping into the portal + await new Promise(r => setTimeout(r, 5000)); + } + + // Search for nether fortress (nether_bricks) + log(bot, 'Searching for Nether Fortress...'); + let fortressFound = false; + let searchAttempts = 0; + + while (!fortressFound && searchAttempts < 15) { + if (bot.interrupt_code) return false; + searchAttempts++; + await eatIfNeeded(bot); + + // Look for nether_bricks which indicate a fortress + const bricks = world.getNearestBlock(bot, 'nether_bricks', 64); + if (bricks) { + log(bot, 'Found Nether Fortress!'); + await skills.goToPosition(bot, bricks.position.x, bricks.position.y, bricks.position.z, 3); + fortressFound = true; + } else { + log(bot, `Fortress search attempt ${searchAttempts}/15...`); + // Travel in a consistent direction through the nether + const pos = bot.entity.position; + const angle = (searchAttempts * 0.6) * Math.PI; // spiral pattern + const dist = 50 + searchAttempts * 20; + const targetX = pos.x + Math.cos(angle) * dist; + const targetZ = pos.z + Math.sin(angle) * dist; + await skills.goToPosition(bot, targetX, pos.y, targetZ, 5); + } + } + + if (!fortressFound) { + log(bot, 'Could not find a Nether Fortress after extensive search.'); + return false; + } + + // Hunt blazes + log(bot, 'Hunting blazes for blaze rods...'); + let rods = countItem(bot, 'blaze_rod'); + let huntAttempts = 0; + + while (rods < count && huntAttempts < 40) { + if (bot.interrupt_code) return false; + huntAttempts++; + await eatIfNeeded(bot); + + // Check health — retreat if low + if (bot.health < 8) { + log(bot, 'Low health! Building emergency shelter...'); + await skills.buildPanicRoom(bot); + } + + const blaze = world.getNearestEntityWhere(bot, e => e.name === 'blaze', 48); + if (blaze) { + // Prefer ranged attack for blazes + const hasBow = hasItem(bot, 'bow') && hasItem(bot, 'arrow'); + if (hasBow) { + await skills.rangedAttack(bot, 'blaze'); + } else { + log(bot, 'Fighting blaze in melee...'); + await skills.attackEntity(bot, blaze, true); + } + await skills.pickupNearbyItems(bot); + } else { + // Explore fortress to find blaze spawners + log(bot, 'No blazes visible. Searching fortress...'); + const spawner = world.getNearestBlock(bot, 'spawner', 32); + if (spawner) { + await skills.goToPosition(bot, spawner.position.x, spawner.position.y, spawner.position.z, 5); + await skills.wait(bot, 5000); // Wait for blazes to spawn + } else { + await skills.explore(bot, 30); + } + } + + rods = countItem(bot, 'blaze_rod'); + log(bot, `Blaze rods: ${rods}/${count}`); + } + + if (rods >= count) { + log(bot, `Collected ${rods} blaze rods! Heading back to portal...`); + } else { + log(bot, `Only got ${rods}/${count} blaze rods. May need to retry.`); + } + + return rods >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 4: Collect Ender Pearls +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectEnderPearls(bot, count = 12) { + /** + * Collect ender pearls by hunting Endermen. Works in both Overworld and Nether. + * Endermen are taller, so look at their feet to aggro them safely. + * @param {MinecraftBot} bot + * @param {number} count - target ender pearls. Default 12. + * @returns {Promise} true if enough ender pearls collected. + **/ + log(bot, `=== CHUNK 4: Collect ${count} Ender Pearls ===`); + + let pearls = countItem(bot, 'ender_pearl'); + if (pearls >= count) { + log(bot, `Already have ${pearls} ender pearls!`); + return true; + } + + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Ensure we have a good sword + const inv = world.getInventoryCounts(bot); + if (!inv['diamond_sword'] && !inv['iron_sword']) { + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } + } + + log(bot, 'Hunting Endermen for ender pearls...'); + let attempts = 0; + + while (pearls < count && attempts < 50) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // Find an enderman + const enderman = world.getNearestEntityWhere(bot, e => e.name === 'enderman', 48); + + if (enderman) { + log(bot, `Found Enderman! Distance: ${Math.floor(bot.entity.position.distanceTo(enderman.position))}`); + + // Get close enough + if (bot.entity.position.distanceTo(enderman.position) > 5) { + await skills.goToPosition(bot, + enderman.position.x, enderman.position.y, enderman.position.z, 4); + } + + // Look at its feet to aggro it (looking at head triggers teleportation aggro) + await bot.lookAt(enderman.position.offset(0, 0.5, 0)); + await new Promise(r => setTimeout(r, 500)); + + // Attack + await skills.attackEntity(bot, enderman, true); + await skills.pickupNearbyItems(bot); + } else { + // Endermen spawn more at night and in specific biomes + const timeOfDay = bot.time?.timeOfDay || 0; + if (timeOfDay < 13000) { + log(bot, 'Waiting for night (endermen spawn more at night)...'); + await skills.wait(bot, 5000); + } else { + // Explore to find endermen + await skills.explore(bot, 80); + } + } + + pearls = countItem(bot, 'ender_pearl'); + if (attempts % 5 === 0) { + log(bot, `Ender pearls: ${pearls}/${count} (attempt ${attempts})`); + } + } + + log(bot, `Collected ${pearls} ender pearls.`); + return pearls >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 5: Locate Stronghold & Enter the End +// ═══════════════════════════════════════════════════════════════════════════ + +export async function locateStronghold(bot) { + /** + * Craft eyes of ender, throw them to triangulate the stronghold, + * dig down to find it, locate the end portal, and activate it. + * Prerequisites: blaze rods + ender pearls. + * @param {MinecraftBot} bot + * @returns {Promise} true if end portal found and activated. + **/ + log(bot, '=== CHUNK 5: Locate Stronghold & Enter the End ==='); + + // Craft blaze powder from blaze rods + const blazeRods = countItem(bot, 'blaze_rod'); + const blazePowder = countItem(bot, 'blaze_powder'); + const enderPearls = countItem(bot, 'ender_pearl'); + const eyesOfEnder = countItem(bot, 'ender_eye'); + + const totalEyes = eyesOfEnder; + const canCraftEyes = Math.min( + blazeRods * 2 + blazePowder, + enderPearls + ); + + if (totalEyes + canCraftEyes < 12) { + log(bot, `Not enough materials for 12 eyes of ender. Have: ${eyesOfEnder} eyes, ${blazeRods} rods, ${enderPearls} pearls.`); + return false; + } + + // Craft blaze powder + if (blazeRods > 0 && countItem(bot, 'blaze_powder') < enderPearls) { + const rodsToCraft = Math.min(blazeRods, Math.ceil((enderPearls - blazePowder) / 2)); + await skills.craftRecipe(bot, 'blaze_powder', rodsToCraft); + } + + // Craft eyes of ender + const currentEyes = countItem(bot, 'ender_eye'); + if (currentEyes < 12) { + const toCraft = Math.min( + countItem(bot, 'blaze_powder'), + countItem(bot, 'ender_pearl'), + 12 - currentEyes + ); + for (let i = 0; i < toCraft; i++) { + if (bot.interrupt_code) return false; + await skills.craftRecipe(bot, 'ender_eye', 1); + } + } + + const finalEyes = countItem(bot, 'ender_eye'); + if (finalEyes < 12) { + log(bot, `Only crafted ${finalEyes} eyes of ender. Need 12.`); + return false; + } + + log(bot, `Crafted ${finalEyes} eyes of ender. Triangulating stronghold...`); + + // Throw eyes of ender to find stronghold direction + // The eye floats toward the stronghold then drops + // We need 2 throws from different positions to triangulate + + const throw1Pos = bot.entity.position.clone(); + let throw1Dir = null; + let throw2Dir = null; + + // First throw + log(bot, 'Throwing first eye of ender...'); + const eye1 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye1) { + await bot.equip(eye1, 'hand'); + await bot.look(0, 0); // look forward + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + // The eye entity should appear and float in a direction + // Watch for thrown ender eye entity + const eyeEntity = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity) { + const eyePos = eyeEntity.position; + throw1Dir = { + x: eyePos.x - throw1Pos.x, + z: eyePos.z - throw1Pos.z + }; + log(bot, `Eye flew toward (${Math.floor(eyePos.x)}, ${Math.floor(eyePos.z)})`); + } + } + + // Move perpendicular for second throw + if (throw1Dir) { + const perpX = throw1Pos.x + (-throw1Dir.z > 0 ? 200 : -200); + const perpZ = throw1Pos.z + (throw1Dir.x > 0 ? 200 : -200); + log(bot, 'Moving for second triangulation throw...'); + await skills.goToPosition(bot, perpX, bot.entity.position.y, perpZ, 10); + } else { + // First throw failed, just move and try again + await skills.explore(bot, 200); + } + + const throw2Pos = bot.entity.position.clone(); + + // Second throw + log(bot, 'Throwing second eye of ender...'); + const eye2 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye2) { + await bot.equip(eye2, 'hand'); + await bot.look(0, 0); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const eyeEntity2 = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity2) { + throw2Dir = { + x: eyeEntity2.position.x - throw2Pos.x, + z: eyeEntity2.position.z - throw2Pos.z + }; + } + } + + // Estimate stronghold position from two throws + let targetX, targetZ; + if (throw1Dir && throw2Dir) { + // Line intersection to find stronghold + const det = throw1Dir.x * throw2Dir.z - throw1Dir.z * throw2Dir.x; + if (Math.abs(det) > 0.01) { + const t = ((throw2Pos.x - throw1Pos.x) * throw2Dir.z - (throw2Pos.z - throw1Pos.z) * throw2Dir.x) / det; + targetX = throw1Pos.x + throw1Dir.x * t; + targetZ = throw1Pos.z + throw1Dir.z * t; + log(bot, `Stronghold estimated at (${Math.floor(targetX)}, ${Math.floor(targetZ)})`); + } else { + // Lines are parallel, just follow the first direction + targetX = throw1Pos.x + throw1Dir.x * 100; + targetZ = throw1Pos.z + throw1Dir.z * 100; + } + } else { + // Fallback: strongholds typically generate 1000-3000 blocks from origin + // in ring patterns. Head toward origin at ~1500 block radius + const pos = bot.entity.position; + const distFromOrigin = Math.sqrt(pos.x * pos.x + pos.z * pos.z); + if (distFromOrigin > 2000) { + targetX = pos.x * 0.6; // Move toward origin + targetZ = pos.z * 0.6; + } else { + targetX = pos.x + 500; + targetZ = pos.z + 500; + } + log(bot, `Eye tracking failed. Heading toward estimated stronghold area...`); + } + + // Navigate to estimated position + log(bot, 'Traveling to stronghold area...'); + await skills.goToPosition(bot, targetX, bot.entity.position.y, targetZ, 20); + + // Keep throwing eyes to refine position until they go DOWN + log(bot, 'Refining position with more eye throws...'); + let goingDown = false; + let refineAttempts = 0; + while (!goingDown && refineAttempts < 10) { + if (bot.interrupt_code) return false; + refineAttempts++; + await eatIfNeeded(bot); + + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const flyingEye = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (flyingEye) { + const eyeY = flyingEye.position.y; + const botY = bot.entity.position.y; + if (eyeY < botY) { + // Eye went DOWN — stronghold is below us! + goingDown = true; + log(bot, 'Eye went underground — stronghold is directly below!'); + } else { + // Still need to follow + await skills.goToPosition(bot, + flyingEye.position.x, bot.entity.position.y, flyingEye.position.z, 10); + } + } + await skills.pickupNearbyItems(bot); // Recover dropped eye + } + + // Dig down to find the stronghold + log(bot, 'Digging down to stronghold...'); + await skills.digDown(bot, 40); + + // Search for end portal frame blocks + let portalFrame = null; + let searchAttempts = 0; + while (!portalFrame && searchAttempts < 20) { + if (bot.interrupt_code) return false; + searchAttempts++; + + portalFrame = world.getNearestBlock(bot, 'end_portal_frame', 32); + if (!portalFrame) { + // Look for stone_bricks (stronghold material) + const stoneBricks = world.getNearestBlock(bot, 'stone_bricks', 32); + if (stoneBricks) { + log(bot, 'Found stronghold stonework! Searching for portal room...'); + await skills.goToPosition(bot, + stoneBricks.position.x, stoneBricks.position.y, stoneBricks.position.z, 2); + } + await skills.explore(bot, 20); + } + } + + if (!portalFrame) { + log(bot, 'Could not find end portal frame. Dig around in the stronghold to find it.'); + return false; + } + + log(bot, 'Found end portal frame! Filling with eyes of ender...'); + await skills.goToPosition(bot, + portalFrame.position.x, portalFrame.position.y, portalFrame.position.z, 3); + + // Fill all portal frames with eyes of ender + const frames = bot.findBlocks({ + matching: block => block && block.name === 'end_portal_frame', + maxDistance: 16, + count: 12 + }); + + let filled = 0; + for (const framePos of frames) { + if (bot.interrupt_code) return false; + const frameBlock = bot.blockAt(framePos); + if (!frameBlock) continue; + + // Check if frame already has an eye (metadata check) + // end_portal_frame has property 'eye' which is true/false + const hasEye = frameBlock.getProperties?.()?.eye === 'true' || + frameBlock.getProperties?.()?.eye === true; + if (hasEye) { + filled++; + continue; + } + + // Place eye of ender in frame + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + try { + await bot.activateBlock(frameBlock); + filled++; + await new Promise(r => setTimeout(r, 500)); + } catch (_e) { + log(bot, 'Failed to place eye in frame.'); + } + } + + log(bot, `Filled ${filled}/${frames.length} portal frames.`); + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (endPortal) { + log(bot, 'End portal is ACTIVE! Ready to enter.'); + // Remember location + const pos = bot.entity.position; + bot.memory_bank?.rememberPlace?.('end_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frames placed but portal not active. May need more eyes.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 6: Defeat the Ender Dragon +// ═══════════════════════════════════════════════════════════════════════════ + +export async function defeatEnderDragon(bot) { + /** + * Enter The End and defeat the Ender Dragon. + * Strategy: destroy end crystals first, then attack dragon during perching. + * Requires: strong weapon, bow + arrows, blocks for pillaring, food. + * @param {MinecraftBot} bot + * @returns {Promise} true if dragon defeated. + **/ + log(bot, '=== CHUNK 6: Defeat the Ender Dragon ==='); + + await eatIfNeeded(bot); + + // Ensure we have needed supplies + const inv = world.getInventoryCounts(bot); + const hasSword = inv['diamond_sword'] || inv['iron_sword']; + const hasBow = inv['bow'] && (inv['arrow'] || 0) >= 32; + const hasBlocks = (inv['cobblestone'] || 0) >= 64; + + if (!hasSword) { + log(bot, 'Need a sword for dragon fight.'); + if (inv['diamond'] >= 2) await skills.craftRecipe(bot, 'diamond_sword', 1); + else if (inv['iron_ingot'] >= 2) await skills.craftRecipe(bot, 'iron_sword', 1); + } + + if (!hasBow) { + log(bot, 'Bow + arrows strongly recommended for end crystals.'); + } + + // Ensure we have blocks for pillaring up to crystals + if (!hasBlocks) { + await skills.collectBlock(bot, 'cobblestone', 64); + } + + // Enter the end portal + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (!endPortal) { + log(bot, 'No end portal found! Run !locateStronghold first.'); + return false; + } + + log(bot, 'Jumping into the End...'); + await skills.goToPosition(bot, endPortal.position.x, endPortal.position.y, endPortal.position.z, 0); + await new Promise(r => setTimeout(r, 10000)); // Wait for dimension transfer + + // In The End now + log(bot, 'Arrived in The End. Beginning dragon fight!'); + + // Phase 1: Destroy end crystals on obsidian pillars + log(bot, 'Phase 1: Destroying end crystals...'); + let crystalsDestroyed = 0; + let crystalAttempts = 0; + + while (crystalAttempts < 30) { + if (bot.interrupt_code) return false; + crystalAttempts++; + await eatIfNeeded(bot); + + // Health check + if (bot.health < 8) { + log(bot, 'Low health! Eating and hiding...'); + await skills.buildPanicRoom(bot); + } + + // Find end crystals + const crystal = world.getNearestEntityWhere(bot, e => + e.name === 'end_crystal' || e.name === 'ender_crystal', 64); + + if (!crystal) { + log(bot, `All visible crystals destroyed (${crystalsDestroyed} confirmed).`); + break; + } + + const dist = bot.entity.position.distanceTo(crystal.position); + log(bot, `End crystal found at distance ${Math.floor(dist)}`); + + if (hasBow && dist > 8) { + // Shoot the crystal with bow + await skills.rangedAttack(bot, crystal.name); + crystalsDestroyed++; + } else { + // Pillar up and melee the crystal + // Get close first + await skills.goToPosition(bot, + crystal.position.x, bot.entity.position.y, crystal.position.z, 4); + + // If crystal is high up, pillar + const heightDiff = crystal.position.y - bot.entity.position.y; + if (heightDiff > 3) { + log(bot, `Pillaring up ${Math.floor(heightDiff)} blocks...`); + const pos = bot.entity.position; + for (let i = 0; i < Math.floor(heightDiff); i++) { + if (bot.interrupt_code) return false; + await skills.placeBlock(bot, 'cobblestone', + Math.floor(pos.x), Math.floor(pos.y) + i, Math.floor(pos.z), 'bottom', true); + bot.setControlState('jump', true); + await new Promise(r => setTimeout(r, 400)); + bot.setControlState('jump', false); + } + } + + // Attack the crystal (causes explosion — back away!) + try { + await bot.attack(crystal); + crystalsDestroyed++; + log(bot, 'Crystal destroyed! (watch for explosion damage)'); + } catch (_e) { + log(bot, 'Failed to attack crystal directly.'); + } + + // Move away from explosion + await skills.moveAway(bot, 5); + } + } + + // Phase 2: Fight the dragon + log(bot, 'Phase 2: Fighting the Ender Dragon!'); + let dragonAlive = true; + let fightAttempts = 0; + + while (dragonAlive && fightAttempts < 100) { + if (bot.interrupt_code) return false; + fightAttempts++; + await eatIfNeeded(bot); + + // RC30: Golden apple priority when health is critical during dragon fight + if (bot.health < 10) { + const inv = world.getInventoryCounts(bot); + const gapple = (inv['golden_apple'] || 0) > 0 ? 'golden_apple' + : (inv['enchanted_golden_apple'] || 0) > 0 ? 'enchanted_golden_apple' : null; + if (gapple) { + log(bot, `Critical health (${bot.health.toFixed(1)})! Eating ${gapple}!`); + await skills.consume(bot, gapple); + } + } + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // RC30: Void edge avoidance — check before we get too close + const pos = bot.entity.position; + if (pos.y < 5 || (Math.abs(pos.x) > 40 && pos.y < 55) || (Math.abs(pos.z) > 40 && pos.y < 55)) { + log(bot, 'DANGER: Near void edge! Moving to center...'); + await skills.goToPosition(bot, 0, 64, 0, 10); // Center of End island + continue; + } + + // Find the dragon + const dragon = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 128); + + if (!dragon) { + // Dragon might be dead or far away + const dragonEntity = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 256); + if (!dragonEntity) { + log(bot, 'Dragon not found. It might be defeated!'); + dragonAlive = false; + break; + } + // Move toward center where dragon perches + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 3000); + continue; + } + + const dist = bot.entity.position.distanceTo(dragon.position); + + // When dragon is perching on the fountain (near 0,64,0), it's vulnerable + if (dragon.position.y < 70 && dist < 20) { + log(bot, 'Dragon is perching! Attacking!'); + // Equip best sword + await equipBestSword(bot); + try { + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + } catch (_e) { + // Dragon may have moved + } + } else if (hasBow && dist < 64) { + // Shoot with bow when dragon is flying + log(bot, 'Shooting dragon with bow...'); + const bow = bot.inventory.items().find(i => i.name === 'bow'); + if (bow) { + await bot.equip(bow, 'hand'); + const predictedPos = dragon.position.offset( + (dragon.velocity?.x || 0) * 2, + (dragon.velocity?.y || 0) * 2 + 2, + (dragon.velocity?.z || 0) * 2 + ); + await bot.lookAt(predictedPos); + bot.activateItem(); + await new Promise(r => setTimeout(r, 1200)); + bot.deactivateItem(); + } + } else { + // Move toward center and wait for dragon to perch + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 2000); + } + + // Check for experience orbs (dragon death indicator) + const xpOrb = world.getNearestEntityWhere(bot, e => + e.name === 'experience_orb' || e.name === 'xp_orb', 32); + if (xpOrb) { + log(bot, 'Experience orbs detected — Dragon might be dead!'); + dragonAlive = false; + } + } + + if (!dragonAlive) { + log(bot, '🐉 ENDER DRAGON DEFEATED! VICTORY!'); + await skills.pickupNearbyItems(bot); + return true; + } + + log(bot, 'Dragon fight timed out. May need to retry.'); + return false; +} + +async function equipBestSword(bot) { + const swords = bot.inventory.items().filter(i => i.name.includes('sword')); + if (swords.length === 0) return; + // Sort by attack damage (diamond > iron > stone > wooden) + const priority = { 'netherite_sword': 5, 'diamond_sword': 4, 'iron_sword': 3, 'stone_sword': 2, 'golden_sword': 1, 'wooden_sword': 0 }; + swords.sort((a, b) => (priority[b.name] || 0) - (priority[a.name] || 0)); + await bot.equip(swords[0], 'hand'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// META ORCHESTRATOR: Full Dragon Progression (RC29 — persistent + smart) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Complete autonomous run from fresh world to defeating the Ender Dragon. + * Uses persistent DragonProgress to survive restarts and deaths. + * Smart retry with exponential backoff, death recovery, dimension awareness. + * @param {MinecraftBot} bot + * @returns {Promise} true if Ender Dragon defeated. + */ +export async function runDragonProgression(bot) { + log(bot, '╔══════════════════════════════════════════════════╗'); + log(bot, '║ DRAGON PROGRESSION v2 (RC29): Smart Orchestrator║'); + log(bot, '╚══════════════════════════════════════════════════╝'); + + // ── Load or initialize persistent state ──────────────────────────── + const botName = bot.username || bot.entity?.username || 'UnknownBot'; + const progress = new DragonProgress(botName); + progress.load(); + + // Log current state + log(bot, progress.getSummary()); + + // ── Register death handler for this run ──────────────────────────── + let deathOccurred = false; + const deathHandler = () => { + deathOccurred = true; + const pos = bot.entity?.position; + if (pos) { + progress.recordDeath(pos.x, pos.y, pos.z, getDimension(bot)); + } + progress.save().catch(err => console.error('[DragonProgress] Save on death failed:', err)); + }; + bot.on('death', deathHandler); + + // ── RC30: Start progress reporter ────────────────────────────────── + const reporter = new ProgressReporter(bot, progress); + reporter.start(); + + // ── Chunk definitions ────────────────────────────────────────────── + const chunkRunners = { + [CHUNKS.DIAMOND_PICKAXE]: { + name: 'Diamond Pickaxe', + check: () => hasItem(bot, 'diamond_pickaxe') || progress.isChunkDone(CHUNKS.DIAMOND_PICKAXE), + run: () => skills.getDiamondPickaxe(bot), + }, + [CHUNKS.NETHER_PORTAL]: { + name: 'Nether Portal', + check: () => { + if (progress.isChunkDone(CHUNKS.NETHER_PORTAL)) return true; + return world.getNearestBlock(bot, 'nether_portal', 128) !== null; + }, + run: () => buildNetherPortal(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('overworldPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.BLAZE_RODS]: { + name: 'Blaze Rods', + check: () => hasItem(bot, 'blaze_rod', 7) || progress.state.milestones.blazeRods >= 7, + run: () => collectBlazeRods(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.ENDER_PEARLS]: { + name: 'Ender Pearls', + check: () => { + const totalEyeMaterial = countItem(bot, 'ender_pearl') + countItem(bot, 'ender_eye'); + return totalEyeMaterial >= 12 || progress.state.milestones.eyesOfEnder >= 12; + }, + run: () => collectEnderPearls(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.STRONGHOLD]: { + name: 'Stronghold', + check: () => { + if (progress.isChunkDone(CHUNKS.STRONGHOLD)) return true; + return world.getNearestBlock(bot, 'end_portal', 16) !== null; + }, + run: () => locateStronghold(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('stronghold', p.x, p.y, p.z); + progress.setCoord('endPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.DRAGON_FIGHT]: { + name: 'Ender Dragon Fight', + check: () => false, // Always attempt + run: () => defeatEnderDragon(bot), + onSuccess: () => { + progress.setEnteredEnd(true); + }, + }, + }; + + // ── Main orchestration loop ──────────────────────────────────────── + const MAX_RETRIES_PER_CHUNK = 5; + + try { + for (const chunkKey of DragonProgress.CHUNK_ORDER) { + if (bot.interrupt_code) { + log(bot, 'Dragon progression interrupted.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + + const runner = chunkRunners[chunkKey]; + const chunkIdx = DragonProgress.CHUNK_ORDER.indexOf(chunkKey) + 1; + const totalChunks = DragonProgress.CHUNK_ORDER.length; + + // Skip completed chunks + if (runner.check()) { + if (!progress.isChunkDone(chunkKey)) { + progress.markChunkDone(chunkKey); + await progress.save(); + } + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- already complete, skipping.`); + continue; + } + + log(bot, `\n>> Chunk ${chunkIdx}/${totalChunks}: ${runner.name}`); + + // Pre-chunk preparation + await prepareForChunk(bot, chunkKey, progress); + + let success = false; + let retries = 0; + + while (!success && retries < MAX_RETRIES_PER_CHUNK) { + if (bot.interrupt_code) break; + retries++; + + // Handle death recovery between retries + if (deathOccurred) { + deathOccurred = false; + log(bot, `Died during ${runner.name}. Recovering...`); + await new Promise(r => setTimeout(r, 3000)); // Wait for respawn + await recoverFromDeath(bot, progress); + } + + progress.markChunkActive(chunkKey); + await progress.save(); + + const backoffMs = Math.min(1000 * Math.pow(2, retries - 1), 30000); + if (retries > 1) { + log(bot, `Retry ${retries}/${MAX_RETRIES_PER_CHUNK} for ${runner.name} (backoff ${Math.round(backoffMs / 1000)}s)...`); + await new Promise(r => setTimeout(r, backoffMs)); + await eatIfNeeded(bot); + // Explore to fresh area before retrying + if (getDimension(bot) === 'overworld') { + await skills.explore(bot, 100 + retries * 50); + } + } + + try { + success = await runner.run(); + } catch (err) { + log(bot, `Chunk ${runner.name} error: ${err.message}`); + success = false; + } + + if (success) { + // Run onSuccess hook + if (runner.onSuccess) { + try { runner.onSuccess(); } catch (_e) { /* best effort */ } + } + progress.markChunkDone(chunkKey); + progress.updateMilestones(bot); + await progress.save(); + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- COMPLETE!`); + reporter.onChunkChange(); // RC30: trigger progress report on chunk transition + } else if (!bot.interrupt_code) { + progress.markChunkFailed(chunkKey); + await progress.save(); + } + } + + if (!success) { + log(bot, `Chunk ${runner.name} failed after ${MAX_RETRIES_PER_CHUNK} attempts.`); + log(bot, 'Dragon progression paused. Run !beatMinecraft or !dragonProgression to resume.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + } + } finally { + reporter.stop(); // RC30: stop progress reporter + bot.removeListener('death', deathHandler); + } + + // ── Victory! ─────────────────────────────────────────────────────── + log(bot, '\n== ENDER DRAGON DEFEATED! GG! =='); + log(bot, progress.getSummary()); + await progress.save(); + return true; +} diff --git a/src/agent/library/progress_reporter.js b/src/agent/library/progress_reporter.js new file mode 100644 index 000000000..2c97fa018 --- /dev/null +++ b/src/agent/library/progress_reporter.js @@ -0,0 +1,231 @@ +/** + * progress_reporter.js — Periodic Dragon Progression Status Reporter (RC30). + * + * Reports bot status every 5 minutes (or on chunk change) to: + * 1. Console/log output + * 2. Optional Discord webhook (if DISCORD_PROGRESS_WEBHOOK env var is set) + * + * Status includes: current chunk, health/hunger, dimension, location, + * elapsed time, estimated time to next stage, next goal, and optionally + * a screenshot if vision is enabled. + * + * Uses the same safeWriteFile and logging patterns as dragon_progress.js. + */ + +import * as world from './world.js'; +import * as skills from './skills.js'; + +// ── Estimated durations per chunk (minutes), for ETA calculation ──────── +const CHUNK_ESTIMATES = { + diamond_pickaxe: 15, + nether_portal: 12, + blaze_rods: 20, + ender_pearls: 25, + stronghold: 15, + dragon_fight: 20, +}; + +const CHUNK_GOALS = { + diamond_pickaxe: 'Mine diamonds and craft a diamond pickaxe', + nether_portal: 'Collect obsidian and build a Nether portal', + blaze_rods: 'Find a Nether fortress and collect 7+ blaze rods', + ender_pearls: 'Hunt endermen for 12+ ender pearls, craft eyes of ender', + stronghold: 'Triangulate and locate the stronghold / End portal', + dragon_fight: 'Enter the End, destroy crystals, defeat the Ender Dragon', +}; + +/** + * ProgressReporter — attaches to a bot + DragonProgress instance. + * Call start() to begin periodic reporting, stop() to end. + */ +export class ProgressReporter { + /** + * @param {object} bot — mineflayer bot instance + * @param {import('./dragon_progress.js').DragonProgress} progress — dragon state tracker + * @param {object} [options] + * @param {number} [options.intervalMs=300000] — report interval (default 5 min) + * @param {string} [options.webhookUrl] — Discord webhook URL (or set DISCORD_PROGRESS_WEBHOOK env) + * @param {object} [options.visionInterpreter] — VisionInterpreter instance for screenshots + */ + constructor(bot, progress, options = {}) { + this.bot = bot; + this.progress = progress; + this.intervalMs = options.intervalMs || 300_000; // 5 minutes + this.webhookUrl = options.webhookUrl || process.env.DISCORD_PROGRESS_WEBHOOK || null; + this.visionInterpreter = options.visionInterpreter || null; + this._timer = null; + this._startTime = null; + this._lastChunk = null; + this._reportCount = 0; + } + + /** Start the periodic reporter. Safe to call multiple times (idempotent). */ + start() { + if (this._timer) return; // already running + this._startTime = Date.now(); + this._lastChunk = this.progress.currentChunk(); + + // Immediate first report + this._report().catch(err => console.error('[ProgressReporter] First report error:', err.message)); + + this._timer = setInterval(() => { + this._report().catch(err => console.error('[ProgressReporter] Report error:', err.message)); + }, this.intervalMs); + + console.log(`[ProgressReporter] Started — reporting every ${Math.round(this.intervalMs / 60_000)}min`); + } + + /** Stop the reporter. */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + // Send final report + this._report().catch(() => {}); + console.log('[ProgressReporter] Stopped.'); + } + + /** + * Check if chunk changed and trigger an off-cycle report. + * Call this from the orchestrator after each chunk transition. + */ + onChunkChange() { + const current = this.progress.currentChunk(); + if (current !== this._lastChunk) { + this._lastChunk = current; + this._report().catch(err => + console.error('[ProgressReporter] Chunk-change report error:', err.message)); + } + } + + // ── Internal ─────────────────────────────────────────────────────── + + async _report() { + this._reportCount++; + const status = this._buildStatus(); + const text = this._formatConsole(status); + + // Always log to console + skills.log(this.bot, `\n${text}`); + console.log(text); + + // Send to Discord webhook if configured + if (this.webhookUrl) { + await this._sendWebhook(status); + } + } + + _buildStatus() { + const bot = this.bot; + const progress = this.progress; + const pos = bot.entity?.position; + const currentChunk = progress.currentChunk(); + const chunkIndex = progress.currentChunkIndex(); + const totalChunks = progress.constructor.CHUNK_ORDER.length; + + // Elapsed time + const elapsedMs = Date.now() - (this._startTime || Date.now()); + const elapsedMin = Math.round(elapsedMs / 60_000); + + // ETA for current chunk + const chunkAttempts = currentChunk ? progress.getChunkAttempts(currentChunk) : 0; + const estimatedMin = currentChunk ? (CHUNK_ESTIMATES[currentChunk] || 15) : 0; + // Rough ETA: base estimate × (1 + 0.5 * retries) — retries take longer + const etaMin = Math.round(estimatedMin * (1 + 0.3 * chunkAttempts)); + + // Inventory summary + const inv = world.getInventoryCounts(bot); + const keyItems = []; + for (const item of ['diamond_pickaxe', 'diamond_sword', 'iron_sword', 'bow', + 'blaze_rod', 'ender_pearl', 'ender_eye', 'obsidian']) { + const count = inv[item] || 0; + if (count > 0) keyItems.push(`${item}×${count}`); + } + + // Food count + const foodNames = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'apple', 'carrot', 'golden_apple']; + let foodCount = 0; + for (const f of foodNames) foodCount += (inv[f] || 0); + + return { + botName: bot.username || 'Bot', + chunk: currentChunk || 'COMPLETE', + chunkIndex: chunkIndex + 1, + totalChunks, + chunkName: currentChunk ? (CHUNK_GOALS[currentChunk] || currentChunk) : 'Dragon defeated!', + health: bot.health?.toFixed(1) || '?', + hunger: bot.food ?? '?', + dimension: bot.game?.dimension || 'unknown', + position: pos ? `${Math.floor(pos.x)}, ${Math.floor(pos.y)}, ${Math.floor(pos.z)}` : 'unknown', + elapsedMin, + etaMin, + deaths: progress.state.stats.deaths, + totalRetries: progress.state.stats.totalRetries, + keyItems, + foodCount, + reportNumber: this._reportCount, + }; + } + + _formatConsole(s) { + const bar = '═'.repeat(50); + return [ + `╔${bar}╗`, + `║ PROGRESS REPORT #${s.reportNumber}`, + `╠${bar}╣`, + `║ Bot: ${s.botName}`, + `║ Chunk: ${s.chunkIndex}/${s.totalChunks} — ${s.chunk}`, + `║ Goal: ${s.chunkName}`, + `║ Health: ${s.health}/20 Hunger: ${s.hunger}/20 Food: ${s.foodCount}`, + `║ Dimension: ${s.dimension}`, + `║ Position: ${s.position}`, + `║ Elapsed: ${s.elapsedMin}min ETA chunk: ~${s.etaMin}min`, + `║ Deaths: ${s.deaths} Retries: ${s.totalRetries}`, + s.keyItems.length > 0 ? `║ Key items: ${s.keyItems.join(', ')}` : null, + `╚${bar}╝`, + ].filter(Boolean).join('\n'); + } + + async _sendWebhook(status) { + if (!this.webhookUrl) return; + try { + const embed = { + title: `🐉 Progress Report #${status.reportNumber}`, + color: status.chunk === 'COMPLETE' ? 0x00ff00 : 0x7289da, + fields: [ + { name: 'Chunk', value: `${status.chunkIndex}/${status.totalChunks} — ${status.chunk}`, inline: true }, + { name: 'Goal', value: status.chunkName, inline: false }, + { name: 'Health', value: `${status.health}/20`, inline: true }, + { name: 'Hunger', value: `${status.hunger}/20`, inline: true }, + { name: 'Food', value: `${status.foodCount}`, inline: true }, + { name: 'Dimension', value: status.dimension, inline: true }, + { name: 'Position', value: status.position, inline: true }, + { name: 'Elapsed', value: `${status.elapsedMin}min`, inline: true }, + { name: 'ETA', value: `~${status.etaMin}min`, inline: true }, + { name: 'Deaths', value: `${status.deaths}`, inline: true }, + { name: 'Retries', value: `${status.totalRetries}`, inline: true }, + ], + timestamp: new Date().toISOString(), + }; + + if (status.keyItems.length > 0) { + embed.fields.push({ name: 'Key Items', value: status.keyItems.join(', '), inline: false }); + } + + const payload = { + username: `${status.botName} Progress`, + embeds: [embed], + }; + + await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.warn('[ProgressReporter] Webhook send failed:', err.message); + } + } +} diff --git a/src/agent/library/skills.js b/src/agent/library/skills.js index 715455073..b999d7997 100644 --- a/src/agent/library/skills.js +++ b/src/agent/library/skills.js @@ -1,9 +1,13 @@ import * as mc from "../../utils/mcdata.js"; import * as world from "./world.js"; -import pf from 'mineflayer-pathfinder'; +import baritoneModule from '@miner-org/mineflayer-baritone'; +import pf from 'mineflayer-pathfinder'; // RC25: retained ONLY for Movements.safeToBreak() in collectBlock import Vec3 from 'vec3'; import settings from "../../../settings.js"; +// RC25: Baritone A* pathfinding goals (replaces mineflayer-pathfinder goals) +const baritoneGoals = baritoneModule.goals; + const blockPlaceDelay = settings.block_place_delay == null ? 0 : settings.block_place_delay; const useDelay = blockPlaceDelay > 0; @@ -16,7 +20,7 @@ async function autoLight(bot) { try { const pos = world.getPosition(bot); return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); - } catch (err) {return false;} + } catch (_err) {return false;} } return false; } @@ -65,6 +69,10 @@ export async function craftRecipe(bot, itemName, num=1) { let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; if (hasTable) { let pos = world.getNearestFreeSpace(bot, 1, 6); + if (!pos) { + log(bot, `No free space to place crafting table.`); + return false; + } await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z); craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); if (craftingTable) { @@ -73,7 +81,7 @@ export async function craftRecipe(bot, itemName, num=1) { } } else { - log(bot, `Crafting ${itemName} requires a crafting table.`) + log(bot, `Crafting ${itemName} requires a crafting table.`); return false; } } @@ -100,7 +108,15 @@ export async function craftRecipe(bot, itemName, num=1) { const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe); //Items required to use the recipe once. const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients); - await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable); + try { + await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable); + } catch (err) { + log(bot, `Failed to craft ${itemName}: ${err.message}`); + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1); + } + return false; + } if(craftLimit.num 4) { @@ -222,7 +238,7 @@ export async function smeltItem(bot, itemName, num=1) { } await furnace.putFuel(fuel.type, null, put_fuel); log(bot, `Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`); - console.log(`Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`) + console.log(`Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`); } // put the items in the furnace await furnace.putInput(mc.getItemId(itemName), null, num); @@ -291,7 +307,7 @@ export async function clearNearestFurnace(bot) { console.log('clearing furnace...'); const furnace = await bot.openFurnace(furnaceBlock); - console.log('opened furnace...') + console.log('opened furnace...'); // take the items out of the furnace let smelted_item, intput_item, fuel_item; if (furnace.outputItem()) @@ -300,7 +316,7 @@ export async function clearNearestFurnace(bot) { intput_item = await furnace.takeInput(); if (furnace.fuelItem()) fuel_item = await furnace.takeFuel(); - console.log(smelted_item, intput_item, fuel_item) + console.log(smelted_item, intput_item, fuel_item); let smelted_name = smelted_item ? `${smelted_item.count} ${smelted_item.name}` : `0 smelted items`; let input_name = intput_item ? `${intput_item.count} ${intput_item.name}` : `0 input items`; let fuel_name = fuel_item ? `${fuel_item.count} ${fuel_item.name}` : `0 fuel items`; @@ -341,18 +357,31 @@ export async function attackEntity(bot, entity, kill=true) { * await skills.attackEntity(bot, entity); **/ + if (!entity || !bot.entities[entity.id]) { + log(bot, 'Entity no longer exists, skipping attack.'); + return false; + } + let pos = entity.position; - await equipHighestAttack(bot) + await equipHighestAttack(bot); if (!kill) { if (bot.entity.position.distanceTo(pos) > 5) { - console.log('moving to mob...') + console.log('moving to mob...'); await goToPosition(bot, pos.x, pos.y, pos.z); } - console.log('attacking mob...') + if (!bot.entities[entity.id]) { + log(bot, 'Entity despawned during approach, skipping attack.'); + return false; + } + console.log('attacking mob...'); await bot.attack(entity); } else { + if (!bot.entities[entity.id]) { + log(bot, 'Entity despawned before pvp start, skipping attack.'); + return false; + } bot.pvp.attack(entity); while (world.getNearbyEntities(bot, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)); @@ -384,16 +413,17 @@ export async function defendSelf(bot, range=9) { await equipHighestAttack(bot); if (bot.entity.position.distanceTo(enemy.position) >= 4 && enemy.name !== 'creeper' && enemy.name !== 'phantom') { try { - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(new pf.goals.GoalFollow(enemy, 3.5), true); - } catch (err) {/* might error if entity dies, ignore */} + // RC25: Baritone chase to melee range + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(enemy.position, 3.5)); + } catch (_err) {/* might error if entity dies, ignore */} } if (bot.entity.position.distanceTo(enemy.position) <= 2) { try { - bot.pathfinder.setMovements(new pf.Movements(bot)); - let inverted_goal = new pf.goals.GoalInvert(new pf.goals.GoalFollow(enemy, 2)); - await bot.pathfinder.goto(inverted_goal, true); - } catch (err) {/* might error if entity dies, ignore */} + // RC25: Baritone retreat from too-close enemy + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalAvoid(enemy.position, 4, bot)); + } catch (_err) {/* might error if entity dies, ignore */} } bot.pvp.attack(enemy); attacked = true; @@ -412,7 +442,22 @@ export async function defendSelf(bot, range=9) { return attacked; } - +// RC24: Timeout helper for Paper server compatibility. +// bot.ashfinder.gotoSmart() and bot.dig() can hang indefinitely on Paper due +// to event handling differences. This races the operation against a timer and +// calls onTimeout (e.g. ashfinder.stop()) to cancel if it exceeds the limit. +function withTimeout(promise, ms, onTimeout) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + if (onTimeout) onTimeout(); + reject(new Error(`Timed out after ${ms}ms`)); + }, ms); + }) + ]).finally(() => clearTimeout(timer)); +} export async function collectBlock(bot, blockType, num=1, exclude=null) { /** @@ -429,6 +474,12 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { log(bot, `Invalid number of blocks to collect: ${num}.`); return false; } + + // RC18b: Pause unstuck mode during collection. Mining/navigating to blocks + // causes the bot to appear "stuck" (moving slowly or pausing while digging), + // triggering false unstuck interruptions that abort the collection. + bot.modes.pause('unstuck'); + let blocktypes = [blockType]; if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' || blockType === 'redstone') blocktypes.push(blockType+'_ore'); @@ -438,9 +489,20 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { blocktypes.push('grass_block'); if (blockType === 'cobblestone') blocktypes.push('stone'); + // RC13: If requesting any log type, also accept other log variants as fallback + // This prevents bots from starving for wood when oak isn't available but birch/spruce is + // RC27: Don't eagerly add all log types — only expand AFTER the primary type yields 0 results. + // Eager expansion causes the bot to target wrong biome logs (e.g., acacia underground) + // when the requested type (oak) exists on the surface nearby. + const isLogFallbackEligible = blockType.endsWith('_log'); + let logFallbackExpanded = false; const isLiquid = blockType === 'lava' || blockType === 'water'; let collected = 0; + let consecutiveFails = 0; + let interruptRetries = 0; // RC20: track retries from mode interruptions (e.g., self_defense) + const MAX_CONSECUTIVE_FAILS = 3; // break out after 3 consecutive failed attempts + const MAX_INTERRUPT_RETRIES = 8; // RC20: max total retries from combat/mode interruptions const movements = new pf.Movements(bot); movements.dontMineUnderFallingBlock = false; @@ -449,14 +511,31 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { // Blocks to ignore safety for, usually next to lava/water const unsafeBlocks = ['obsidian']; + // RC13 fix: Log/wood blocks have no gravity and are always safe to break. + // mineflayer-pathfinder's safeToBreak() is overly conservative for tree logs, + // causing searchForBlock to find logs that collectBlock then filters out. + const isNoGravityNaturalBlock = blockType.endsWith('_log') || blockType.endsWith('_wood') || + blockType.endsWith('_stem') || blockType === 'mushroom_stem' || + blockType.endsWith('leaves') || blockType.endsWith('_planks'); + for (let i=0; i { + // RC18 FIX: mineflayer's findBlocks has a section palette pre-filter + // (isBlockInSection) that creates blocks via Block.fromStateId() which + // sets position=null. We MUST check block name first and return true + // for palette pre-filter (position=null) to avoid skipping entire sections. + // Previously: `if (!block.position || !blocktypes.includes(block.name)) return false` + // caused ALL sections to be skipped because position is always null during palette check. if (!blocktypes.includes(block.name)) { return false; } + // If position is null, we're in the palette pre-filter — name matched, + // so tell mineflayer this section might contain our target block. + if (!block.position) return true; + if (exclude) { for (let position of exclude) { - if (block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { + if (position && block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { return false; } } @@ -466,17 +545,29 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { return block.metadata === 0; } - return movements.safeToBreak(block) || unsafeBlocks.includes(block.name); - }, 64, 1); + return movements.safeToBreak(block) || unsafeBlocks.includes(block.name) || isNoGravityNaturalBlock; + }, 128, 1); // RC17: Increased from 64 to 128 to match searchForBlock range if (blocks.length === 0) { + // RC27: If no primary log type found, expand to all log variants as fallback + if (isLogFallbackEligible && !logFallbackExpanded) { + logFallbackExpanded = true; + const allLogs = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + for (const logType of allLogs) { + if (!blocktypes.includes(logType)) blocktypes.push(logType); + } + console.log(`[RC27] No ${blockType} found, expanding search to all log types`); + i--; // retry this iteration with expanded types + continue; + } if (collected === 0) - log(bot, `No ${blockType} nearby to collect.`); + log(bot, `No ${blockType} found within 128 blocks. Gathering system is working fine — this area simply has none. Use !explore(200) to travel far enough to find new resources, then retry. Do NOT use !searchForBlock — explore first to load fresh chunks.`); else - log(bot, `No more ${blockType} nearby to collect.`); + log(bot, `No more ${blockType} nearby to collect. Successfully collected ${collected} so far.`); break; } const block = blocks[0]; + await bot.tool.equipForBlock(block); if (isLiquid) { const bucket = bot.inventory.items().find(item => item.name === 'bucket'); @@ -486,37 +577,161 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { } await bot.equip(bucket, 'hand'); } - const itemId = bot.heldItem ? bot.heldItem.type : null + const itemId = bot.heldItem ? bot.heldItem.type : null; if (!block.canHarvest(itemId)) { log(bot, `Don't have right tools to harvest ${blockType}.`); return false; } try { let success = false; + const invBefore = world.getInventoryCounts(bot); if (isLiquid) { success = await useToolOnBlock(bot, 'bucket', block); } else if (mc.mustCollectManually(blockType)) { - await goToPosition(bot, block.position.x, block.position.y, block.position.z, 2); - await bot.dig(block); - await pickupNearbyItems(bot); - success = true; + // RC24c: Distance-adaptive timeout for manual collection + const dist = bot.entity.position.distanceTo(block.position); + const navTimeout = Math.max(20000, Math.round(dist * 1000) + 10000); + console.log(`[RC24] Manual-collect ${blockType} at (${block.position.x}, ${block.position.y}, ${block.position.z}) dist=${Math.round(dist)} timeout=${navTimeout}ms`); + await withTimeout( + goToPosition(bot, block.position.x, block.position.y, block.position.z, 2), + navTimeout, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // RC26: Re-fetch block after navigation (see RC26 comment above) + const freshBlockManual = bot.blockAt(block.position); + if (!freshBlockManual || !blocktypes.includes(freshBlockManual.name)) { + console.log(`[RC26] Manual block at (${block.position.x}, ${block.position.y}, ${block.position.z}) changed to ${freshBlockManual?.name ?? 'null'}, skipping`); + if (!exclude) exclude = []; + exclude.push(block.position); + i--; // retry this slot with a different block + continue; + } + console.log(`[RC24] Digging ${freshBlockManual.name} (manual)`); + await withTimeout( + bot.dig(freshBlockManual), + 10000, + () => { try { bot.stopDigging(); } catch(_e) {} } + ); + await new Promise(r => setTimeout(r, 300)); + await withTimeout( + pickupNearbyItems(bot), + 8000, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // Verify items actually entered inventory + const invAfter = world.getInventoryCounts(bot); + const totalBefore = Object.values(invBefore).reduce((a, b) => a + b, 0); + const totalAfter = Object.values(invAfter).reduce((a, b) => a + b, 0); + success = totalAfter > totalBefore; + console.log(`[RC24] Manual-collect result: success=${success}`); } else { - await bot.collectBlock.collect(block); - success = true; + // RC24: Manual dig with timeout protection for Paper servers. + // bot.collectBlock.collect() hangs indefinitely on Paper due to event + // handling differences. Manual dig also needs timeouts since + // pathfinder.goto() has no built-in timeout. + try { + // RC24c: Scale nav timeout by distance — bot walks ~4 blocks/sec, + // plus overhead for path computation and terrain navigation. + const dist = bot.entity.position.distanceTo(block.position); + const navTimeout = Math.max(20000, Math.round(dist * 1000) + 10000); + console.log(`[RC24] Navigating to ${blockType} at (${block.position.x}, ${block.position.y}, ${block.position.z}) dist=${Math.round(dist)} timeout=${navTimeout}ms`); + await withTimeout( + goToPosition(bot, block.position.x, block.position.y, block.position.z, 2), + navTimeout, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // RC26: Re-fetch block at position after navigation. + // The original block reference can go stale if chunks + // unloaded/reloaded during pathfinding, causing bot.dig() + // to silently no-op (items 0→0). + const freshBlock = bot.blockAt(block.position); + if (!freshBlock || !blocktypes.includes(freshBlock.name)) { + console.log(`[RC26] Block at (${block.position.x}, ${block.position.y}, ${block.position.z}) changed to ${freshBlock?.name ?? 'null'}, skipping`); + if (!exclude) exclude = []; + exclude.push(block.position); + i--; // retry this slot with a different block + continue; + } + console.log(`[RC24] Digging ${freshBlock.name}`); + await withTimeout( + bot.dig(freshBlock), + 10000, + () => { try { bot.stopDigging(); } catch(_e) {} } + ); + console.log(`[RC24] Picking up items`); + await new Promise(r => setTimeout(r, 300)); + await withTimeout( + pickupNearbyItems(bot), + 8000, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + const invAfter = world.getInventoryCounts(bot); + const totalBefore = Object.values(invBefore).reduce((a, b) => a + b, 0); + const totalAfter = Object.values(invAfter).reduce((a, b) => a + b, 0); + success = totalAfter > totalBefore; + console.log(`[RC24] Result: success=${success}, items ${totalBefore}→${totalAfter}`); + } catch (_digErr) { + // RC24b: Re-throw "aborted" errors so the outer RC20 handler + // can retry them (self_defense/combat interruption recovery) + if (_digErr.message && _digErr.message.includes('aborted')) { + throw _digErr; + } + console.log(`[RC24] Failed for ${blockType}: ${_digErr.message}`); + try { bot.ashfinder.stop(); } catch(_e) {} + } + if (!success) { + if (!exclude) exclude = []; + exclude.push(block.position); + } } - if (success) + if (success) { + collected++; + consecutiveFails = 0; + } else { + + // Exclude this position so we don't keep retrying the same unreachable block + if (block && block.position) { + if (!exclude) exclude = []; + exclude.push(block.position); + } + consecutiveFails++; + if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + log(bot, `Failed to collect ${blockType} ${MAX_CONSECUTIVE_FAILS} times in a row. Blocks may be unreachable. Use !explore(200) to travel to a completely new area, then retry.`); + break; + } + } await autoLight(bot); } catch (err) { + if (err.name === 'NoChests') { log(bot, `Failed to collect ${blockType}: Inventory full, no place to deposit.`); break; } + // RC20: "Digging aborted" comes from self_defense/self_preservation interrupting the dig. + // Don't count this as a real failure — wait for the mode to finish, then retry the same block. + else if (err.message && err.message.includes('aborted') && interruptRetries < MAX_INTERRUPT_RETRIES) { + interruptRetries++; + console.log(`[RC20] Dig interrupted (retry ${interruptRetries}/${MAX_INTERRUPT_RETRIES}), waiting for mode to finish...`); + await new Promise(r => setTimeout(r, 2000)); // Wait 2s for combat/mode to finish + i--; // Retry the same block on next loop iteration + continue; + } else { log(bot, `Failed to collect ${blockType}: ${err}.`); + // Exclude this block position so we don't retry it + if (block && block.position) { + if (!exclude) exclude = []; + exclude.push(block.position); + } + consecutiveFails++; + if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + log(bot, `Failed ${MAX_CONSECUTIVE_FAILS} times in a row. Blocks may be unreachable. Use !explore(200) to travel to a completely new area, then retry.`); + break; + } continue; } } @@ -524,7 +739,14 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { if (bot.interrupt_code) break; } - log(bot, `Collected ${collected} ${blockType}.`); + // RC18b: Resume unstuck mode after collection is done + bot.modes.unpause('unstuck'); + + if (collected === 0 && num > 0) { + log(bot, `Collected 0 ${blockType}. There are no ${blockType} blocks in this area (commands are working correctly). You MUST relocate far away: !explore(200) to reach a completely new area with fresh resources. Do NOT say gathering is broken — it works fine, you just need to travel farther.`); + } else { + log(bot, `Collected ${collected} ${blockType}.`); + } return collected > 0; } @@ -536,25 +758,36 @@ export async function pickupNearbyItems(bot) { * @example * await skills.pickupNearbyItems(bot); **/ - const distance = 8; + const distance = 10; const getNearestItem = bot => bot.nearestEntity(entity => entity.name === 'item' && bot.entity.position.distanceTo(entity.position) < distance); let nearestItem = getNearestItem(bot); let pickedUp = 0; - while (nearestItem) { - let movements = new pf.Movements(bot); - movements.canDig = false; - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalFollow(nearestItem, 1)); - await new Promise(resolve => setTimeout(resolve, 200)); - let prev = nearestItem; - nearestItem = getNearestItem(bot); - if (prev === nearestItem) { - break; + const maxAttempts = 10; + let attempts = 0; + while (nearestItem && attempts < maxAttempts) { + attempts++; + const invBefore = bot.inventory.items().reduce((sum, item) => sum + item.count, 0); + // RC25: Navigate to item without breaking blocks + const prevBreak = bot.ashfinder.config.breakBlocks; + bot.ashfinder.config.breakBlocks = false; + try { + await goToGoal(bot, new baritoneGoals.GoalNear(nearestItem.position, 1)); + } finally { + bot.ashfinder.config.breakBlocks = prevBreak; } - pickedUp++; + // Wait for item pickup with increasing delays + for (let wait = 0; wait < 5; wait++) { + await new Promise(resolve => setTimeout(resolve, 200)); + const invAfter = bot.inventory.items().reduce((sum, item) => sum + item.count, 0); + if (invAfter > invBefore) { + pickedUp += (invAfter - invBefore); + break; + } + } + nearestItem = getNearestItem(bot); } log(bot, `Picked up ${pickedUp} items.`); - return true; + return pickedUp > 0; } @@ -582,16 +815,18 @@ export async function breakBlockAt(bot, x, y, z) { } if (bot.entity.position.distanceTo(block.position) > 4.5) { - let pos = block.position; - let movements = new pf.Movements(bot); - movements.canPlaceOn = false; - movements.allow1by1towers = false; - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Navigate to block with baritone (no placing for breakBlockAt) + const prevPlace = bot.ashfinder.config.placeBlocks; + bot.ashfinder.config.placeBlocks = false; + try { + await goToGoal(bot, new baritoneGoals.GoalNear(block.position, 4)); + } finally { + bot.ashfinder.config.placeBlocks = prevPlace; + } } if (bot.game.gameMode !== 'creative') { await bot.tool.equipForBlock(block); - const itemId = bot.heldItem ? bot.heldItem.type : null + const itemId = bot.heldItem ? bot.heldItem.type : null; if (!block.canHarvest(itemId)) { log(bot, `Don't have right tools to break ${block.name}.`); return false; @@ -723,7 +958,7 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont 'south': Vec3(0, 0, 1), 'east': Vec3(1, 0, 0), 'west': Vec3(-1, 0, 0), - } + }; let dirs = []; if (placeOn === 'side') { dirs.push(dir_map['north'], dir_map['south'], dir_map['east'], dir_map['west']); @@ -755,18 +990,13 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont const dont_move_for = ['torch', 'redstone_torch', 'redstone', 'lever', 'button', 'rail', 'detector_rail', 'powered_rail', 'activator_rail', 'tripwire_hook', 'tripwire', 'water_bucket', 'string']; if (!dont_move_for.includes(item_name) && (pos.distanceTo(targetBlock.position) < 1.1 || pos_above.distanceTo(targetBlock.position) < 1.1)) { - // too close - let goal = new pf.goals.GoalNear(targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(inverted_goal); + // RC25: Too close — move away using baritone GoalAvoid + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalAvoid(targetBlock.position, 2, bot)); } if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { - // too far - let pos = targetBlock.position; - let movements = new pf.Movements(bot); - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Too far — navigate closer with baritone + await goToGoal(bot, new baritoneGoals.GoalNear(targetBlock.position, 4)); } // will throw error if an entity is in the way, and sometimes even if the block was placed @@ -782,7 +1012,7 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont await new Promise(resolve => setTimeout(resolve, 200)); return true; } - } catch (err) { + } catch (_err) { log(bot, `Failed to place ${blockType} at ${target_dest}.`); return false; } @@ -1010,7 +1240,7 @@ export async function giveToPlayer(bot, itemType, username, num=1) { log(bot, `You cannot give items to yourself.`); return false; } - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) { log(bot, `Could not find ${username}.`); return false; @@ -1069,45 +1299,109 @@ export async function giveToPlayer(bot, itemType, username, num=1) { export async function goToGoal(bot, goal) { /** - * Navigate to the given goal. Use doors and attempt minimally destructive movements. + * RC25b: Navigate to the given goal using Baritone A* pathfinding. + * Uses bot.ashfinder.gotoSmart() which auto-chooses between direct A* + * and waypoint navigation based on distance. + * + * CRITICAL: gotoSmart() awaits the PathExecutor's completionPromise, but + * executor.stop() only rejects currentPromise — NOT completionPromise. + * This means gotoSmart() hangs forever if stopped externally. We MUST wrap + * it with Promise.race + timeout. After timeout, we check proximity to goal + * since Paper server position corrections prevent the executor's tight reach + * check (0.35 blocks) from passing even when the bot is very close. + * * @param {MinecraftBot} bot, reference to the minecraft bot. - * @param {pf.goals.Goal} goal, the goal to navigate to. + * @param {Goal} goal, a baritone goal to navigate to. **/ - const nonDestructiveMovements = new pf.Movements(bot); - const dontBreakBlocks = ['glass', 'glass_pane']; - for (let block of dontBreakBlocks) { - nonDestructiveMovements.blocksCantBreak.add(mc.getBlockId(block)); + // Ensure any previous navigation is stopped before starting new one + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + + // Add glass types to blocksToAvoid so baritone prefers non-destructive paths + const blocksToAvoid = bot.ashfinder.config.blocksToAvoid || []; + const glassList = ['glass', 'glass_pane']; + const addedBlocks = []; + for (const name of glassList) { + if (!blocksToAvoid.includes(name)) { + blocksToAvoid.push(name); + addedBlocks.push(name); + } } - nonDestructiveMovements.placeCost = 2; - nonDestructiveMovements.digCost = 10; - const destructiveMovements = new pf.Movements(bot); + // RC25b: Calculate timeout based on distance to goal + // Paper server's movement corrections make executor reach checks unreliable, + // so we need our own timeout to prevent infinite hangs. + let navTimeout = 30000; // default 30s + try { + const goalPos = goal.pos || (goal.getPosition ? goal.getPosition() : null); + if (goalPos) { + const dist = bot.entity.position.distanceTo(goalPos); + navTimeout = Math.max(12000, Math.round(dist * 1200) + 5000); + } + } catch (_) {} - let final_movements = destructiveMovements; + const doorCheckInterval = startDoorInterval(bot); + let timeoutId = null; - const pathfind_timeout = 1000; - if (await bot.pathfinder.getPathTo(nonDestructiveMovements, goal, pathfind_timeout).status === 'success') { - final_movements = nonDestructiveMovements; - log(bot, `Found non-destructive path.`); - } - else if (await bot.pathfinder.getPathTo(destructiveMovements, goal, pathfind_timeout).status === 'success') { - log(bot, `Found destructive path.`); - } - else { - log(bot, `Path not found, but attempting to navigate anyway using destructive movements.`); - } + const restoreConfig = () => { + clearInterval(doorCheckInterval); + if (timeoutId) clearTimeout(timeoutId); + for (const name of addedBlocks) { + const idx = blocksToAvoid.indexOf(name); + if (idx !== -1) blocksToAvoid.splice(idx, 1); + } + }; - const doorCheckInterval = startDoorInterval(bot); + // RC25b: Helper — check if bot is close enough to goal to count as success. + // Paper's position corrections prevent the executor from passing its tight + // reach threshold (0.35 blocks), but the bot is often within 2-4 blocks. + // Use generous threshold: goal.distance + 2 (or 5 as fallback). + const isCloseEnough = () => { + try { + const goalPos = goal.pos || (goal.getPosition ? goal.getPosition() : null); + if (!goalPos) return false; + const dist = bot.entity.position.distanceTo(goalPos); + const threshold = (goal.distance || 4) + 2; + return dist <= threshold; + } catch (_e) { return false; } + }; - bot.pathfinder.setMovements(final_movements); try { - await bot.pathfinder.goto(goal); - clearInterval(doorCheckInterval); - return true; + // RC25b: Wrap gotoSmart with Promise.race because the PathExecutor's + // stop() doesn't reject completionPromise, causing gotoSmart to hang forever. + const result = await Promise.race([ + bot.ashfinder.gotoSmart(goal).catch(err => { + // RC27: Baritone executor.js:185 can crash with "Cannot read properties + // of undefined (reading 'length')" when this.path becomes null mid-execution. + // Catch this here so it doesn't crash the entire process. + if (err?.message?.includes('Cannot read properties of undefined')) { + console.warn(`[RC27] Baritone internal error (non-fatal): ${err.message}`); + return { status: 'error', error: err }; + } + throw err; + }), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + try { bot.ashfinder.stop(); } catch (_) {} + resolve({ status: 'timeout' }); + }, navTimeout); + }) + ]); + + restoreConfig(); + + // Check proximity first — if close enough, it's success regardless of executor status + if (isCloseEnough()) return true; + + if (result && result.status === 'success') return true; + if (result && result.status === 'timeout') { + throw new Error(`Navigation timed out after ${Math.round(navTimeout/1000)}s`); + } + throw new Error(result?.error?.message || 'Navigation failed'); } catch (err) { - clearInterval(doorCheckInterval); - // we need to catch so we can clean up the door check interval, then rethrow the error + restoreConfig(); + // Even on error, if we're close enough, consider it success + if (isCloseEnough()) return true; throw err; } } @@ -1116,6 +1410,9 @@ let _doorInterval = null; function startDoorInterval(bot) { /** * Start helper interval that opens nearby doors if the bot is stuck. + * Phase 1 (1.2s stuck): Try opening doors, fence gates, trapdoors. + * Phase 2 (8s stuck): Last resort — temporarily enable breakBlocks so + * Baritone can dig through the obstacle, then disable again. * @param {MinecraftBot} bot, reference to the minecraft bot. * @returns {number} the interval id. **/ @@ -1125,17 +1422,27 @@ function startDoorInterval(bot) { let prev_pos = bot.entity.position.clone(); let prev_check = Date.now(); let stuck_time = 0; + let doorAttempted = false; // track whether we already tried doors this stuck episode + const DOOR_THRESHOLD = 1200; // ms — try doors first + const BREAK_THRESHOLD = 8000; // ms — last resort: enable block breaking const doorCheckInterval = setInterval(() => { const now = Date.now(); if (bot.entity.position.distanceTo(prev_pos) >= 0.1) { stuck_time = 0; + doorAttempted = false; + // RC26: If we previously enabled breakBlocks as last resort, disable it again + if (bot.ashfinder && bot.ashfinder.config.breakBlocks) { + bot.ashfinder.config.breakBlocks = false; + } } else { stuck_time += now - prev_check; } - - if (stuck_time > 1200) { + + // Phase 1: Open doors / fence gates / trapdoors + if (stuck_time > DOOR_THRESHOLD && !doorAttempted) { + doorAttempted = true; // shuffle positions so we're not always opening the same door const positions = [ bot.entity.position.clone(), @@ -1143,7 +1450,7 @@ function startDoorInterval(bot) { bot.entity.position.offset(0, 0, -1), bot.entity.position.offset(1, 0, 0), bot.entity.position.offset(-1, 0, 0), - ] + ]; let elevated_positions = positions.map(position => position.offset(0, 1, 0)); positions.push(...elevated_positions); positions.push(bot.entity.position.offset(0, 2, 0)); // above head @@ -1169,8 +1476,18 @@ function startDoorInterval(bot) { break; } } + } + + // Phase 2: Last resort — enable block breaking temporarily + if (stuck_time > BREAK_THRESHOLD) { + if (bot.ashfinder && !bot.ashfinder.config.breakBlocks) { + console.log('[RC26] Stuck >8s after door attempts — enabling breakBlocks as last resort'); + bot.ashfinder.config.breakBlocks = true; + } stuck_time = 0; + doorAttempted = false; } + prev_pos = bot.entity.position.clone(); prev_check = now; }, 200); @@ -1201,22 +1518,43 @@ export async function goToPosition(bot, x, y, z, min_distance=2) { return true; } + let lastDigTarget = null; + let unharvestableTicks = 0; const checkDigProgress = () => { if (bot.targetDigBlock) { const targetBlock = bot.targetDigBlock; const itemId = bot.heldItem ? bot.heldItem.type : null; if (!targetBlock.canHarvest(itemId)) { - log(bot, `Pathfinding stopped: Cannot break ${targetBlock.name} with current tools.`); - bot.pathfinder.stop(); - bot.stopDigging(); + // RC27: Only abort after 2 consecutive checks on the same unharvstable block. + // Single transient ticks happen when the pathfinder equips tools mid-dig. + if (lastDigTarget && lastDigTarget.x === targetBlock.position.x && + lastDigTarget.y === targetBlock.position.y && + lastDigTarget.z === targetBlock.position.z) { + unharvestableTicks++; + } else { + lastDigTarget = targetBlock.position.clone(); + unharvestableTicks = 1; + } + if (unharvestableTicks >= 2) { + log(bot, `Pathfinding stopped: Cannot break ${targetBlock.name} with current tools.`); + bot.ashfinder.stop(); + bot.stopDigging(); + unharvestableTicks = 0; + } + } else { + unharvestableTicks = 0; + lastDigTarget = null; } + } else { + unharvestableTicks = 0; + lastDigTarget = null; } }; const progressInterval = setInterval(checkDigProgress, 1000); try { - await goToGoal(bot, new pf.goals.GoalNear(x, y, z, min_distance)); + await goToGoal(bot, new baritoneGoals.GoalNear(new Vec3(x, y, z), min_distance)); clearInterval(progressInterval); const distance = bot.entity.position.distanceTo(new Vec3(x, y, z)); if (distance <= min_distance+1) { @@ -1261,13 +1599,32 @@ export async function goToNearestBlock(bot, blockType, min_distance=2, range=64 } else { block = world.getNearestBlock(bot, blockType, range); + // RC13: If searching for a log type and none found, try any log type as fallback + if (!block && blockType.endsWith('_log')) { + const allLogs = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + for (const logType of allLogs) { + if (logType !== blockType) { + block = world.getNearestBlock(bot, logType, range); + if (block) { + log(bot, `No ${blockType} found, but found ${logType} instead.`); + break; + } + } + } + } } if (!block) { log(bot, `Could not find any ${blockType} in ${range} blocks.`); return false; } log(bot, `Found ${blockType} at ${block.position}. Navigating...`); - await goToPosition(bot, block.position.x, block.position.y, block.position.z, min_distance); + // RC17: Pause unstuck during navigation to prevent false stuck detection + bot.modes.pause('unstuck'); + try { + await goToPosition(bot, block.position.x, block.position.y, block.position.z, min_distance); + } finally { + bot.modes.unpause('unstuck'); + } return true; } @@ -1313,16 +1670,17 @@ export async function goToPlayer(bot, username, distance=3) { bot.modes.pause('self_defense'); bot.modes.pause('cowardice'); - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) { log(bot, `Could not find ${username}.`); return false; } distance = Math.max(distance, 0.5); - const goal = new pf.goals.GoalFollow(player, distance); + // RC25: Baritone — use GoalNear with player's current position instead of GoalFollow + const goal = new baritoneGoals.GoalNear(player.position, distance); - await goToGoal(bot, goal, true); + await goToGoal(bot, goal); log(bot, `You have reached ${username}.`); } @@ -1337,16 +1695,14 @@ export async function followPlayer(bot, username, distance=4) { * @example * await skills.followPlayer(bot, "player"); **/ - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) return false; - const move = new pf.Movements(bot); - move.digCost = 10; - bot.pathfinder.setMovements(move); + // RC25: Baritone followEntity for continuous following + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + bot.ashfinder.followEntity(player, { distance: distance, updateInterval: 500 }); let doorCheckInterval = startDoorInterval(bot); - - bot.pathfinder.setGoal(new pf.goals.GoalFollow(player, distance), true); log(bot, `You are now actively following player ${username}.`); @@ -1389,6 +1745,7 @@ export async function followPlayer(bot, username, distance=4) { bot.modes.unpause('elbow_room'); } } + bot.ashfinder.stopFollowing(); // RC25: stop baritone entity following clearInterval(doorCheckInterval); return true; } @@ -1404,15 +1761,14 @@ export async function moveAway(bot, distance) { * await skills.moveAway(bot, 8); **/ const pos = bot.entity.position; - let goal = new pf.goals.GoalNear(pos.x, pos.y, pos.z, distance); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); + // RC25: Baritone GoalAvoid moves away from a position + let avoidGoal = new baritoneGoals.GoalAvoid(pos, distance, bot); if (bot.modes.isOn('cheat')) { - const move = new pf.Movements(bot); - const path = await bot.pathfinder.getPathTo(move, inverted_goal, 10000); - let last_move = path.path[path.path.length-1]; - if (last_move) { + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + const pathResult = await bot.ashfinder.generatePath(avoidGoal); + if (pathResult && pathResult.path && pathResult.path.length > 0) { + let last_move = pathResult.path[pathResult.path.length-1]; let x = Math.floor(last_move.x); let y = Math.floor(last_move.y); let z = Math.floor(last_move.z); @@ -1421,7 +1777,7 @@ export async function moveAway(bot, distance) { } } - await goToGoal(bot, inverted_goal); + await goToGoal(bot, avoidGoal); let new_pos = bot.entity.position; log(bot, `Moved away from ${pos.floored()} to ${new_pos.floored()}.`); return true; @@ -1435,11 +1791,150 @@ export async function moveAwayFromEntity(bot, entity, distance=16) { * @param {number} distance, the distance to move away. * @returns {Promise} true if the bot moved away, false otherwise. **/ - let goal = new pf.goals.GoalFollow(entity, distance); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(inverted_goal); - return true; + // RC25: Baritone GoalAvoid to move away from entity + let avoidGoal = new baritoneGoals.GoalAvoid(entity.position, distance, bot); + await goToGoal(bot, avoidGoal); +} + + +export async function explore(bot, distance=40) { + /** + * Move to a random position to explore new terrain and find fresh resources. + * Uses multi-hop navigation for distances > 60 blocks to avoid pathfinder timeouts. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} distance, the approximate distance to explore. Defaults to 40. + * @returns {Promise} true if exploration succeeded, false otherwise. + * @example + * await skills.explore(bot, 200); + **/ + const startPos = bot.entity.position.clone(); + let angle = Math.random() * 2 * Math.PI; // RC17b: let (mutable) — water avoidance changes direction + const HOP_SIZE = 50; // max distance per pathfinding hop + const numHops = Math.max(1, Math.ceil(distance / HOP_SIZE)); + + // Pause unstuck mode during multi-hop — pathfinding between hops can trigger false stuck detection + if (numHops > 1) bot.modes.pause('unstuck'); + + log(bot, `Exploring ${distance} blocks (${numHops} hops)...`); + + let totalMoved = 0; + let consecutiveFails = 0; + + for (let hop = 0; hop < numHops; hop++) { + if (bot.interrupt_code) break; + + const currentPos = bot.entity.position; + const hopDist = Math.min(HOP_SIZE, distance - totalMoved); + + // Add slight random angle variation per hop to avoid obstacles + const hopAngle = angle + (Math.random() - 0.5) * 0.4; + const tx = Math.floor(currentPos.x + hopDist * Math.cos(hopAngle)); + const tz = Math.floor(currentPos.z + hopDist * Math.sin(hopAngle)); + + try { + await goToPosition(bot, tx, currentPos.y, tz, 3); + const moved = currentPos.distanceTo(bot.entity.position); + totalMoved += moved; + consecutiveFails = 0; + + // RC17b: Water/ocean detection — if we dropped below sea level (y<=62) + // or are standing on water, the path is heading toward ocean. Change direction. + // Note: y=63 is sea level and many valid forest areas exist there, so only trigger at y<=62. + const postHopY = bot.entity.position.y; + const feetBlock = bot.blockAt(bot.entity.position.offset(0, -0.5, 0)); + const isNearWater = (feetBlock && (feetBlock.name === 'water' || feetBlock.name === 'kelp' || feetBlock.name === 'seagrass')) || postHopY <= 62; + + if (isNearWater && hop < numHops - 1) { + log(bot, `Heading toward water (y=${Math.round(postHopY)}), changing direction...`); + // Reverse + perpendicular to move away from water + angle = angle + Math.PI * 0.75 + (Math.random() - 0.5) * 0.5; + } + + if (moved < 5 && hop > 0) { + // Barely moved — try a perpendicular direction + const perpAngle = angle + (Math.random() > 0.5 ? Math.PI/2 : -Math.PI/2); + const px = Math.floor(bot.entity.position.x + hopDist * Math.cos(perpAngle)); + const pz = Math.floor(bot.entity.position.z + hopDist * Math.sin(perpAngle)); + log(bot, `Path blocked, trying perpendicular direction...`); + try { + await goToPosition(bot, px, bot.entity.position.y, pz, 3); + totalMoved += bot.entity.position.distanceTo(currentPos); + } catch (_e) { /* continue with next hop */ } + } + } catch (_err) { + consecutiveFails++; + if (consecutiveFails >= 2) { + log(bot, `Exploration stuck after ${Math.round(totalMoved)} blocks. Move to a new area far away and try a different direction.`); + break; + } + // Try perpendicular direction on failure + const perpAngle = angle + (Math.random() > 0.5 ? Math.PI/2 : -Math.PI/2); + const px = Math.floor(currentPos.x + hopDist * Math.cos(perpAngle)); + const pz = Math.floor(currentPos.z + hopDist * Math.sin(perpAngle)); + try { + await goToPosition(bot, px, currentPos.y, pz, 3); + totalMoved += currentPos.distanceTo(bot.entity.position); + consecutiveFails = 0; + } catch (_e2) { /* will try next hop */ } + } + } + + let finalPos = bot.entity.position; + let directDistance = startPos.distanceTo(finalPos); + + // RC17: Smart explore — check if we landed near resources (any log type). + // If not, auto-retry in different directions before returning to the LLM. + const LOG_TYPES = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + const MAX_DIRECTION_RETRIES = 2; + + for (let retry = 0; retry < MAX_DIRECTION_RETRIES; retry++) { + if (bot.interrupt_code) break; + + // Check for any log blocks within 128 blocks + let hasLogs = false; + for (const logType of LOG_TYPES) { + const logBlock = world.getNearestBlock(bot, logType, 128); + if (logBlock) { + hasLogs = true; + break; + } + } + + if (hasLogs) break; // Found logs nearby, good landing spot + + // Also check if we're in water (bad landing) + const feetBlock = bot.blockAt(bot.entity.position.offset(0, -1, 0)); + const isInWater = feetBlock && (feetBlock.name === 'water' || feetBlock.name === 'ice' || feetBlock.name === 'blue_ice'); + + if (!hasLogs) { + log(bot, `No trees in this area${isInWater ? ' (landed in water)' : ''}. Trying a different direction (attempt ${retry + 1}/${MAX_DIRECTION_RETRIES})...`); + + // Pick a direction roughly perpendicular to our original angle + const retryAngle = angle + Math.PI / 2 + (retry * Math.PI / 3) + (Math.random() - 0.5) * 0.5; + const retryDist = Math.min(100, distance); + const retryHops = Math.max(1, Math.ceil(retryDist / HOP_SIZE)); + + for (let hop = 0; hop < retryHops; hop++) { + if (bot.interrupt_code) break; + const cp = bot.entity.position; + const hd = Math.min(HOP_SIZE, retryDist - (hop * HOP_SIZE)); + const tx = Math.floor(cp.x + hd * Math.cos(retryAngle)); + const tz = Math.floor(cp.z + hd * Math.sin(retryAngle)); + try { + await goToPosition(bot, tx, cp.y, tz, 3); + } catch (_e) { break; } + } + + finalPos = bot.entity.position; + directDistance = startPos.distanceTo(finalPos); + } + } + + // Resume unstuck mode + if (numHops > 1) bot.modes.unpause('unstuck'); + + log(bot, `Explored ${Math.round(directDistance)} blocks to (${Math.floor(finalPos.x)}, ${Math.floor(finalPos.y)}, ${Math.floor(finalPos.z)}). New chunks loaded — try gathering here.`); + return directDistance > 10; } export async function avoidEnemies(bot, distance=16) { @@ -1454,10 +1949,12 @@ export async function avoidEnemies(bot, distance=16) { bot.modes.pause('self_preservation'); // prevents damage-on-low-health from interrupting the bot let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), distance); while (enemy) { - const follow = new pf.goals.GoalFollow(enemy, distance+1); // move a little further away - const inverted_goal = new pf.goals.GoalInvert(follow); - bot.pathfinder.setMovements(new pf.Movements(bot)); - bot.pathfinder.setGoal(inverted_goal, true); + // RC25: Baritone GoalAvoid to flee from enemy + const avoidGoal = new baritoneGoals.GoalAvoid(enemy.position, distance + 1, bot); + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + try { + await bot.ashfinder.gotoSmart(avoidGoal); + } catch (_e) { /* best-effort flee */ } await new Promise(resolve => setTimeout(resolve, 500)); enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), distance); if (bot.interrupt_code) { @@ -1467,7 +1964,7 @@ export async function avoidEnemies(bot, distance=16) { await attackEntity(bot, enemy, false); } } - bot.pathfinder.stop(); + bot.ashfinder.stop(); log(bot, `Moved ${distance} away from enemies.`); return true; } @@ -1520,24 +2017,43 @@ export async function useDoor(bot, door_pos=null) { return false; } - bot.pathfinder.setGoal(new pf.goals.GoalNear(door_pos.x, door_pos.y, door_pos.z, 1)); - await new Promise((resolve) => setTimeout(resolve, 1000)); - while (bot.pathfinder.isMoving()) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - let door_block = bot.blockAt(door_pos); - await bot.lookAt(door_pos); - if (!door_block._properties.open) - await bot.activateBlock(door_block); - - bot.setControlState("forward", true); - await new Promise((resolve) => setTimeout(resolve, 600)); - bot.setControlState("forward", false); - await bot.activateBlock(door_block); + try { + // RC25: Baritone gotoSmart replaces pathfinder.setGoal + isMoving poll + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(door_pos, 1)); + + let door_block = bot.blockAt(door_pos); + if (!door_block) { + log(bot, `Door block disappeared at ${door_pos}.`); + return false; + } + + await bot.lookAt(door_pos.offset(0.5, 0.5, 0.5)); + + // Toggle door state if it exists + if (door_block._properties && !door_block._properties.open) + await bot.activateBlock(door_block); + else if (!door_block._properties) + await bot.activateBlock(door_block); + + // Wait and move through + await new Promise((resolve) => setTimeout(resolve, 300)); + bot.setControlState("forward", true); + await new Promise((resolve) => setTimeout(resolve, 800)); + bot.setControlState("forward", false); + + // Close door if it's still open + door_block = bot.blockAt(door_pos); + if (door_block && door_block._properties && door_block._properties.open) { + await bot.activateBlock(door_block); + } - log(bot, `Used door at ${door_pos}.`); - return true; + log(bot, `Used door at ${door_pos}.`); + return true; + } catch (err) { + log(bot, `Error using door: ${err.message}.`); + return false; + } } export async function goToBed(bot) { @@ -1562,7 +2078,40 @@ export async function goToBed(bot) { let loc = beds[0]; await goToPosition(bot, loc.x, loc.y, loc.z); const bed = bot.blockAt(loc); - await bot.sleep(bed); + if (!bed) { + log(bot, `Could not find a bed block at location.`); + return false; + } + let slept = false; + try { + await bot.sleep(bed); + slept = true; + } catch (err) { + // Sometimes the located bed is the wrong half. Try adjacent blocks. + const offsets = [ + { x: 1, y: 0, z: 0 }, + { x: -1, y: 0, z: 0 }, + { x: 0, y: 0, z: 1 }, + { x: 0, y: 0, z: -1 } + ]; + for (const offset of offsets) { + const otherPos = bed.position.offset(offset.x, offset.y, offset.z); + const otherBed = bot.blockAt(otherPos); + if (otherBed && otherBed.name === bed.name) { + try { + await bot.sleep(otherBed); + slept = true; + break; + } catch (_e) { + // continue trying other halves + } + } + } + if (!slept) { + log(bot, `Could not sleep in bed: ${err?.message ?? 'unknown error'}`); + return false; + } + } log(bot, `You are in bed.`); bot.modes.pause('unstuck'); while (bot.isSleeping) { @@ -1596,8 +2145,8 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { seedType = seedType.replace(remove, ''); } } - placeBlock(bot, 'farmland', x, y, z); - placeBlock(bot, seedType, x, y+1, z); + await placeBlock(bot, 'farmland', x, y, z); + await placeBlock(bot, seedType, x, y+1, z); return true; } @@ -1620,8 +2169,8 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { // if distance is too far, move to the block if (bot.entity.position.distanceTo(block.position) > 4.5) { let pos = block.position; - bot.pathfinder.setMovements(new pf.Movements(bot)); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Baritone GoalNear + await goToGoal(bot, new baritoneGoals.GoalNear(pos, 4)); } if (block.name !== 'farmland') { let hoe = bot.inventory.items().find(item => item.name.includes('hoe')); @@ -1665,8 +2214,8 @@ export async function activateNearestBlock(bot, type) { } if (bot.entity.position.distanceTo(block.position) > 4.5) { let pos = block.position; - bot.pathfinder.setMovements(new pf.Movements(bot)); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Baritone GoalNear + await goToGoal(bot, new baritoneGoals.GoalNear(pos, 4)); } await bot.activateBlock(block); log(bot, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`); @@ -1720,7 +2269,8 @@ async function findAndGoToVillager(bot, id) { log(bot, `Villager is ${distance.toFixed(1)} blocks away, moving closer...`); try { bot.modes.pause('unstuck'); - const goal = new pf.goals.GoalFollow(entity, 2); + // RC25: Baritone GoalNear replaces GoalFollow for villager approach + const goal = new baritoneGoals.GoalNear(entity.position, 2); await goToGoal(bot, goal); @@ -1926,7 +2476,7 @@ export async function digDown(bot, distance = 10) { // Check for lava, water if (targetBlock.name === 'lava' || targetBlock.name === 'water' || belowBlock.name === 'lava' || belowBlock.name === 'water') { - log(bot, `Dug down ${i-1} blocks, but reached ${belowBlock ? belowBlock.name : '(lava/water)'}`) + log(bot, `Dug down ${i-1} blocks, but reached ${belowBlock ? belowBlock.name : '(lava/water)'}`); return false; } @@ -1973,7 +2523,7 @@ export async function goToSurface(bot) { continue; } await goToPosition(bot, block.position.x, block.position.y + 1, block.position.z, 0); // this will probably work most of the time but a custom mining and towering up implementation could be added if needed - log(bot, `Going to the surface at y=${y+1}.`);`` + log(bot, `Going to the surface at y=${y+1}.`); return true; } return false; @@ -2061,7 +2611,7 @@ export async function useToolOn(bot, toolName, targetName) { return blockInView && !blockInView.position.equals(block.position) && blockInView.position.distanceTo(headPos) < block.position.distanceTo(headPos); - } + }; const blockInView = bot.blockAtCursor(5); if (viewBlocked()) { log(bot, `Block ${blockInView.name} is in the way, moving closer...`); @@ -2090,4 +2640,653 @@ export async function useToolOn(bot, toolName, targetName) { } log(bot, `Used ${toolName} on ${block.name}.`); return true; - } +} + +export async function enterMinecart(bot, minecart_pos=null) { + /** + * Enter a minecart at the given position. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {Vec3} minecart_pos, the position of the minecart. If null, the nearest minecart will be used. + * @returns {Promise} true if the minecart was entered, false otherwise. + * @example + * await skills.enterMinecart(bot); + **/ + try { + if (!minecart_pos) { + const minecarts = bot.entities + .filter(e => e.type === 'minecart' || e.type === 'object') + .sort((a, b) => a.position.distanceTo(bot.entity.position) - b.position.distanceTo(bot.entity.position)); + + if (minecarts.length === 0) { + log(bot, `No minecart found nearby.`); + return false; + } + minecart_pos = minecarts[0].position; + } + + // Go to the minecart + // RC25: Baritone gotoSmart replaces pathfinder.setGoal + isMoving poll + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(minecart_pos, 1)); + + // Right-click to enter + await bot.lookAt(minecart_pos.offset(0.5, 0.5, 0.5)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Use interaction (activate the minecart) + const minecartEntity = bot.nearestEntity(entity => + (entity.type === 'minecart' || entity.type === 'object') && + entity.position.distanceTo(minecart_pos) < 2 + ); + + if (minecartEntity) { + await bot.activateEntity(minecartEntity); + await new Promise((resolve) => setTimeout(resolve, 500)); + log(bot, `Entered minecart at ${minecart_pos}.`); + return true; + } + + log(bot, `Could not find minecart entity to enter.`); + return false; + } catch (err) { + log(bot, `Error entering minecart: ${err.message}`); + return false; + } +} + +export async function exitMinecart(bot) { + /** + * Exit the current minecart. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if exited minecart, false otherwise. + * @example + * await skills.exitMinecart(bot); + **/ + try { + if (!bot.vehicle) { + log(bot, `Not currently in a minecart.`); + return false; + } + + // Jump to exit + bot.setControlState("jump", true); + await new Promise((resolve) => setTimeout(resolve, 200)); + bot.setControlState("jump", false); + + await new Promise((resolve) => setTimeout(resolve, 300)); + log(bot, `Exited minecart.`); + return true; + } catch (err) { + log(bot, `Error exiting minecart: ${err.message}`); + return false; + } +} + +export async function getDiamondPickaxe(bot) { + /** + * Automatically obtain a diamond pickaxe by progressing through tool tiers. + * Detects any existing pickaxe and starts from the appropriate tier, + * so calling it twice is safe (idempotent). + * Tiers: wooden → stone → iron → diamond. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if diamond pickaxe obtained, false otherwise. + * @example + * await skills.getDiamondPickaxe(bot); + **/ + let inv; + + // Already done + inv = world.getInventoryCounts(bot); + if (inv['diamond_pickaxe'] > 0) { + log(bot, 'Already have a diamond pickaxe!'); + return true; + } + + // ── TIER 1: wooden pickaxe ─────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['wooden_pickaxe'] && !inv['stone_pickaxe'] && !inv['iron_pickaxe']) { + log(bot, 'Starting tool progression: collecting logs...'); + const logTypes = ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', + 'acacia_log', 'jungle_log', 'mangrove_log']; + // Use logs already in inventory, or collect some + let logType = logTypes.find(l => (inv[l] ?? 0) >= 3); + if (!logType) { + for (const lt of logTypes) { + if (await collectBlock(bot, lt, 3)) { logType = lt; break; } + } + } + if (!logType) { + log(bot, 'Cannot find any logs to begin tool progression.'); + return false; + } + const plankType = logType.replace('_log', '_planks'); + if (!await craftRecipe(bot, plankType, 1)) { + log(bot, `Failed to craft ${plankType}.`); + return false; + } + if (!await craftRecipe(bot, 'stick', 1)) { + log(bot, 'Failed to craft sticks.'); + return false; + } + if (!await craftRecipe(bot, 'wooden_pickaxe', 1)) { + log(bot, 'Failed to craft wooden pickaxe.'); + return false; + } + log(bot, 'Wooden pickaxe crafted.'); + } + + // ── TIER 2: stone pickaxe ──────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['stone_pickaxe'] && !inv['iron_pickaxe']) { + log(bot, 'Collecting cobblestone for stone pickaxe...'); + if (!await collectBlock(bot, 'stone', 3)) { + log(bot, 'Could not find stone. Try moving to a rocky area.'); + return false; + } + if (!await craftRecipe(bot, 'stone_pickaxe', 1)) { + log(bot, 'Failed to craft stone pickaxe.'); + return false; + } + log(bot, 'Stone pickaxe crafted.'); + } + + // ── TIER 3: iron pickaxe ───────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['iron_pickaxe']) { + log(bot, 'Collecting iron ore for iron pickaxe...'); + let gotIron = await collectBlock(bot, 'iron_ore', 3); + if (!gotIron) gotIron = await collectBlock(bot, 'deepslate_iron_ore', 3); + if (!gotIron) { + log(bot, 'Could not find iron ore. Try digging deeper or exploring caves.'); + return false; + } + if (!await smeltItem(bot, 'raw_iron', 3)) { + log(bot, 'Failed to smelt raw iron into iron ingots.'); + return false; + } + if (!await craftRecipe(bot, 'iron_pickaxe', 1)) { + log(bot, 'Failed to craft iron pickaxe.'); + return false; + } + log(bot, 'Iron pickaxe crafted.'); + } + + // ── TIER 4: diamond pickaxe ────────────────────────────────────────────── + log(bot, 'Digging to diamond level (y=-11)...'); + const targetY = -11; + const currentY = Math.floor(bot.entity.position.y); + if (currentY > targetY) { + const dist = currentY - targetY; + if (!await digDown(bot, dist)) { + log(bot, 'Stopped before reaching diamond level (lava or cave gap detected). Try again from a different spot.'); + return false; + } + } + + log(bot, 'Searching for diamond ore...'); + let gotDiamonds = await collectBlock(bot, 'deepslate_diamond_ore', 3); + if (!gotDiamonds) gotDiamonds = await collectBlock(bot, 'diamond_ore', 3); + if (!gotDiamonds) { + log(bot, 'No diamond ore found near y=-11. Explore at this depth and try again with !getDiamondPickaxe.'); + return false; + } + + if (!await craftRecipe(bot, 'diamond_pickaxe', 1)) { + log(bot, 'Failed to craft diamond pickaxe. Need 3 diamonds + 2 sticks on a crafting table.'); + return false; + } + + await goToSurface(bot); + log(bot, 'Diamond pickaxe obtained!'); + return true; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GENERAL IMPROVEMENTS — Safe Movement, Combat, Inventory, Food +// ═══════════════════════════════════════════════════════════════════════════ + +export async function safeMoveTo(bot, x, y, z, options = {}) { + /** + * Navigate to a position with safety checks: avoids lava, deep water, + * and large falls. Places torches underground every ~10 blocks. + * If the bot is falling >2 blocks, attempts water bucket clutch. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} x, the x coordinate to navigate to. + * @param {number} y, the y coordinate to navigate to. + * @param {number} z, the z coordinate to navigate to. + * @param {boolean} options.avoidLava, avoid lava paths (default true). + * @param {boolean} options.lightPath, place torches underground (default true). + * @param {number} options.timeout, navigation timeout in seconds (default 60). + * @returns {Promise} true if destination reached. + * @example + * await skills.safeMoveTo(bot, 100, 64, 200); + * await skills.safeMoveTo(bot, 100, 64, 200, {avoidLava: true, lightPath: true}); + **/ + const avoidLava = options.avoidLava !== false; + const lightPath = options.lightPath !== false; + + // Pre-flight: check destination is not in lava/void + if (avoidLava && y < -64) { + log(bot, 'Destination is below the void. Aborting.'); + return false; + } + + const startPos = bot.entity.position.clone(); + let lastTorchPos = startPos.clone(); + let torchInterval = null; + + // Auto-torch placer for underground travel + if (lightPath) { + torchInterval = setInterval(async () => { + try { + const pos = bot.entity.position; + if (pos.y < 50 && pos.distanceTo(lastTorchPos) >= 10) { + const inv = world.getInventoryCounts(bot); + if ((inv['torch'] || 0) > 0 && world.shouldPlaceTorch(bot)) { + await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); + lastTorchPos = pos.clone(); + } + } + } catch (_e) { /* non-critical */ } + }, 3000); + } + + // Fall detection: water bucket clutch + let fallWatcher = null; + const startFallWatch = () => { + let lastY = bot.entity.position.y; + let fallStart = -1; + fallWatcher = setInterval(async () => { + const curY = bot.entity.position.y; + const vel = bot.entity.velocity; + if (vel && vel.y < -0.5) { + if (fallStart < 0) fallStart = lastY; + const fallDist = fallStart - curY; + if (fallDist > 4) { + // Attempt water bucket clutch + const waterBucket = bot.inventory.items().find(i => i.name === 'water_bucket'); + if (waterBucket) { + try { + await bot.equip(waterBucket, 'hand'); + const below = bot.blockAt(bot.entity.position.offset(0, -1, 0)); + if (below) await bot.placeBlock(below, new Vec3(0, 1, 0)); + } catch (_e) { /* best effort */ } + } + } + } else { + fallStart = -1; + } + lastY = curY; + }, 200); + }; + startFallWatch(); + + let success = false; + try { + success = await goToPosition(bot, x, y, z, 2); + } catch (err) { + log(bot, `safeMoveTo failed: ${err.message}`); + success = false; + } finally { + if (torchInterval) clearInterval(torchInterval); + if (fallWatcher) clearInterval(fallWatcher); + } + + if (!success) { + log(bot, `Could not safely reach (${x}, ${y}, ${z}).`); + } + return success; +} + +export async function rangedAttack(bot, entityType, preferredWeapon = 'bow') { + /** + * Attack the nearest entity of a given type using a bow if available, + * falling back to melee. Predicts target position for better aim. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} entityType, the type of entity to attack (e.g. 'blaze', 'skeleton'). + * @param {string} preferredWeapon, preferred ranged weapon: 'bow' or 'crossbow'. Default 'bow'. + * @returns {Promise} true if entity killed or driven off. + * @example + * await skills.rangedAttack(bot, "blaze"); + * await skills.rangedAttack(bot, "skeleton", "bow"); + **/ + const entity = world.getNearestEntityWhere(bot, e => e.name === entityType, 48); + if (!entity) { + log(bot, `No ${entityType} found nearby.`); + return false; + } + + const inv = world.getInventoryCounts(bot); + const hasBow = (inv['bow'] || 0) > 0; + const hasCrossbow = (inv['crossbow'] || 0) > 0; + const hasArrows = (inv['arrow'] || 0) > 0; + + // If we have ranged weapon + arrows, use ranged attack + if ((hasBow || hasCrossbow) && hasArrows) { + const weaponName = (preferredWeapon === 'crossbow' && hasCrossbow) ? 'crossbow' : (hasBow ? 'bow' : 'crossbow'); + const weapon = bot.inventory.items().find(i => i.name === weaponName); + if (weapon) await bot.equip(weapon, 'hand'); + + log(bot, `Attacking ${entityType} with ${weaponName}...`); + let attempts = 0; + const maxAttempts = 20; + while (entity.isValid && attempts < maxAttempts) { + if (bot.interrupt_code) return false; + attempts++; + + const dist = bot.entity.position.distanceTo(entity.position); + if (dist > 40) { + // Too far, close distance + await goToPosition(bot, entity.position.x, entity.position.y, entity.position.z, 20); + continue; + } + if (dist < 6) { + // Too close for bow, use melee fallback + await attackEntity(bot, entity, true); + return true; + } + + // Predict target position (lead the shot) + const vel = entity.velocity || new Vec3(0, 0, 0); + const flightTime = dist / 30; // approximate arrow speed + const predictedPos = entity.position.offset( + vel.x * flightTime, + vel.y * flightTime + entity.height * 0.7, + vel.z * flightTime + ); + + await bot.lookAt(predictedPos); + + // Activate bow (hold right click) + bot.activateItem(); + await new Promise(r => setTimeout(r, 1200)); // charge bow + bot.deactivateItem(); + await new Promise(r => setTimeout(r, 500)); + + // Check if entity is dead + if (!entity.isValid) { + log(bot, `${entityType} defeated with ${weaponName}!`); + return true; + } + } + + if (!entity.isValid) { + log(bot, `${entityType} defeated!`); + return true; + } + } + + // Fallback: melee attack + log(bot, `No ranged weapon available, using melee against ${entityType}.`); + return await attackNearest(bot, entityType, true); +} + +export async function buildPanicRoom(bot) { + /** + * Emergency shelter: builds a 3x3x3 hollow cobblestone box around the bot. + * Used when health is critically low. Eats available food inside. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if shelter built. + * @example + * await skills.buildPanicRoom(bot); + **/ + const inv = world.getInventoryCounts(bot); + const cobble = (inv['cobblestone'] || 0) + (inv['stone'] || 0) + (inv['deepslate'] || 0); + const material = cobble >= 20 ? + (inv['cobblestone'] >= 20 ? 'cobblestone' : (inv['stone'] >= 20 ? 'stone' : 'deepslate')) : + null; + + if (!material || cobble < 20) { + log(bot, 'Not enough blocks to build panic room (need 20+ cobblestone/stone).'); + // Just eat and heal in place + await ensureFed(bot); + return false; + } + + log(bot, 'Building emergency shelter!'); + const pos = bot.entity.position; + const bx = Math.floor(pos.x); + const by = Math.floor(pos.y); + const bz = Math.floor(pos.z); + + // Build floor, walls, and ceiling (3x3x3 hollow box) + const offsets = []; + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + offsets.push([dx, -1, dz]); // floor + offsets.push([dx, 2, dz]); // ceiling + } + } + // walls + for (let dy = 0; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === -1 || dx === 1) { + offsets.push([dx, dy, -1]); + offsets.push([dx, dy, 0]); + offsets.push([dx, dy, 1]); + } + } + offsets.push([0, dy, -1]); + offsets.push([0, dy, 1]); + } + + let placed = 0; + for (const [dx, dy, dz] of offsets) { + if (bot.interrupt_code) return false; + const block = bot.blockAt(new Vec3(bx + dx, by + dy, bz + dz)); + if (block && block.name === 'air') { + try { + await placeBlock(bot, material, bx + dx, by + dy, bz + dz, 'bottom', true); + placed++; + } catch (_e) { /* best effort */ } + } + } + + log(bot, `Panic room built with ${placed} blocks. Eating food...`); + await ensureFed(bot); + + // Wait until health recovers + let waitTime = 0; + while (bot.health < 18 && waitTime < 30000) { + if (bot.interrupt_code) return true; + await new Promise(r => setTimeout(r, 2000)); + waitTime += 2000; + if (bot.food < 18) await ensureFed(bot); + } + + log(bot, `Health recovered to ${bot.health}. Breaking out...`); + // Break front wall to exit + try { + await breakBlockAt(bot, bx, by, bz - 1); + await breakBlockAt(bot, bx, by + 1, bz - 1); + } catch (_e) { /* exit best effort */ } + + return true; +} + +export async function ensureFed(bot) { + /** + * Eat the best available food item if hunger is below 18. + * Prioritizes cooked food, then raw food. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if food was consumed. + * @example + * await skills.ensureFed(bot); + **/ + if (bot.food >= 18) return true; + + // Food priority list (best to worst) + const foodPriority = [ + 'golden_apple', 'enchanted_golden_apple', + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', + 'cooked_salmon', 'cooked_cod', 'cooked_chicken', 'cooked_rabbit', + 'bread', 'baked_potato', 'beetroot_soup', 'mushroom_stew', + 'pumpkin_pie', 'cookie', 'melon_slice', 'sweet_berries', + 'apple', 'carrot', 'potato', + 'beef', 'porkchop', 'mutton', 'chicken', 'rabbit', 'cod', 'salmon', + 'dried_kelp', 'beetroot', 'rotten_flesh' + ]; + + const inv = world.getInventoryCounts(bot); + for (const food of foodPriority) { + if ((inv[food] || 0) > 0) { + log(bot, `Eating ${food}...`); + return await consume(bot, food); + } + } + + log(bot, 'No food available!'); + return false; +} + +export async function autoManageInventory(bot) { + /** + * Clean up inventory: drop junk items, keep important items, + * ensure at least 8 empty slots. Stores excess in a nearby chest if available. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if inventory was managed. + * @example + * await skills.autoManageInventory(bot); + **/ + const junkItems = [ + 'dirt', 'gravel', 'sand', 'andesite', 'diorite', 'granite', + 'cobbled_deepslate', 'tuff', 'netherrack', 'cobblestone', + 'rotten_flesh', 'poisonous_potato', 'spider_eye', + 'pufferfish', 'tropical_fish' + ]; + + // Keep threshold: always keep some cobblestone for crafting + const keepAmounts = { + 'cobblestone': 64, + 'dirt': 0, + 'gravel': 0, + 'sand': 0, + 'andesite': 0, + 'diorite': 0, + 'granite': 0, + 'cobbled_deepslate': 0, + 'tuff': 0, + 'netherrack': 0, + 'rotten_flesh': 0, + 'poisonous_potato': 0, + 'spider_eye': 0, + 'pufferfish': 0, + 'tropical_fish': 0 + }; + + const inv = world.getInventoryCounts(bot); + let emptySlots = bot.inventory.emptySlotCount(); + let discarded = 0; + + if (emptySlots >= 8) { + log(bot, `Inventory is fine (${emptySlots} empty slots).`); + return true; + } + + // Try to store in nearby chest first + const chest = world.getNearestBlock(bot, 'chest', 32); + if (chest) { + log(bot, 'Storing excess items in nearby chest...'); + for (const item of junkItems) { + if (bot.interrupt_code) return false; + const count = inv[item] || 0; + const keep = keepAmounts[item] || 0; + const toStore = count - keep; + if (toStore > 0) { + await putInChest(bot, item, toStore); + discarded += toStore; + } + } + } else { + // No chest — discard junk + log(bot, 'No chest nearby. Discarding junk items...'); + for (const item of junkItems) { + if (bot.interrupt_code) return false; + const count = inv[item] || 0; + const keep = keepAmounts[item] || 0; + const toDrop = count - keep; + if (toDrop > 0) { + await discard(bot, item, toDrop); + discarded += toDrop; + } + emptySlots = bot.inventory.emptySlotCount(); + if (emptySlots >= 8) break; + } + } + + emptySlots = bot.inventory.emptySlotCount(); + log(bot, `Inventory managed. Discarded/stored ${discarded} items. ${emptySlots} slots free.`); + return emptySlots >= 8; +} + +export async function stockpileFood(bot, quantity = 32) { + /** + * Gather food by hunting passive animals nearby. Cooks meat in a furnace + * if fuel and furnace materials are available. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} quantity, target number of food items. Default 32. + * @returns {Promise} true if enough food collected. + * @example + * await skills.stockpileFood(bot, 32); + **/ + const meatAnimals = ['cow', 'pig', 'sheep', 'chicken', 'rabbit']; + + // Count current food + const foodItems = [ + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', 'cooked_rabbit', + 'bread', 'apple', 'carrot', 'baked_potato', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'rabbit' + ]; + + let inv = world.getInventoryCounts(bot); + let totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + + log(bot, `Current food supply: ${totalFood}/${quantity}`); + + // Hunt animals until we have enough + let huntAttempts = 0; + while (totalFood < quantity && huntAttempts < 20) { + if (bot.interrupt_code) return false; + huntAttempts++; + + let hunted = false; + for (const animal of meatAnimals) { + const entity = world.getNearestEntityWhere(bot, e => e.name === animal, 32); + if (entity) { + await attackEntity(bot, entity, true); + await pickupNearbyItems(bot); + hunted = true; + break; + } + } + + if (!hunted) { + log(bot, 'No animals nearby. Exploring to find more...'); + await explore(bot, 60); + } + + inv = world.getInventoryCounts(bot); + totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + } + + // Cook raw meat if we have a furnace and fuel + const rawMeats = ['beef', 'porkchop', 'mutton', 'chicken', 'rabbit']; + inv = world.getInventoryCounts(bot); + for (const meat of rawMeats) { + if (bot.interrupt_code) return false; + const rawCount = inv[meat] || 0; + if (rawCount > 0) { + const cooked = await smeltItem(bot, meat, rawCount); + if (cooked) log(bot, `Cooked ${rawCount} ${meat}.`); + } + } + + inv = world.getInventoryCounts(bot); + totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + log(bot, `Food stockpile complete: ${totalFood} food items.`); + return totalFood >= quantity; +} + diff --git a/src/agent/library/world.js b/src/agent/library/world.js index d993a0931..02816a1a3 100644 --- a/src/agent/library/world.js +++ b/src/agent/library/world.js @@ -1,4 +1,5 @@ -import pf from 'mineflayer-pathfinder'; +import baritoneModule from '@miner-org/mineflayer-baritone'; +const baritoneGoals = baritoneModule.goals; import * as mc from '../../utils/mcdata.js'; @@ -97,7 +98,7 @@ export function getFirstBlockAboveHead(bot, ignore_types=null, distance=32) { } // The block above, stops when it finds a solid block . let block_above = {name: 'air'}; - let height = 0 + let height = 0; for (let i = 0; i < distance; i++) { let block = bot.blockAt(bot.entity.position.offset(0, i+2, 0)); if (!block) block = {name: 'air'}; @@ -401,17 +402,29 @@ export function getNearbyBlockTypes(bot, distance=16) { export async function isClearPath(bot, target) { /** * Check if there is a path to the target that requires no digging or placing blocks. + * RC25: Uses Baritone generatePath with restrictive config. + * RC30: Guard against missing ashfinder (e.g. during respawn). * @param {Bot} bot - The bot to get the path for. * @param {Entity} target - The target to path to. * @returns {boolean} - True if there is a clear path, false otherwise. */ - let movements = new pf.Movements(bot) - movements.canDig = false; - movements.canPlaceOn = false; - movements.canOpenDoors = false; - let goal = new pf.goals.GoalNear(target.position.x, target.position.y, target.position.z, 1); - let path = await bot.pathfinder.getPathTo(movements, goal, 100); - return path.status === 'success'; + // RC30: Guard — ashfinder may not be initialized yet (respawn, early tick) + if (!bot.ashfinder?.config) { + return false; + } + // Temporarily disable break/place to test clear-path feasibility + const prevBreak = bot.ashfinder.config.breakBlocks; + const prevPlace = bot.ashfinder.config.placeBlocks; + bot.ashfinder.config.breakBlocks = false; + bot.ashfinder.config.placeBlocks = false; + try { + let goal = new baritoneGoals.GoalNear(target.position, 1); + let pathResult = await bot.ashfinder.generatePath(goal); + return pathResult && pathResult.status === 'success'; + } finally { + bot.ashfinder.config.breakBlocks = prevBreak; + bot.ashfinder.config.placeBlocks = prevPlace; + } } export function shouldPlaceTorch(bot) { diff --git a/tasks/dragon/blaze_rods.json b/tasks/dragon/blaze_rods.json new file mode 100644 index 000000000..7a149d5d8 --- /dev/null +++ b/tasks/dragon/blaze_rods.json @@ -0,0 +1,15 @@ +{ + "_comment": "Chunk 3: Collect blaze rods from a Nether Fortress.", + "blaze_rods": { + "goal": "Collect 12 blaze rods in the Nether. Use !collectBlazeRods(12). Requires: nether portal, sword, food, and ideally a bow with arrows.", + "initial_inventory": { "0": { "diamond_pickaxe": 1, "iron_sword": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "target": "blaze_rod", + "number_of_target": 12, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/diamond_pickaxe.json b/tasks/dragon/diamond_pickaxe.json new file mode 100644 index 000000000..c42d3f883 --- /dev/null +++ b/tasks/dragon/diamond_pickaxe.json @@ -0,0 +1,17 @@ +{ + "_comment": "Chunk 1: Get a diamond pickaxe from scratch.", + "diamond_pickaxe": { + "goal": "Obtain a diamond pickaxe. Use !getDiamondPickaxe for automated progression, or manually: collect logs → craft wooden pickaxe → mine stone → craft stone pickaxe → mine iron → smelt → craft iron pickaxe → dig to Y=-11 → mine diamonds → craft diamond pickaxe.", + "initial_inventory": {}, + "agent_count": 1, + "target": "diamond_pickaxe", + "number_of_target": 1, + "type": "techtree", + "max_depth": 4, + "depth": 0, + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": true + } +} diff --git a/tasks/dragon/ender_dragon.json b/tasks/dragon/ender_dragon.json new file mode 100644 index 000000000..9cf832729 --- /dev/null +++ b/tasks/dragon/ender_dragon.json @@ -0,0 +1,22 @@ +{ + "_comment": "Chunk 6: Enter The End and defeat the Ender Dragon.", + "ender_dragon": { + "goal": "Jump into the end portal and defeat the Ender Dragon. Use !defeatEnderDragon. Strategy: destroy end crystals with bow, then melee dragon during perching phase. Bring: sword, bow+arrows, blocks, food.", + "initial_inventory": { + "0": { + "diamond_sword": 1, + "bow": 1, + "arrow": 64, + "cobblestone": 64, + "cooked_beef": 64, + "diamond_pickaxe": 1 + } + }, + "agent_count": 1, + "type": "techtree", + "timeout": 3600, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/ender_pearls.json b/tasks/dragon/ender_pearls.json new file mode 100644 index 000000000..0fb259e3d --- /dev/null +++ b/tasks/dragon/ender_pearls.json @@ -0,0 +1,15 @@ +{ + "_comment": "Chunk 4: Collect ender pearls from Endermen.", + "ender_pearls": { + "goal": "Collect 12 ender pearls by hunting Endermen. Use !collectEnderPearls(12). Works best at night or in the Nether warped forest.", + "initial_inventory": { "0": { "diamond_sword": 1, "iron_armor_set": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "target": "ender_pearl", + "number_of_target": 12, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/full_run.json b/tasks/dragon/full_run.json new file mode 100644 index 000000000..60d609266 --- /dev/null +++ b/tasks/dragon/full_run.json @@ -0,0 +1,13 @@ +{ + "_comment": "Full dragon progression: fresh world to Ender Dragon defeat. Single agent.", + "dragon_full_run": { + "goal": "Defeat the Ender Dragon. Use !dragonProgression to run the full automated sequence, or manually chain: !getDiamondPickaxe → !buildNetherPortal → !collectBlazeRods(12) → !collectEnderPearls(12) → !locateStronghold → !defeatEnderDragon.", + "initial_inventory": {}, + "agent_count": 1, + "type": "techtree", + "timeout": 7200, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/nether_portal.json b/tasks/dragon/nether_portal.json new file mode 100644 index 000000000..66a4d64cb --- /dev/null +++ b/tasks/dragon/nether_portal.json @@ -0,0 +1,13 @@ +{ + "_comment": "Chunk 2: Build and light a nether portal.", + "nether_portal": { + "goal": "Build and activate a nether portal. Use !buildNetherPortal for automated construction. Requires diamond pickaxe for mining obsidian. Will use water+lava casting method or direct obsidian mining.", + "initial_inventory": { "0": { "diamond_pickaxe": 1, "iron_ingot": 5 } }, + "agent_count": 1, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": true + } +} diff --git a/tasks/dragon/stronghold.json b/tasks/dragon/stronghold.json new file mode 100644 index 000000000..2c8d64427 --- /dev/null +++ b/tasks/dragon/stronghold.json @@ -0,0 +1,13 @@ +{ + "_comment": "Chunk 5: Locate the stronghold and activate the end portal.", + "stronghold": { + "goal": "Find the stronghold using eyes of ender and activate the end portal. Use !locateStronghold. Requires: 12+ eyes of ender (crafted from blaze powder + ender pearls).", + "initial_inventory": { "0": { "ender_eye": 12, "diamond_pickaxe": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} From eb50bd7d8e9af5e5f90dbae27e00d5e559b85e84 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 2 Mar 2026 05:59:19 -0500 Subject: [PATCH 3/7] feat: vision system, HUD overlay, and Discord bot Vision system (src/agent/vision/): - Dynamic import() for camera module with graceful fallback if WebGL unavailable - Xvfb + Mesa software rendering in Docker (LIBGL_ALWAYS_SOFTWARE=1) - 2-second delay after Xvfb start for WebGL context initialization - Patched prismarine-viewer: entity bone parent null check, unknown entity suppression - Per-bot vision model support (grok-2-vision-1212 for CloudGrok) HUD overlay (mindserver.js + public/index.html): - Gaming-style web dashboard at :8080 - Per-bot panels: runtime tracker, current goal, action display, scrollable command log - Live bot camera feeds via protocol-aware viewer iframes - Toolbar with bot controls; responsive CSS Discord bot (discord-bot.js): - Direct bot chat via Discord channels - Admin commands: !start, !stop, !restart with group-based control - Auto-fix monitor: watches bot-output events for errors, suggests fixes - Role-based access (DISCORD_ADMIN_IDS), usage tracking (!usage [agent|all]) - Path traversal guard + command injection detection on all user input - MindServer integration: live agent status display Windows launcher (start.ps1): one-command start/stop/detach for all bot profiles --- discord-bot.js | 1258 ++++++++++++++++++++++++ src/agent/vision/browser_viewer.js | 13 +- src/agent/vision/camera.js | 22 +- src/agent/vision/vision_interpreter.js | 32 +- src/mindcraft/mindserver.js | 128 ++- src/mindcraft/public/index.html | 409 +++++++- start.ps1 | 19 + 7 files changed, 1834 insertions(+), 47 deletions(-) create mode 100644 discord-bot.js create mode 100644 start.ps1 diff --git a/discord-bot.js b/discord-bot.js new file mode 100644 index 000000000..593524801 --- /dev/null +++ b/discord-bot.js @@ -0,0 +1,1258 @@ + +import { Client, GatewayIntentBits, Partials } from 'discord.js'; +import { io } from 'socket.io-client'; +import { readFile, writeFile } from 'fs/promises'; +import { readFileSync } from 'fs'; +import { join, dirname, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { validateDiscordMessage } from './src/utils/message_validator.js'; +import { RateLimiter } from './src/utils/rate_limiter.js'; +import { deepSanitize } from './settings.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROFILES_DIR = join(__dirname, 'profiles'); +const ACTIVE_PROFILES = ['cloud-persistent', 'local-research', 'claude-explorer']; + +// ── Admin Authorization ────────────────────────────────────── +// Comma-separated Discord user IDs allowed to run destructive commands. +// If empty, only users with the admin role (default "admin") are allowed. +const ADMIN_USER_IDS = (process.env.DISCORD_ADMIN_IDS || '').split(',').map(s => s.trim()).filter(Boolean); +const DISCORD_ADMIN_ROLE = (process.env.DISCORD_ADMIN_ROLE || 'admin').toLowerCase(); +const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || ''; + +if (ADMIN_USER_IDS.length === 0) { + console.warn('[Auth] DISCORD_ADMIN_IDS is empty — only users with the admin role can run destructive commands.'); +} + +function isAdmin(userId, member) { + // Check explicit user ID list + if (ADMIN_USER_IDS.includes(userId)) return true; + // Check Discord server role (guild messages only) + if (member?.roles?.cache?.some(r => r.name.toLowerCase() === DISCORD_ADMIN_ROLE)) return true; + return false; +} + +// ── Profile Name Validation ────────────────────────────────── +// Only allow alphanumeric, underscore, and hyphen characters to prevent +// path traversal attacks when profile names are used in file paths. +function isValidProfileName(name) { + return typeof name === 'string' && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64; +} + +function safeProfilePath(name) { + if (!isValidProfileName(name)) { + throw new Error(`Invalid profile name: "${name}"`); + } + const filePath = join(PROFILES_DIR, `${name}.json`); + const resolvedDir = resolve(PROFILES_DIR); + const resolvedFile = resolve(filePath); + if (!resolvedFile.startsWith(resolvedDir + sep)) { + throw new Error(`Path traversal detected for profile: "${name}"`); + } + return filePath; +} + +// ── Bot Groups (name → agent names) ───────────────────────── +// Reads agent names from profile JSON files at startup so the map +// stays in sync with whatever profiles are configured. +function loadProfileAgentMap() { + const map = {}; + for (const profileName of ACTIVE_PROFILES) { + try { + const filePath = safeProfilePath(profileName); + const profile = deepSanitize(JSON.parse(readFileSync(filePath, 'utf8'))); + if (profile.name) map[profileName] = profile.name; + } catch { /* profile may not exist yet */ } + } + return map; +} +const PROFILE_AGENT_MAP = loadProfileAgentMap(); +const allAgentNames = Object.values(PROFILE_AGENT_MAP); +const BOT_GROUPS = { + all: allAgentNames.length > 0 ? allAgentNames : ['CloudGrok', 'LocalAndy'], + cloud: allAgentNames.filter(n => PROFILE_AGENT_MAP['cloud-persistent'] === n), + local: allAgentNames.filter(n => PROFILE_AGENT_MAP['local-research'] === n), + research: allAgentNames.length > 0 ? [...allAgentNames] : ['LocalAndy', 'CloudGrok'], +}; +console.log('[Boot] Profile→Agent map:', JSON.stringify(PROFILE_AGENT_MAP)); + +// ── Aliases (shorthand → canonical agent name) ────────────── +// Build aliases dynamically from discovered names, with static fallbacks +const cloudAgent = PROFILE_AGENT_MAP['cloud-persistent'] || 'CloudGrok'; +const localAgent = PROFILE_AGENT_MAP['local-research'] || 'LocalAndy'; +const AGENT_ALIASES = { + 'cloud': cloudAgent, + 'cg': cloudAgent, + 'grok': cloudAgent, + 'local': localAgent, + 'la': localAgent, + 'andy': localAgent, +}; + +/** + * Resolve a user argument to a list of agent names. + * Supports: exact agent names, group names, or comma-separated. + * Returns: { agents: string[], label: string } + */ +function resolveAgents(arg) { + if (!arg) return { agents: [], label: 'none' }; + + // Check for comma-separated list + const parts = arg.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); + const resolved = []; + const labels = []; + + for (const part of parts) { + const lower = part.toLowerCase(); + + // Group match + if (BOT_GROUPS[lower]) { + resolved.push(...BOT_GROUPS[lower]); + labels.push(`group:${lower}`); + continue; + } + + // Alias match + if (AGENT_ALIASES[lower]) { + resolved.push(AGENT_ALIASES[lower]); + labels.push(AGENT_ALIASES[lower]); + continue; + } + + // Exact agent name match (case-insensitive) + const agent = knownAgents.find(a => a.name.toLowerCase() === lower); + if (agent) { + resolved.push(agent.name); + labels.push(agent.name); + continue; + } + + // Partial match (prefix) + const partial = knownAgents.filter(a => a.name.toLowerCase().startsWith(lower)); + if (partial.length > 0) { + resolved.push(...partial.map(a => a.name)); + labels.push(...partial.map(a => a.name)); + continue; + } + + // Unknown — pass through as-is (MindServer may know it) + resolved.push(part); + labels.push(part); + } + + // Deduplicate + const unique = [...new Set(resolved)]; + return { agents: unique, label: labels.join(', ') }; +} + +// ── Config ────────────────────────────────────────────────── +const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN || ''; +const BOT_DM_CHANNEL = process.env.BOT_DM_CHANNEL || ''; +const BACKUP_CHAT_CHANNEL = process.env.BACKUP_CHAT_CHANNEL || ''; +const MINDSERVER_HOST = process.env.MINDSERVER_HOST || 'mindcraft'; +const MINDSERVER_PORT = process.env.MINDSERVER_PORT || 8080; +// Optional secondary MindServer for local PC bots (e.g. Tailscale IP) +// Set LOCAL_MINDSERVER_URL to connect to a second MindServer running locally +// Example: LOCAL_MINDSERVER_URL=http://100.x.x.x:8080 +const LOCAL_MINDSERVER_URL = process.env.LOCAL_MINDSERVER_URL || ''; + +// ── Discord Client ────────────────────────────────────────── +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages + ], + partials: [Partials.Channel, Partials.Message] +}); + +// ── State ─────────────────────────────────────────────────── +let mindServerSocket = null; +let mindServerConnected = false; +let knownAgents = []; // [{name, in_game, socket_connected, viewerPort}] +let agentStates = {}; // {agentName: {gameplay, action, inventory, nearby, ...}} +let replyChannel = null; // cached Discord channel for fast replies + +// ── Local MindServer State ────────────────────────────────── +let localMindServerSocket = null; +let localMindServerConnected = false; +let localKnownAgents = []; // agents from local PC MindServer +let localAgentStates = {}; // states from local PC MindServer +const messageLimiter = new RateLimiter(3, 60000); // 3 messages per 60 seconds per user + +// ── Gemini Helper (shared by auto-fix and direct chat) ──────── +async function callGemini(systemPrompt, userMessage, history = []) { + if (!GOOGLE_API_KEY) return null; + try { + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GOOGLE_API_KEY}`; + const contents = [ + ...history, + { role: 'user', parts: [{ text: userMessage }] } + ]; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents, + generationConfig: { maxOutputTokens: 600, temperature: 0.7 } + }) + }); + if (!res.ok) { console.error(`[Gemini] API error ${res.status}`); return null; } + const data = await res.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null; + } catch (err) { + console.error('[Gemini] Error:', err.message); + return null; + } +} + +// ── Chat Buffer (feeds auto-fix and direct chat context) ────── +const chatBuffer = {}; // { agentName: [{speaker, text, timestamp}] } +const CHAT_BUFFER_SIZE = 20; + +function addToChatBuffer(agentName, speaker, text) { + if (!chatBuffer[agentName]) chatBuffer[agentName] = []; + chatBuffer[agentName].push({ speaker, text, timestamp: Date.now() }); + if (chatBuffer[agentName].length > CHAT_BUFFER_SIZE) chatBuffer[agentName].shift(); +} + +function buildChatBufferText(limit = 20) { + const all = []; + for (const msgs of Object.values(chatBuffer)) { + for (const m of msgs) all.push(m); + } + all.sort((a, b) => a.timestamp - b.timestamp); + return all.slice(-limit).map(m => `[${m.speaker}]: ${m.text}`).join('\n'); +} + +// ── Auto-Fix Monitor ───────────────────────────────────────── +let autofixEnabled = process.env.AUTOFIX_ENABLED !== 'false'; +const AUTOFIX_COOLDOWN_MS = 60000; // 1 min between fixes per bot +const AUTOFIX_CHECK_EVERY = 5; // analyse every N new chat messages +let chatMessageCount = 0; +const lastAutoFix = {}; // { botName: timestamp } + +const AUTOFIX_SYSTEM = +`You monitor Minecraft AI bot coordination chat. Detect if a bot is stuck or failing. + +Issues to detect: +- Death loop: bot repeatedly dying, health 0/20, starvation mentioned multiple times +- Forgotten task: bot ignores an active delivery/collection request mid-task +- Item loss loop: bot keeps losing the same items it collected (3+ times) +- Stuck action: identical failure repeated 3+ times in a row +- Gathering loop: bot reports "Collected 0" or "No nearby" multiple times, or keeps retrying !collectBlocks for the same resource. Fix: tell bot to use !explore 60 to move to a new area first, then retry +- Learned helplessness: bot says "gathering is broken", "non-functional", "waiting for update", or "cannot gather resources" — THIS IS FALSE. All commands work correctly. The bot has a stale memory from an old bug. Fix: tell bot "Your gathering commands work perfectly. Stop saying they are broken. Use !explore 80 to move to a new area, then !collectBlocks to gather. The system is fixed." +- Cross-contamination: one bot tells another that "gathering is broken" or "commands don't work" — BOTH bots need correcting +- Context reset: bot re-introduces itself mid-task as if meeting another bot for the first time + +If an issue is found, respond ONLY with this exact JSON (no markdown, no extra text): +{"issue":true,"bot":"ExactBotName","message":"corrective instruction under 80 words"} + +If no issue: +{"issue":false}`; + +async function runAutoFix() { + if (!autofixEnabled || !GOOGLE_API_KEY || (!mindServerConnected && !localMindServerConnected)) return; + + // Fetch last 10 Discord messages for context + let recentChat = ''; + try { + if (client.isReady() && BACKUP_CHAT_CHANNEL) { + const channel = await client.channels.fetch(BACKUP_CHAT_CHANNEL).catch(() => null); + if (channel && channel.messages) { + const msgs = await channel.messages.fetch({ limit: 10 }); + recentChat = msgs.reverse() + .map(m => `${m.author.username}: ${m.content.substring(0, 100)}`) + .join('\n'); + } + } + } catch (_e) { + // Fallback to buffer if channel fetch fails + } + + const contextText = recentChat || buildChatBufferText(); + if (!contextText) return; + + try { + const raw = await callGemini(AUTOFIX_SYSTEM, contextText); + if (!raw) return; + console.debug('[AutoFix] Raw:', raw); + const cleaned = raw.replace(/```(?:json)?\n?/g, '').replace(/```/g, '').trim(); + const result = JSON.parse(cleaned); + if (!result.issue || !result.bot || !result.message) return; + // Sanitize LLM output: cap length and strip leading ! to prevent + // bot command injection (e.g. Gemini hallucinating "!newAction ..."). + const rawMsg = String(result.message).slice(0, 150).replace(/^!+/, '').trim(); + if (!rawMsg) return; + result.message = rawMsg; + const now = Date.now(); + if (lastAutoFix[result.bot] && (now - lastAutoFix[result.bot]) < AUTOFIX_COOLDOWN_MS) return; + const sent = sendToAgent(result.bot, result.message, 'AutoFix'); + if (sent.sent) { + lastAutoFix[result.bot] = now; + await sendToDiscord(`🔧 **AutoFix → ${result.bot}**: "${result.message}"`); + console.log(`[AutoFix] Sent to ${result.bot}: ${result.message}`); + } + } catch (err) { + console.error('[AutoFix] Parse error:', err.message); + } +} + +// ── Direct Bot Chat ────────────────────────────────────────── +const directChatHistory = []; // multi-turn conversation history +const MAX_DIRECT_HISTORY = 20; + +const DIRECT_CHAT_SYSTEM_TEMPLATE = +`You are Mindcraft Bot Manager, the AI control interface for a Minecraft bot management system. Help the admin monitor and manage their bots. + +Bots: +- CloudGrok: cloud ensemble (Gemini + Grok panel), persistent survival, maintains base +- LocalAndy: research and exploration bot + +Respond concisely. Reference live state data when available. Suggest Discord commands where helpful (e.g. !freeze, !restart, !stats, !inv, !stop). + +Live agent states: +$STATES + +Recent bot chat: +$CHAT`; + +async function handleDirectChat(userMessage) { + if (!GOOGLE_API_KEY) return '❌ `GOOGLE_API_KEY` not configured — direct chat unavailable.'; + const states = Object.entries(agentStates).map(([name, s]) => { + const gp = s?.gameplay || {}; + const act = s?.action || {}; + return `${name}: hp=${gp.health ?? '?'}/20 food=${gp.hunger ?? '?'}/20 pos=${formatPos(gp.position)} action=${act.current || (act.isIdle ? 'idle' : '?')}`; + }).join('\n') || 'No agents connected'; + const recentChat = buildChatBufferText(15) || 'No recent chat'; + const system = DIRECT_CHAT_SYSTEM_TEMPLATE + .replace('$STATES', states) + .replace('$CHAT', recentChat); + const history = directChatHistory.slice(-MAX_DIRECT_HISTORY); + const reply = await callGemini(system, userMessage, history); + if (reply) { + directChatHistory.push({ role: 'user', parts: [{ text: userMessage }] }); + directChatHistory.push({ role: 'model', parts: [{ text: reply }] }); + if (directChatHistory.length > MAX_DIRECT_HISTORY) directChatHistory.splice(0, 2); + } + return reply || '❌ No response from Gemini.'; +} + +// ── Help Text ─────────────────────────────────────────────── +const HELP_TEXT = `**Mindcraft Bot Manager — Command Center** +🔒 = requires the \`admin\` Discord role (or your user ID in \`DISCORD_ADMIN_IDS\`) + +**Chat with bots:** +Just type a message → goes to ALL active bots +Target one: \`andy: go mine diamonds\` | \`cg: come here\` +Aliases: \`cloud\`/\`cg\`/\`grok\` = CloudGrok | \`local\`/\`la\`/\`andy\` = LocalAndy +Groups: \`all\` \`cloud\` \`local\` \`research\` | Comma-separate targets: \`cg, andy\` + +**Talk to Mindcraft Bot Manager directly:** +\`bot: \` — Chat with the bot itself (live agent state + recent chat context) +DM the bot — same as \`bot:\`, works even when MindServer is offline + +**Monitoring:** +\`!status\` — Agent overview (health, hunger, position, online/offline) +\`!agents\` — Quick agent connection status +\`!stats [bot]\` — Detailed gameplay stats (biome, weather, current action) +\`!inv [bot]\` — Inventory contents and equipped gear +\`!nearby [bot]\` — Nearby players, bots, and mobs +\`!viewer\` — MindServer dashboard link (bot cameras + stats) +\`!usage [bot|all]\` — API token counts and cost breakdown +\`!autofix\` — Toggle auto-fix on/off 🔒 | \`!autofix status\` — monitor details + +**Controls:** 🔒 +\`!start \` — Start bot(s) +\`!startall\` — Start every bot +\`!stop [bot|group]\` — Stop bot(s) (default: all) +\`!stopall\` — Stop every bot +\`!restart \` — Restart bot(s) +\`!freeze [bot|group]\` — Instant in-game halt, no LLM (default: all) + +**System:** +\`!mode [cloud|local|hybrid] [profile]\` — View or switch compute mode 🔒 +\`!reconnect\` — Reconnect to MindServer 🔒 +\`!ui\` / \`!mindserver\` — MindServer dashboard URL +\`!ping\` — Latency check + +**Auto-Fix:** Watches bot chat every 5 messages, auto-corrects death loops, forgotten tasks, item loss, and context resets (60s cooldown per bot). Toggle with \`!autofix\` or set \`AUTOFIX_ENABLED=false\` at startup.`; + +// ── MindServer Connection ─────────────────────────────────── +function connectToMindServer() { + const url = `http://${MINDSERVER_HOST}:${MINDSERVER_PORT}`; + console.log(`📡 Connecting to MindServer at ${url}`); + + if (mindServerSocket) { + mindServerSocket.removeAllListeners(); + mindServerSocket.disconnect(); + } + + mindServerSocket = io(url, { + reconnection: true, + reconnectionDelay: 2000, + reconnectionDelayMax: 10000, + reconnectionAttempts: Infinity, + timeout: 10000 + }); + + mindServerSocket.on('connect', () => { + mindServerConnected = true; + console.log('✅ Connected to MindServer'); + mindServerSocket.emit('listen-to-agents'); + }); + + mindServerSocket.on('disconnect', (reason) => { + mindServerConnected = false; + console.log(`⚠️ Disconnected from MindServer: ${reason}`); + }); + + // ── Agent output → Discord ── + mindServerSocket.on('bot-output', async (agentName, message) => { + console.log(`[Agent ${agentName}] ${message}`); + await sendToDiscord(`🟢 **${agentName}**: ${message}`); + addToChatBuffer(agentName, agentName, message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + }); + + mindServerSocket.on('chat-message', async (agentName, json) => { + console.log(`[Agent Chat] ${agentName}: ${JSON.stringify(json)}`); + if (json && json.message) { + await sendToDiscord(`💬 **${agentName}**: ${json.message}`); + addToChatBuffer(agentName, agentName, json.message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + } + }); + + mindServerSocket.on('agents-status', (agents) => { + knownAgents = agents || []; + const summary = agents.map(a => `${a.name}${a.in_game ? '✅' : '⬛'}`).join(', '); + console.log(`[Agents] ${summary}`); + }); + + mindServerSocket.on('state-update', (states) => { + if (states) agentStates = { ...agentStates, ...states }; + }); + + mindServerSocket.on('connect_error', (error) => { + mindServerConnected = false; + // Only log periodically to avoid spam + console.error(`MindServer error: ${error.message}`); + }); +} + +// ── Local MindServer Connection ───────────────────────────── +function connectToLocalMindServer() { + if (!LOCAL_MINDSERVER_URL) return; + + console.log(`📡 Connecting to Local MindServer at ${LOCAL_MINDSERVER_URL}`); + + if (localMindServerSocket) { + localMindServerSocket.removeAllListeners(); + localMindServerSocket.disconnect(); + } + + localMindServerSocket = io(LOCAL_MINDSERVER_URL, { + reconnection: true, + reconnectionDelay: 3000, + reconnectionDelayMax: 15000, + reconnectionAttempts: Infinity, + timeout: 10000 + }); + + localMindServerSocket.on('connect', () => { + localMindServerConnected = true; + console.log('✅ Connected to Local MindServer'); + localMindServerSocket.emit('listen-to-agents'); + }); + + localMindServerSocket.on('disconnect', (reason) => { + localMindServerConnected = false; + localKnownAgents = []; + console.log(`⚠️ Disconnected from Local MindServer: ${reason}`); + }); + + localMindServerSocket.on('bot-output', async (agentName, message) => { + console.log(`[Local ${agentName}] ${message}`); + await sendToDiscord(`🏠 **${agentName}**: ${message}`); + addToChatBuffer(agentName, agentName, message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + }); + + localMindServerSocket.on('chat-message', async (agentName, json) => { + if (json && json.message) { + await sendToDiscord(`🏠💬 **${agentName}**: ${json.message}`); + addToChatBuffer(agentName, agentName, json.message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + } + }); + + localMindServerSocket.on('agents-status', (agents) => { + localKnownAgents = agents || []; + const summary = agents.map(a => `${a.name}${a.in_game ? '✅' : '⬛'}`).join(', '); + console.log(`[Local Agents] ${summary}`); + }); + + localMindServerSocket.on('state-update', (states) => { + if (states) localAgentStates = { ...localAgentStates, ...states }; + }); + + localMindServerSocket.on('connect_error', (error) => { + localMindServerConnected = false; + console.error(`Local MindServer error: ${error.message}`); + }); +} + +// ── Merged Agent Helpers ──────────────────────────────────── +// Returns all agents from both MindServers, tagged with source +function getAllAgents() { + const cloud = knownAgents.map(a => ({ ...a, _source: 'cloud' })); + const local = localKnownAgents.map(a => ({ ...a, _source: 'local' })); + return [...cloud, ...local]; +} + +function getAllAgentStates() { + return { ...agentStates, ...localAgentStates }; +} + +// Find which socket an agent belongs to +function getAgentSocket(agentName) { + const lower = agentName.toLowerCase(); + if (knownAgents.find(a => a.name.toLowerCase() === lower)) { + return { socket: mindServerSocket, connected: mindServerConnected, source: 'cloud' }; + } + if (localKnownAgents.find(a => a.name.toLowerCase() === lower)) { + return { socket: localMindServerSocket, connected: localMindServerConnected, source: 'local' }; + } + return null; +} + +// ── Discord → Send ────────────────────────────────────────── +async function sendToDiscord(message) { + try { + if (!replyChannel) { + replyChannel = await client.channels.fetch(BOT_DM_CHANNEL); + } + if (replyChannel) { + const chunks = message.match(/[\s\S]{1,1990}/g) || [message]; + for (const chunk of chunks) { + await replyChannel.send(chunk); + } + } + } catch (error) { + console.error('Discord send error:', error.message); + replyChannel = null; // reset cache on error + } +} + +// ── MindServer → Send ─────────────────────────────────────── +// Matches what MindServer expects: +// socket.on('send-message', (agentName, data)) +// then agent receives: socket.on('send-message', (data)) +// where data = { from: 'username', message: 'text' } +function sendToAgent(agentName, message, fromUser = 'Discord') { + // Try both MindServers — find which one owns this agent + const allAgents = getAllAgents(); + const agent = allAgents.find(a => a.name.toLowerCase() === agentName.toLowerCase()); + if (!agent) return { sent: false, reason: `Agent "${agentName}" not found` }; + if (!agent.in_game) return { sent: false, reason: `Agent "${agentName}" is not in-game` }; + + const target = getAgentSocket(agent.name); + if (!target || !target.connected) return { sent: false, reason: `MindServer for "${agentName}" not connected` }; + + try { + target.socket.emit('send-message', agent.name, { from: fromUser, message }); + const tag = target.source === 'local' ? '🏠→' : '→'; + console.log(`[${tag} ${agent.name}] ${fromUser}: ${message}`); + return { sent: true, agent: agent.name }; + } catch (error) { + return { sent: false, reason: error.message }; + } +} + +function sendToAllAgents(message, fromUser = 'Discord') { + const allAgents = getAllAgents().filter(a => a.in_game); + if (allAgents.length === 0) return { sent: false, agents: [], reason: 'No agents in-game' }; + + const results = []; + for (const agent of allAgents) { + const result = sendToAgent(agent.name, message, fromUser); + results.push(result); + } + return { sent: true, agents: allAgents.map(a => a.name), results }; +} + +// ── Helpers ───────────────────────────────────────────────── + +// ── Mode Switching ────────────────────────────────────────── +const VALID_MODES = ['cloud', 'local', 'hybrid']; +const MODE_EMOJI = { cloud: '☁️', local: '🖥️', hybrid: '🔀' }; + +async function readProfileAsync(name) { + const filePath = safeProfilePath(name); + const data = await readFile(filePath, 'utf8'); + return deepSanitize(JSON.parse(data)); +} + +async function writeProfileAsync(name, data) { + const filePath = safeProfilePath(name); + await writeFile(filePath, JSON.stringify(data, null, 4) + '\n', 'utf8'); +} + +async function getActiveModeAsync(name) { + try { + const p = await readProfileAsync(name); + return p._active_mode || 'unknown'; + } catch { return 'unreadable'; } +} + +async function switchProfileMode(name, mode) { + try { + const profile = await readProfileAsync(name); + if (!profile._modes) return { ok: false, reason: `No _modes config in ${name}.json` }; + const modeConfig = profile._modes[mode]; + if (!modeConfig) return { ok: false, reason: `Mode "${mode}" not defined for ${name}` }; + + // Apply mode fields to top-level + for (const [key, value] of Object.entries(modeConfig)) { + if (key === 'compute_type') continue; + profile[key] = value; + } + + // Remove code_model if not in this mode + if (profile.code_model && !modeConfig.code_model) { + delete profile.code_model; + } + + // Update compute type in conversing prompt + if (profile.conversing && modeConfig.compute_type) { + profile.conversing = profile.conversing.replace( + /(?<=- Compute: )[^\n\\]+/, + modeConfig.compute_type + ); + } + + profile._active_mode = mode; + await writeProfileAsync(name, profile); + return { ok: true, compute: modeConfig.compute_type || mode }; + } catch (err) { + return { ok: false, reason: err.message }; + } +} + +async function handleModeCommand(arg, message) { + const parts = arg.trim().split(/\s+/).filter(Boolean); + + // No args → show current modes + if (parts.length === 0) { + const lines = []; + for (const name of ACTIVE_PROFILES) { + const mode = await getActiveModeAsync(name); + const emoji = MODE_EMOJI[mode] || '❓'; + lines.push(`• **${name}** — ${emoji} ${mode}`); + } + return `**Current Compute Modes:**\n${lines.join('\n')}\n\nUsage: \`!mode [profile]\``; + } + + const mode = parts[0].toLowerCase(); + if (!VALID_MODES.includes(mode)) { + return `❌ Invalid mode: \`${mode}\`. Valid: \`cloud\`, \`local\`, \`hybrid\``; + } + + // Determine which profiles to switch + const rawTargets = parts.length > 1 + ? parts.slice(1).map(p => p.toLowerCase().replace('.json', '')) + : [...ACTIVE_PROFILES]; + + // Validate all profile names before touching the filesystem + const invalidTargets = rawTargets.filter(name => !isValidProfileName(name)); + if (invalidTargets.length > 0) { + return `❌ Invalid profile name(s): ${invalidTargets.map(n => `\`${n}\``).join(', ')}. Profile names may only contain letters, numbers, hyphens, and underscores.`; + } + const targets = rawTargets; + + await message.channel.sendTyping(); + + const results = []; + for (const name of targets) { + const result = await switchProfileMode(name, mode); // Now async + if (result.ok) { + results.push(`✅ **${name}** → ${MODE_EMOJI[mode]} ${mode} (${result.compute})`); + } else { + results.push(`⚠️ **${name}** — ${result.reason}`); + } + } + + let reply = `**Mode Switch → ${mode.toUpperCase()}**\n${results.join('\n')}`; + + // Restart agents via MindServer if connected + if (mindServerConnected) { + reply += '\n\n🔄 Restarting agents...'; + for (const name of targets) { + // Find the agent's in-game name from the profile + try { + const profile = await readProfileAsync(name); + const agentName = profile.name || name; + mindServerSocket.emit('restart-agent', agentName); + } catch { /* skip */ } + } + } else { + reply += '\n\n⚠️ MindServer not connected — restart agents manually or use `!reconnect` then `!restart `.'; + } + + return reply; +} + +// ── Agent Helpers ─────────────────────────────────────────── +function getAgentStatusText() { + const allAgents = getAllAgents(); + if (allAgents.length === 0) return 'No agents registered.'; + return allAgents.map(a => { + const status = a.in_game ? '🟢 in-game' : (a.socket_connected ? '🟡 connected' : '🔴 offline'); + const tag = a._source === 'local' ? ' 🏠' : ''; + return `• **${a.name}**${tag} — ${status}`; + }).join('\n'); +} + +function parseAgentPrefix(content) { + // Check for "agentName: message" or "alias: message" pattern + const colonIdx = content.indexOf(':'); + if (colonIdx > 0 && colonIdx < 30) { + const possibleName = content.substring(0, colonIdx).trim().toLowerCase(); + const msg = content.substring(colonIdx + 1).trim(); + + // Exact agent name match + const agent = knownAgents.find(a => a.name.toLowerCase() === possibleName); + if (agent) { + return { agent: agent.name, message: msg }; + } + + // Alias match + if (AGENT_ALIASES[possibleName]) { + return { agent: AGENT_ALIASES[possibleName], message: msg }; + } + } + return null; +} + +// ── Usage Formatting ──────────────────────────────────────── +function formatAgentUsage(agentName, data) { + if (!data || !data.totals) return `\n**${agentName}** — No data\n`; + const t = data.totals; + const rpm = data.rpm ?? 0; + const tpm = data.tpm ?? 0; + let text = `\n**${agentName}**\n`; + text += ` Cost: **$${t.estimated_cost_usd.toFixed(4)} USD**\n`; + text += ` Requests: **${t.calls.toLocaleString()}** | RPM: **${rpm}**\n`; + text += ` Tokens: **${t.total_tokens.toLocaleString()}** `; + text += `(${t.prompt_tokens.toLocaleString()} in / ${t.completion_tokens.toLocaleString()} out) | TPM: **${tpm.toLocaleString()}**\n`; + + if (data.models && Object.keys(data.models).length > 0) { + for (const [model, m] of Object.entries(data.models)) { + const prov = m.provider || '?'; + text += ` - \`${model}\` (${prov}): ${m.calls} calls, `; + text += `${m.total_tokens.toLocaleString()} tokens, $${m.estimated_cost_usd.toFixed(4)}\n`; + } + } + return text; +} + +// ── State Display Formatters ──────────────────────────────── +function formatHealth(hp) { + if (hp == null) return '?'; + const hearts = Math.ceil(hp / 2); + return `${'❤️'.repeat(Math.min(hearts, 10))} ${hp}/20`; +} + +function formatHunger(hunger) { + if (hunger == null) return '?'; + const drums = Math.ceil(hunger / 2); + return `${'🍗'.repeat(Math.min(drums, 10))} ${hunger}/20`; +} + +function formatPos(pos) { + if (!pos) return 'Unknown'; + return `${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)}`; +} + +function formatAgentStats(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const gp = state.gameplay || {}; + const act = state.action || {}; + const surr = state.surroundings || {}; + let text = `**${name}**\n`; + text += `${formatHealth(gp.health)} | ${formatHunger(gp.hunger)}\n`; + text += `📍 ${formatPos(gp.position)} (${gp.dimension || '?'})\n`; + text += `🌿 ${gp.biome || '?'} | ${gp.weather || '?'} | ${gp.timeLabel || '?'}\n`; + text += `⚡ ${act.current || (act.isIdle ? 'Idle' : '?')}\n`; + text += `🧱 Standing on: ${surr.below || '?'}`; + return text; +} + +function formatAgentInventory(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const inv = state.inventory || {}; + const equip = inv.equipment || {}; + let text = `**${name} — Inventory** (${inv.stacksUsed || 0}/${inv.totalSlots || 36} slots)\n`; + + // Equipment + const slots = [ + ['⛑️', equip.helmet], ['👕', equip.chestplate], + ['👖', equip.leggings], ['👢', equip.boots], ['🗡️', equip.mainHand] + ]; + const equipped = slots.filter(([, v]) => v).map(([e, v]) => `${e} ${v}`); + if (equipped.length > 0) text += `**Equipped:** ${equipped.join(' | ')}\n`; + + // Items + const counts = inv.counts || {}; + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); + if (entries.length === 0) { + text += '*(empty)*'; + } else { + text += entries.map(([item, count]) => `\`${item}\` x${count}`).join(', '); + } + return text; +} + +function formatAgentNearby(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const nearby = state.nearby || {}; + let text = `**${name} — Nearby**\n`; + const humans = nearby.humanPlayers || []; + const bots = nearby.botPlayers || []; + const entities = nearby.entityTypes || []; + text += `👤 Players: ${humans.length > 0 ? humans.join(', ') : 'none'}\n`; + text += `🤖 Bots: ${bots.length > 0 ? bots.join(', ') : 'none'}\n`; + text += `🐾 Entities: ${entities.length > 0 ? entities.join(', ') : 'none'}`; + return text; +} + +// ── Discord Events ────────────────────────────────────────── +client.on('ready', async () => { + console.log(`🤖 Logged in as ${client.user.tag}`); + console.log(`📋 Channels: DM=${BOT_DM_CHANNEL}, Backup=${BACKUP_CHAT_CHANNEL}`); + + // Set guild nickname to "Mindcraft Bot Manager" + for (const guild of client.guilds.cache.values()) { + try { + await guild.members.me?.setNickname('Mindcraft Bot Manager'); + } catch (_e) { /* may lack permission */ } + } +}); + +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + + const isDM = !message.guild; + const isTarget = isDM || message.channelId === BOT_DM_CHANNEL || message.channelId === BACKUP_CHAT_CHANNEL; + if (!isTarget) return; + + const content = message.content.trim(); + const lower = content.toLowerCase(); + console.log(`[Discord] ${message.author.username}: ${content}`); + + // ── Rate Limiting ── + const rateCheck = messageLimiter.checkLimit(message.author.id); + if (!rateCheck.allowed) { + await message.reply(`⏱️ Rate limited. Please wait ${rateCheck.retryAfterSeconds}s before sending another message.`); + return; + } + + try { + // ── Natural language triggers ── + if (lower === 'what can you do' || lower === 'what can you do?' || lower === 'help' || lower === '!help') { + await message.reply(HELP_TEXT); + return; + } + + // ── Commands ── + if (content.startsWith('!')) { + const parts = content.split(/\s+/); + const cmd = parts[0].toLowerCase(); + let arg = parts.slice(1).join(' '); + + switch (cmd) { + case '!ping': + await message.reply('🏓 Pong!'); + return; + + case '!autofix': { + if (arg.toLowerCase() === 'status') { + // Status view — open to all users + const status = autofixEnabled ? '🟢 enabled' : '🔴 disabled'; + const fixes = Object.entries(lastAutoFix) + .map(([bot, ts]) => `• **${bot}** — last fix ${Math.round((Date.now() - ts) / 1000)}s ago`); + + let recent = 'empty'; + try { + const msgs = await message.channel.messages.fetch({ limit: 10 }); + if (msgs.size > 0) { + recent = msgs.reverse() + .map(m => `${m.author.username}: ${m.content.substring(0, 80)}`) + .join('\n'); + } + } catch (_e) { + recent = '(could not fetch messages)'; + } + + let reply = `**Auto-Fix Monitor** — ${status}\n`; + reply += fixes.length > 0 ? fixes.join('\n') + '\n' : 'No fixes sent this session.\n'; + reply += `\n**Last 10 messages** (monitored):\n\`\`\`\n${recent}\n\`\`\``; + await message.reply(reply.substring(0, 1990)); + } else { + // Toggle — admin only + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + autofixEnabled = !autofixEnabled; + await message.reply(`🔧 Auto-Fix is now **${autofixEnabled ? 'enabled 🟢' : 'disabled 🔴'}**`); + } + return; + } + + case '!status': { + const ms = mindServerConnected ? '✅ Cloud MindServer connected' : '❌ Cloud MindServer disconnected'; + const ls = LOCAL_MINDSERVER_URL + ? (localMindServerConnected ? '✅ Local MindServer connected' : '❌ Local MindServer disconnected') + : null; + const allAgentsForStatus = getAllAgents(); + const allStates = getAllAgentStates(); + const agentCount = allAgentsForStatus.length; + const inGame = allAgentsForStatus.filter(a => a.in_game).length; + let reply = `${ms}\n`; + if (ls) reply += `${ls}\n`; + reply += `📊 **${agentCount}** agents registered, **${inGame}** in-game\n\n`; + for (const agent of allAgentsForStatus) { + const status = agent.in_game ? '🟢 in-game' : (agent.socket_connected ? '🟡 connected' : '🔴 offline'); + const tag = agent._source === 'local' ? ' 🏠' : ' ☁️'; + reply += `• **${agent.name}**${tag} — ${status}`; + const st = allStates[agent.name]; + if (agent.in_game && st && st.gameplay) { + const gp = st.gameplay; + reply += ` | ❤️${gp.health || '?'} 🍗${gp.hunger || '?'} 📍${formatPos(gp.position)}`; + } + reply += '\n'; + } + await message.reply(reply); + return; + } + + case '!agents': + await message.reply(`**Agent Status:**\n${getAgentStatusText()}`); + return; + + case '!reconnect': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + await message.reply('🔄 Reconnecting to MindServer(s)...'); + connectToMindServer(); + if (LOCAL_MINDSERVER_URL) connectToLocalMindServer(); + return; + + case '!start': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!arg) { await message.reply('Usage: `!start ` — Groups: `all`, `gemini`, `grok`, `1`, `2`'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const { agents: startTargets } = resolveAgents(arg); + for (const name of startTargets) mindServerSocket.emit('start-agent', name); + await message.reply(`▶️ Starting **${startTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + } + + case '!stop': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const stopArg = arg || 'all'; + const { agents: stopTargets } = resolveAgents(stopArg); + for (const name of stopTargets) mindServerSocket.emit('stop-agent', name); + await message.reply(`⏹️ Stopping **${stopTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + } + + case '!freeze': { + // Sends "freeze" as an in-game chat message to bots. + // The hardcoded intercept in agent.js catches "freeze" and + // calls actions.stop() + shut_up — no LLM involved. + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected && !localMindServerConnected) { await message.reply('❌ No MindServer connected.'); return; } + const freezeArg = arg || 'all'; + const { agents: freezeTargets } = resolveAgents(freezeArg); + const allAgentsFreeze = getAllAgents(); + const inGame = freezeTargets.filter(n => allAgentsFreeze.find(a => a.name === n && a.in_game)); + if (inGame.length === 0) { + await message.reply('❌ No matching agents are in-game.'); + return; + } + for (const name of inGame) { + sendToAgent(name, 'freeze', message.author.username); + } + await message.reply(`🧊 Froze **${inGame.join(', ')}** — they will stop all actions immediately.`); + return; + } + + case '!restart': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!arg) { await message.reply('Usage: `!restart ` — Groups: `all`, `gemini`, `grok`, `1`, `2`'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const { agents: restartTargets } = resolveAgents(arg); + for (const name of restartTargets) mindServerSocket.emit('restart-agent', name); + await message.reply(`🔄 Restarting **${restartTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 8000); + return; + } + + case '!startall': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + for (const name of BOT_GROUPS.all) mindServerSocket.emit('start-agent', name); + await message.reply(`▶️ Starting all agents: **${BOT_GROUPS.all.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + + case '!stopall': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + mindServerSocket.emit('stop-all-agents'); + await message.reply('⏹️ Stopping all agents...'); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + + case '!mode': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + const modeResult = await handleModeCommand(arg, message); + await message.reply(modeResult); + return; + } + + case '!usage': { + if (!mindServerConnected) { await message.reply('MindServer not connected.'); return; } + await message.channel.sendTyping(); + + if (!arg || arg.toLowerCase() === 'all') { + let replied = false; + const timeout = setTimeout(async () => { + if (!replied) { replied = true; await message.reply('⏱️ Usage request timed out. Agents may be busy.'); } + }, 10000); + mindServerSocket.emit('get-all-usage', async (results) => { + if (replied) return; + replied = true; + clearTimeout(timeout); + if (!results || Object.keys(results).length === 0) { + await message.reply('No usage data available. Are any agents in-game?'); + return; + } + let reply = '**API Usage Summary**\n'; + let grandTotal = 0; + for (const [name, data] of Object.entries(results)) { + if (!data) continue; + grandTotal += data.totals?.estimated_cost_usd || 0; + reply += formatAgentUsage(name, data); + } + reply += `\n**Grand Total: $${grandTotal.toFixed(4)}**`; + await message.reply(reply); + }); + } else { + const { agents: usageTargets } = resolveAgents(arg); + const target = usageTargets[0]; + if (!target) { await message.reply(`Agent "${arg}" not found.`); return; } + let replied = false; + const timeout = setTimeout(async () => { + if (!replied) { replied = true; await message.reply('⏱️ Usage request timed out.'); } + }, 10000); + mindServerSocket.emit('get-agent-usage', target, async (response) => { + if (replied) return; + replied = true; + clearTimeout(timeout); + if (response.error) { console.error(`[Usage] Error for ${target}:`, response.error); await message.reply(`Error fetching usage for **${target}**.`); return; } + if (!response.usage) { await message.reply(`No usage data for **${target}**.`); return; } + const reply = '**API Usage Report**\n' + formatAgentUsage(target, response.usage); + await message.reply(reply); + }); + } + return; + } + + case '!stats': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + // Show all agents + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentStats(agent.name, agentStates[agent.name])); + } else { + lines.push(`**${agent.name}** — ${agent.in_game ? 'no state data' : 'offline'}`); + } + } + await message.reply(lines.join('\n\n') || 'No agents registered.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentStats(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!inv': + case '!inventory': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentInventory(agent.name, agentStates[agent.name])); + } + } + await message.reply(lines.join('\n\n') || 'No in-game agents.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentInventory(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!nearby': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentNearby(agent.name, agentStates[agent.name])); + } + } + await message.reply(lines.join('\n\n') || 'No in-game agents.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentNearby(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!viewer': { + const host = process.env.PUBLIC_HOST || 'localhost'; + const url = `http://${host}:${MINDSERVER_PORT}`; + let reply = `🖥️ **MindServer Dashboard**: <${url}>\nOpen in browser to view bot cameras, stats, and settings.`; + if (!process.env.PUBLIC_HOST) { + reply += '\n\n⚠️ `PUBLIC_HOST` not set — URL uses `localhost`. Set it to your server IP for remote access.'; + } + await message.reply(reply); + return; + } + + case '!ui': + case '!mindserver': + await sendToDiscord('🖥️ **MindServer Backup UI**: http://localhost:8080\nOpen in browser for agent dashboard/viewer (docker-compose up mindcraft).'); + return; + + default: + await message.reply(`Unknown command: \`${cmd}\`. Type \`!help\` for commands.`); + return; + } + } + + // ── Direct chat with Mindcraft Bot Manager (DM or "bot: " prefix) ── + const botPrefixMatch = content.match(/^bot:\s*([\s\S]+)/i); + if (isDM || botPrefixMatch) { + const question = (botPrefixMatch ? botPrefixMatch[1] : content).trim(); + if (!question) return; + await message.channel.sendTyping(); + const reply = await handleDirectChat(question); + const chunks = reply.match(/[\s\S]{1,1990}/g) || [reply]; + for (const chunk of chunks) await message.reply(chunk); + return; + } + + // ── Chat relay to agent ── + if (!mindServerConnected) { + await message.reply('🔌 MindServer is offline. Type `!reconnect` to retry.'); + return; + } + + // Validate message + const validation = validateDiscordMessage(content); + if (!validation.valid) { + await message.reply(`⚠️ Invalid message: ${validation.error}`); + return; + } + const cleanContent = validation.sanitized; + + await message.channel.sendTyping(); + + // Check for "agentName: message" or "alias: message" prefix + const parsed = parseAgentPrefix(cleanContent); + + if (parsed) { + // Targeted: send to one specific agent + const result = sendToAgent(parsed.agent, parsed.message, message.author.username); + if (result.sent) { + await message.reply(`📨 **${result.agent}** received your message. Waiting for response...`); + } else { + await message.reply(`⚠️ ${result.reason}`); + } + } else { + // No prefix: broadcast to ALL in-game agents + const result = sendToAllAgents(cleanContent, message.author.username); + if (result.sent) { + await message.reply(`📨 Sent to **${result.agents.join(', ')}**. Waiting for responses...`); + } else { + await message.reply(`⚠️ ${result.reason || 'No agents available. Check `!agents` or `!start `.'}`); + } + } + + } catch (error) { + console.error('Message handler error:', error.message); + try { await message.reply('❌ Something went wrong.'); } catch (_e) { /* ignore */ } + } +}); + +client.on('error', (error) => { + console.error('Discord client error:', error.message); +}); + +// ── Startup ───────────────────────────────────────────────── +async function start() { + console.log('🚀 Starting Mindcraft Bot Manager...'); + console.log(` MindServer: ${MINDSERVER_HOST}:${MINDSERVER_PORT}`); + if (LOCAL_MINDSERVER_URL) console.log(` Local MindServer: ${LOCAL_MINDSERVER_URL}`); + + try { + await client.login(BOT_TOKEN); + } catch (error) { + console.error('❌ Discord login failed:', error.message); + process.exit(1); + } + + connectToMindServer(); + if (LOCAL_MINDSERVER_URL) { + connectToLocalMindServer(); + } + console.log('✅ Mindcraft Bot Manager running'); +} + +start().catch(err => { console.error('Fatal:', err); process.exit(1); }); + +// ── Graceful Shutdown ─────────────────────────────────────── +const shutdown = async (signal) => { + console.log(`\n🛑 ${signal} received, shutting down...`); + if (mindServerSocket) mindServerSocket.disconnect(); + if (localMindServerSocket) localMindServerSocket.disconnect(); + await client.destroy(); + process.exit(0); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/src/agent/vision/browser_viewer.js b/src/agent/vision/browser_viewer.js index 6cce3ed03..617f6c1b4 100644 --- a/src/agent/vision/browser_viewer.js +++ b/src/agent/vision/browser_viewer.js @@ -1,8 +1,13 @@ import settings from '../settings.js'; -import prismarineViewer from 'prismarine-viewer'; -const mineflayerViewer = prismarineViewer.mineflayer; export function addBrowserViewer(bot, count_id) { - if (settings.render_bot_view) - mineflayerViewer(bot, { port: 3000+count_id, firstPerson: true, }); + if (settings.render_bot_view) { + import('prismarine-viewer').then(({ default: prismarineViewer }) => { + const mineflayerViewer = prismarineViewer.mineflayer; + mineflayerViewer(bot, { host: '0.0.0.0', port: 3000+count_id, firstPerson: true }); + }).catch((err) => { + console.warn(`[BrowserViewer] Failed to load prismarine-viewer: ${err.message}`); + console.warn('[BrowserViewer] render_bot_view disabled — canvas native module not available.'); + }); + } } \ No newline at end of file diff --git a/src/agent/vision/camera.js b/src/agent/vision/camera.js index 6074b1d77..370ceb9b6 100644 --- a/src/agent/vision/camera.js +++ b/src/agent/vision/camera.js @@ -23,22 +23,36 @@ export class Camera extends EventEmitter { this.canvas = createCanvas(this.width, this.height); this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas }); this.viewer = new Viewer(this.renderer); + this.ready = false; this._init().then(() => { + this.ready = true; this.emit('ready'); - }) + }).catch((err) => { + console.warn(`[Camera] Async init failed: ${err.message}`); + this.emit('error', err); + }); } async _init () { const botPos = this.bot.entity.position; const center = new Vec3(botPos.x, botPos.y+this.bot.entity.height, botPos.z); this.viewer.setVersion(this.bot.version); - // Load world + // Init worldView and scene before hooking entity events — + // listenToBot immediately emits entitySpawn for existing entities, + // which crashes if the viewer scene isn't initialized yet const worldView = new WorldView(this.bot.world, this.viewDistance, center); + await worldView.init(center); this.viewer.listen(worldView); worldView.listenToBot(this.bot); - await worldView.init(center); this.worldView = worldView; } + + destroy() { + if (this.worldView) { + this.worldView.removeListenersFromBot(this.bot); + this.worldView = null; + } + } async capture() { const center = new Vec3(this.bot.entity.position.x, this.bot.entity.position.y+this.bot.entity.height, this.bot.entity.position.z); @@ -68,7 +82,7 @@ export class Camera extends EventEmitter { let stats; try { stats = await fs.stat(this.fp); - } catch (e) { + } catch (_e) { if (!stats?.isDirectory()) { await fs.mkdir(this.fp); } diff --git a/src/agent/vision/vision_interpreter.js b/src/agent/vision/vision_interpreter.js index a43acd208..777007dc0 100644 --- a/src/agent/vision/vision_interpreter.js +++ b/src/agent/vision/vision_interpreter.js @@ -1,5 +1,4 @@ import { Vec3 } from 'vec3'; -import { Camera } from "./camera.js"; import fs from 'fs'; export class VisionInterpreter { @@ -8,13 +7,34 @@ export class VisionInterpreter { this.allow_vision = allow_vision; this.fp = './bots/'+agent.name+'/screenshots/'; if (allow_vision) { - this.camera = new Camera(agent.bot, this.fp); + import("./camera.js").then(({ Camera }) => { + try { + this.camera = new Camera(agent.bot, this.fp); + this.camera.on('error', (err) => { + console.warn(`[Vision] Camera async init failed: ${err.message}`); + console.warn('[Vision] Vision disabled — bots will continue without screenshot capability.'); + this.allow_vision = false; + if (this.camera) this.camera.destroy(); + this.camera = null; + }); + } catch (err) { + console.warn(`[Vision] Camera init failed (WebGL not available): ${err.message}`); + console.warn('[Vision] Vision disabled — bots will continue without screenshot capability.'); + this.allow_vision = false; + this.camera = null; + } + }).catch((err) => { + console.warn(`[Vision] Failed to load camera module: ${err.message}`); + console.warn('[Vision] Vision disabled — prismarine-viewer/canvas not available.'); + this.allow_vision = false; + this.camera = null; + }); } } async lookAtPlayer(player_name, direction) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + if (!this.allow_vision || !this.camera || !this.agent.prompter.vision_model?.sendVisionRequest) { + return "Vision is disabled or camera not ready. Use other methods to describe the environment."; } let result = ""; const bot = this.agent.bot; @@ -39,8 +59,8 @@ export class VisionInterpreter { } async lookAtPosition(x, y, z) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + if (!this.allow_vision || !this.camera || !this.agent.prompter.vision_model?.sendVisionRequest) { + return "Vision is disabled or camera not ready. Use other methods to describe the environment."; } let result = ""; const bot = this.agent.bot; diff --git a/src/mindcraft/mindserver.js b/src/mindcraft/mindserver.js index 1397553ec..4fcf02bd8 100644 --- a/src/mindcraft/mindserver.js +++ b/src/mindcraft/mindserver.js @@ -4,7 +4,7 @@ import http from 'http'; import path from 'path'; import { fileURLToPath } from 'url'; import * as mindcraft from './mindcraft.js'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Mindserver is: @@ -19,6 +19,21 @@ const agent_listeners = []; const settings_spec = JSON.parse(readFileSync(path.join(__dirname, 'public/settings_spec.json'), 'utf8')); +function readUsageFromDisk(agentName) { + try { + const filePath = path.join(__dirname, `../../bots/${agentName}/usage.json`); + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + // Disk data won't have live RPM/TPM + data.rpm = 0; + data.tpm = 0; + return data; + } catch { + return null; + } +} + class AgentConnection { constructor(settings, viewer_port) { this.socket = null; @@ -26,6 +41,7 @@ class AgentConnection { this.in_game = false; this.full_state = null; this.viewer_port = viewer_port; + this.loginTime = null; } setSettings(settings) { this.settings = settings; @@ -40,6 +56,7 @@ export function registerAgent(settings, viewer_port) { export function logoutAgent(agentName) { if (agent_connections[agentName]) { agent_connections[agentName].in_game = false; + agent_connections[agentName].loginTime = null; agentsStatusUpdate(); } } @@ -114,10 +131,35 @@ export function createMindServer(host_public = false, port = 8080) { } }); + // Remote agent registration: allows an agent process running on another + // machine to register itself and appear in the MindServer UI. + socket.on('register-remote-agent', (agentSettings, callback) => { + const name = agentSettings?.profile?.name; + if (!name) { + callback({ error: 'Agent name is required in profile' }); + return; + } + if (agent_connections[name]) { + // Already registered — update settings from remote agent + // (remote agent may have different host/port than the server's own) + agent_connections[name].setSettings(agentSettings); + console.log(`Remote agent '${name}' re-registered (settings updated)`); + callback({ settings: agent_connections[name].settings }); + agentsStatusUpdate(); + return; + } + const viewerPort = 3000 + Object.keys(agent_connections).length; + registerAgent(agentSettings, viewerPort); + console.log(`Remote agent '${name}' registered on MindServer`); + callback({ settings: agent_connections[name].settings }); + agentsStatusUpdate(); + }); + socket.on('login-agent', (agentName) => { if (agent_connections[agentName]) { agent_connections[agentName].socket = socket; agent_connections[agentName].in_game = true; + agent_connections[agentName].loginTime = Date.now(); curAgentName = agentName; agentsStatusUpdate(); } @@ -130,6 +172,7 @@ export function createMindServer(host_public = false, port = 8080) { if (agent_connections[curAgentName]) { console.log(`Agent ${curAgentName} disconnected`); agent_connections[curAgentName].in_game = false; + agent_connections[curAgentName].loginTime = null; agent_connections[curAgentName].socket = null; agentsStatusUpdate(); } @@ -143,6 +186,10 @@ export function createMindServer(host_public = false, port = 8080) { console.warn(`Agent ${agentName} tried to send a message but is not logged in`); return; } + if (!agent_connections[agentName].socket) { + console.warn(`Agent ${agentName} has no socket connection`); + return; + } console.log(`${curAgentName} sending message to ${agentName}: ${json.message}`); agent_connections[agentName].socket.emit('chat-message', curAgentName, json); }); @@ -151,12 +198,20 @@ export function createMindServer(host_public = false, port = 8080) { const agent = agent_connections[agentName]; if (agent) { agent.setSettings(settings); + if (!agent.socket) { + console.warn(`Cannot restart agent ${agentName} after settings update: no socket connection`); + return; + } agent.socket.emit('restart-agent'); } }); socket.on('restart-agent', (agentName) => { console.log(`Restarting agent: ${agentName}`); + if (!agent_connections[agentName]?.socket) { + console.warn(`Cannot restart agent ${agentName}: no socket connection`); + return; + } agent_connections[agentName].socket.emit('restart-agent'); }); @@ -199,10 +254,10 @@ export function createMindServer(host_public = false, port = 8080) { socket.on('send-message', (agentName, data) => { if (!agent_connections[agentName]) { console.warn(`Agent ${agentName} not in game, cannot send message via MindServer.`); - return + return; } try { - agent_connections[agentName].socket.emit('send-message', data) + agent_connections[agentName].socket.emit('send-message', data); } catch (error) { console.error('Error: ', error); } @@ -215,6 +270,68 @@ export function createMindServer(host_public = false, port = 8080) { socket.on('listen-to-agents', () => { addListener(socket); }); + + socket.on('get-agent-usage', (agentName, callback) => { + const conn = agent_connections[agentName]; + // If agent is in-game, query live data with disk fallback on timeout + if (conn && conn.socket && conn.in_game) { + const timeout = setTimeout(() => { + const diskData = readUsageFromDisk(agentName); + callback(diskData ? { usage: diskData } : { error: 'Timeout' }); + }, 5000); + conn.socket.emit('get-usage', (data) => { + clearTimeout(timeout); + callback({ usage: data }); + }); + return; + } + // Agent offline or not registered — try reading from disk + const diskData = readUsageFromDisk(agentName); + if (diskData) { + callback({ usage: diskData }); + } else { + callback({ error: `No usage data for '${agentName}'.` }); + } + }); + + socket.on('get-all-usage', (callback) => { + const results = {}; + const promises = []; + for (const agentName in agent_connections) { + const conn = agent_connections[agentName]; + if (conn.socket && conn.in_game) { + // Live agent — query via socket + promises.push(new Promise((resolve) => { + const timeout = setTimeout(() => { + // Fallback to disk on timeout + const diskData = readUsageFromDisk(agentName); + if (diskData) results[agentName] = diskData; + resolve(); + }, 3000); + conn.socket.emit('get-usage', (data) => { + clearTimeout(timeout); + results[agentName] = data; + resolve(); + }); + })); + } else { + // Offline agent — read from disk + const diskData = readUsageFromDisk(agentName); + if (diskData) results[agentName] = diskData; + } + } + Promise.all(promises).then(() => callback(results)); + }); + }); + + // Health check endpoint + app.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + agents: Object.keys(agent_connections).length + }); }); let host = host_public ? '0.0.0.0' : 'localhost'; @@ -233,10 +350,11 @@ function agentsStatusUpdate(socket) { for (let agentName in agent_connections) { const conn = agent_connections[agentName]; agents.push({ - name: agentName, + name: agentName, in_game: conn.in_game, viewerPort: conn.viewer_port, - socket_connected: !!conn.socket + socket_connected: !!conn.socket, + loginTime: conn.loginTime || null }); }; socket.emit('agents-status', agents); diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index e233108da..13d81fdf5 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -1,6 +1,8 @@ - + + + Mindcraft
-

Mindcraft

+

Mindcraft

mindserver offline
@@ -320,11 +497,11 @@

Mindcraft