diff --git a/bots/execTemplate.js b/bots/execTemplate.js index b7f270c9f..9ea5f6108 100644 --- a/bots/execTemplate.js +++ b/bots/execTemplate.js @@ -3,4 +3,4 @@ /* CODE HERE */ log(bot, 'Code finished.'); -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/bots/lintTemplate.js b/bots/lintTemplate.js index 77b5d975f..6b95ae7d2 100644 --- a/bots/lintTemplate.js +++ b/bots/lintTemplate.js @@ -1,6 +1,6 @@ import * as skills from '../../../src/agent/library/skills.js'; -import * as world from '../../../src/agent/library/world.js'; -import Vec3 from 'vec3'; +import * as _world from '../../../src/agent/library/world.js'; +import _Vec3 from 'vec3'; const log = skills.log; diff --git a/src/agent/action_manager.js b/src/agent/action_manager.js index 9b9d0d279..a4eb6801a 100644 --- a/src/agent/action_manager.js +++ b/src/agent/action_manager.js @@ -1,3 +1,5 @@ +import assert from 'assert'; + export class ActionManager { constructor(agent) { this.agent = agent; @@ -9,6 +11,11 @@ export class ActionManager { this.resume_name = ''; this.last_action_time = 0; this.recent_action_counter = 0; + // Stuck detection: track repeated same-label calls within a time window + this._stuckTracker = {}; // { label: { count, firstSeen } } + // Cross-invocation zero-collect tracker for gathering actions + this._collectFailTracker = {}; // { blockType: { count, lastSeen } } + this._COLLECT_FAIL_THRESHOLD = 3; // after 3 zero-collect results, force intervention } async resumeAction(actionFn, timeout) { @@ -26,7 +33,8 @@ export class ActionManager { async stop() { if (!this.executing) return; const timeout = setTimeout(() => { - this.agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.'); + console.warn('Code execution refused stop after 10 seconds. Force-cancelling action.'); + this.executing = false; }, 10000); while (this.executing) { this.agent.requestInterrupt(); @@ -34,7 +42,7 @@ export class ActionManager { await new Promise(resolve => setTimeout(resolve, 300)); } clearTimeout(timeout); - } + } cancelResume() { this.resume_func = null; @@ -80,6 +88,70 @@ export class ActionManager { } } this.last_action_time = Date.now(); + + // Detect slow repeating action patterns + if (!this._actionHistory) this._actionHistory = []; + this._actionHistory.push(actionLabel); + if (this._actionHistory.length > 12) this._actionHistory.shift(); + if (this._actionHistory.length >= 6) { + // Pattern repeat: exact 3-action sequence repeats + const last3 = this._actionHistory.slice(-3).join(','); + const prev3 = this._actionHistory.slice(-6, -3).join(','); + // Frequency: any single action appears N+ times in the window + const counts = {}; + for (const a of this._actionHistory) counts[a] = (counts[a] || 0) + 1; + const maxCount = Math.max(...Object.values(counts)); + const loopAction = Object.keys(counts).find(a => counts[a] === maxCount); + // RC27: Crafting chains legitimately need 5+ craftRecipe calls + // (logs→planks→sticks→tool = 5+ sequential crafts). Raise threshold + // for crafting actions and exempt non-identical craft sequences. + const isCraftAction = loopAction && loopAction.includes('craftRecipe'); + const freqThreshold = isCraftAction ? 8 : 5; + const isPatternLoop = last3 === prev3; + const isFreqLoop = maxCount >= freqThreshold; + if (isPatternLoop || isFreqLoop) { + const reason = isPatternLoop ? `pattern "${last3}" repeated` : `"${loopAction}" called ${maxCount} times`; + console.warn(`[ActionManager] Slow loop detected: ${reason}. Cancelling resume.`); + this.cancelResume(); + this._actionHistory = []; + return { success: false, message: `Action loop detected (${reason}). Stopping to avoid infinite loop.`, interrupted: true, timedout: false }; + } + } + // ── Stuck detector: same action label ≥3 times within window ────────── + const STUCK_WINDOW_MS = 15000; + const STUCK_WINDOW_LONG_MS = 120000; // longer window for slow actions like collectBlocks + const STUCK_THRESHOLD = 3; + const stuckLabels = ['goToPlayer', 'collectBlocks', 'goToBlock', 'moveAway', 'goToBed', 'goToNearestBlock']; + const slowLabels = ['collectBlocks']; // actions that take a long time + const isStuckable = stuckLabels.some(l => actionLabel.includes(l)); + const isSlow = slowLabels.some(l => actionLabel.includes(l)); + const windowMs = isSlow ? STUCK_WINDOW_LONG_MS : STUCK_WINDOW_MS; + const now = Date.now(); + if (isStuckable) { + const t = this._stuckTracker[actionLabel]; + if (t && (now - t.firstSeen) < windowMs) { + t.count++; + if (t.count >= STUCK_THRESHOLD) { + console.warn(`[ActionManager] Stuck detected: "${actionLabel}" called ${t.count}x in ${Math.round((now - t.firstSeen)/1000)}s`); + this._stuckTracker = {}; + this.cancelResume(); + return { + success: false, + message: `Action output:\nStuck detected — "${actionLabel}" failed ${t.count} times in a row. Switch to a different approach immediately: try !searchForBlock with a range of 128+, !moveAway 50, or !newAction with an alternative strategy. Do NOT repeat the same command.`, + interrupted: true, + timedout: false + }; + } + } else { + // New action type — reset all stuck tracking + this._stuckTracker = { [actionLabel]: { count: 1, firstSeen: now } }; + } + } else { + // Non-stuckable action succeeded — reset tracker + this._stuckTracker = {}; + } + // ──────────────────────────────────────────────────────────────────── + console.log('executing code...\n'); // await current action to finish (executing=false), with 10 seconds timeout @@ -110,12 +182,45 @@ export class ActionManager { this.currentActionFn = null; clearTimeout(TIMEOUT); - // get bot activity summary + // Capture raw output BEFORE truncation for reliable regex matching + const rawOutput = this.agent.bot.output || ''; + + // get bot activity summary (may truncate) let output = this.getBotOutputSummary(); let interrupted = this.agent.bot.interrupt_code; let timedout = this.timedout; this.agent.clearBotLogs(); + // ── Cross-invocation zero-collect detection ────────────────────── + // Use rawOutput for regex matching to avoid truncation issues + if (actionLabel.includes('collectBlocks') && rawOutput) { + const zeroMatch = rawOutput.match(/Collected 0 (\w+)/); + const successMatch = rawOutput.match(/Collected (\d+) (\w+)/); + if (zeroMatch) { + const blockType = zeroMatch[1]; + const tracker = this._collectFailTracker; + if (!tracker[blockType]) tracker[blockType] = { count: 0, lastSeen: 0 }; + tracker[blockType].count++; + tracker[blockType].lastSeen = Date.now(); + if (tracker[blockType].count >= this._COLLECT_FAIL_THRESHOLD) { + const failCount = tracker[blockType].count; + tracker[blockType] = { count: 0, lastSeen: 0 }; // reset + console.warn(`[ActionManager] Gather loop: collected 0 ${blockType} ${failCount} times — forcing explore`); + this.cancelResume(); + // Set flag so agent.js auto-executes !explore(200) bypassing the LLM + this.agent._forceExplore = { distance: 200, blockType }; + output += `\n\nArea depleted: collected 0 ${blockType} ${failCount} times. Auto-exploring 200 blocks to find fresh resources.`; + } + } else if (successMatch && parseInt(successMatch[1]) > 0) { + // Success — reset tracker for this block type + const blockType = successMatch[2]; + if (this._collectFailTracker[blockType]) { + this._collectFailTracker[blockType] = { count: 0, lastSeen: 0 }; + } + } + } + // ──────────────────────────────────────────────────────────────── + // if not interrupted and not generating, emit idle event if (!interrupted) { this.agent.bot.emit('idle'); @@ -131,14 +236,14 @@ export class ActionManager { this.cancelResume(); console.error("Code execution triggered catch:", err); // Log the full stack trace - console.error(err.stack); + const stackTrace = err.stack || ''; + console.error(stackTrace); await this.stop(); - err = err.toString(); let message = this.getBotOutputSummary() + '!!Code threw exception!!\n' + - 'Error: ' + err + '\n' + - 'Stack trace:\n' + err.stack+'\n'; + 'Error: ' + err.toString() + '\n' + + 'Stack trace:\n' + stackTrace + '\n'; let interrupted = this.agent.bot.interrupt_code; this.agent.clearBotLogs(); diff --git a/src/agent/agent.js b/src/agent/agent.js index f5a8e3d52..1b4af7eb7 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -4,7 +4,7 @@ import { VisionInterpreter } from './vision/vision_interpreter.js'; import { Prompter } from '../models/prompter.js'; import { initModes } from './modes.js'; import { initBot } from '../utils/mcdata.js'; -import { containsCommand, commandExists, executeCommand, truncCommandMessage, isAction, blacklistCommands } from './commands/index.js'; +import { containsCommand, commandExists, executeCommand, truncCommandMessage, isAction, blacklistCommands, isCommandBlocked } from './commands/index.js'; import { ActionManager } from './action_manager.js'; import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; @@ -17,6 +17,16 @@ import settings from './settings.js'; import { Task } from './tasks/tasks.js'; import { speak } from './speak.js'; import { log, validateNameFormat, handleDisconnection } from './connection_handler.js'; +import { Learnings } from './learnings.js'; +import { validateMinecraftMessage, validateUsername } from '../utils/message_validator.js'; + +// ── In-game aliases (shorthand → canonical agent name) ────── +const INGAME_ALIASES = { + 'gemini': 'Gemini_1', + 'gi': 'Gemini_1', + 'grok': 'Grok_En', + 'gk': 'Grok_En', +}; export class Agent { async start(load_mem=false, init_message=null, count_id=0) { @@ -44,6 +54,8 @@ export class Agent { this.npc = new NPCContoller(this); this.memory_bank = new MemoryBank(); this.self_prompter = new SelfPrompter(this); + this.learnings = new Learnings(this.name); + this.learnings.load(); convoManager.initAgent(this); await this.prompter.initExamples(); @@ -59,7 +71,7 @@ export class Agent { taskStart = Date.now(); } this.task = new Task(this, settings.task, taskStart); - this.blocked_actions = settings.blocked_actions.concat(this.task.blocked_actions || []); + this.blocked_actions = settings.blocked_actions.concat(this.task.blocked_actions || []).concat(this.prompter.profile.blocked_actions || []); blacklistCommands(this.blocked_actions); console.log(this.name, 'logging into minecraft...'); @@ -73,18 +85,27 @@ export class Agent { // Log and Analyze // handleDisconnection handles logging to console and server const { type } = handleDisconnection(this.name, reason); - - process.exit(1); + + // Clean disconnect so MC server releases the session immediately + try { this.bot.quit(); } catch {} + + // Name conflicts need extra delay — use exit code 88 + process.exit(type === 'name_conflict' ? 88 : 1); }; // Bind events this.bot.once('kicked', (reason) => onDisconnect('Kicked', reason)); this.bot.once('end', (reason) => onDisconnect('Disconnected', reason)); this.bot.on('error', (err) => { - if (String(err).includes('Duplicate') || String(err).includes('ECONNREFUSED')) { + const errStr = String(err); + if (errStr.includes('Duplicate') || errStr.includes('ECONNREFUSED')) { onDisconnect('Error', err); + } else if (errStr.includes('EPIPE') || errStr.includes('ECONNRESET')) { + // Connection broken — log it but let mineflayer's 'end' event + // handle the actual disconnect/restart to avoid false restarts + console.warn(`[${this.name}] Connection error: ${errStr}. Waiting for disconnect event...`); } else { - log(this.name, `[LoginGuard] Connection Error: ${String(err)}`); + log(this.name, `[LoginGuard] Connection Error: ${errStr}`); } }); @@ -139,7 +160,7 @@ export class Agent { } catch (error) { console.error('Error in spawn event:', error); - process.exit(0); + process.exit(1); } }); } @@ -157,34 +178,55 @@ export class Agent { const respondFunc = async (username, message) => { if (message === "") return; if (username === this.name) return; + + // Validate username and message + const userValidation = validateUsername(username); + if (!userValidation.valid) { + console.warn(`[MessageValidator] Rejected message from invalid username: "${username}" (${userValidation.error})`); + return; + } + + const msgValidation = validateMinecraftMessage(message); + if (!msgValidation.valid) { + console.warn(`[MessageValidator] Rejected message: ${msgValidation.error}`); + return; + } + const cleanMessage = msgValidation.sanitized; + if (settings.only_chat_with.length > 0 && !settings.only_chat_with.includes(username)) return; try { - if (ignore_messages.some((m) => message.startsWith(m))) return; + if (ignore_messages.some((m) => cleanMessage.startsWith(m))) return; + + // Ignore bot action status broadcasts from unrecognized bots + // (e.g. "*used goToCoordinates*", "*BotName stopped.*") + if (/^\*.*\*$/.test(cleanMessage.trim())) return; this.shut_up = false; - console.log(this.name, 'received message from', username, ':', message); + console.log(this.name, 'received message from', username, ':', cleanMessage); if (convoManager.isOtherAgent(username)) { - console.warn('received whisper from other bot??') + console.warn('received whisper from other bot??'); } else { - let translation = await handleEnglishTranslation(message); + let translation = await handleEnglishTranslation(cleanMessage); this.handleMessage(username, translation); } } catch (error) { console.error('Error handling message:', error); } - } + }; this.respondFunc = respondFunc; this.bot.on('whisper', respondFunc); - + this.bot.on('chat', (username, message) => { - if (serverProxy.getNumOtherAgents() > 0) return; - // only respond to open chat messages when there are no other agents - respondFunc(username, message); + // Parse prefix/alias to determine if this message targets a specific bot + const parsed = this.parseInGamePrefix(message); + if (parsed.targeted && !parsed.isForMe) return; // targeted at another bot, skip + const msgToProcess = parsed.targeted ? parsed.message : message; + respondFunc(username, msgToProcess); }); // Set up auto-eat @@ -194,11 +236,34 @@ export class Agent { bannedFood: ["rotten_flesh", "spider_eye", "poisonous_potato", "pufferfish", "chicken"] }; + // Log inventory on every load so the bot (and LLM) knows what it has + const inv = this.bot.inventory.items(); + if (inv.length > 0) { + const invStr = inv.map(i => `${i.name}: ${i.count}`).join(', '); + console.log(`[Startup] ${this.name} inventory: ${invStr}`); + this.history.add('system', `Inventory on load: ${invStr}`); + } else { + console.log(`[Startup] ${this.name} inventory is empty.`); + this.history.add('system', 'Inventory on load: empty.'); + } + if (save_data?.self_prompt) { if (init_message) { this.history.add('system', init_message); } await this.self_prompter.handleLoad(save_data.self_prompt, save_data.self_prompting_state); + } else if (this.prompter.profile.self_prompt) { + // Fresh spawn with no saved state — auto-start from profile default goal + if (init_message) { + this.history.add('system', init_message); + } + const defaultGoal = this.prompter.profile.self_prompt; + setTimeout(() => { + if (this.self_prompter.isStopped()) { + console.log(`[AutoGoal] Starting default self-prompt for ${this.name}: "${defaultGoal}"`); + this.self_prompter.start(defaultGoal); + } + }, 3000); } if (save_data?.last_sender) { this.last_sender = save_data.last_sender; @@ -210,7 +275,7 @@ export class Agent { convoManager.receiveFromBot(this.last_sender, msg_package); } } - else if (init_message) { + else if (init_message && !this.self_prompter.isActive()) { await this.handleMessage('system', init_message, 2); } else { @@ -218,6 +283,35 @@ export class Agent { } } + parseInGamePrefix(message) { + const colonIdx = message.indexOf(':'); + if (colonIdx <= 0 || colonIdx >= 30) return { targeted: false, isForMe: true, message }; + + const prefix = message.substring(0, colonIdx).trim().toLowerCase(); + const body = message.substring(colonIdx + 1).trim(); + + // Check if prefix matches this agent's name + if (prefix === this.name.toLowerCase()) { + return { targeted: true, isForMe: true, targetName: this.name, message: body }; + } + + // Check aliases + const aliasTarget = INGAME_ALIASES[prefix]; + if (aliasTarget) { + const isForMe = aliasTarget === this.name; + return { targeted: true, isForMe, targetName: aliasTarget, message: body }; + } + + // Check if prefix matches any other known agent name (case-insensitive) + if (convoManager.isOtherAgent(prefix) || + convoManager.isOtherAgent(prefix.charAt(0).toUpperCase() + prefix.slice(1))) { + return { targeted: true, isForMe: false, targetName: prefix, message: body }; + } + + // Not a recognized prefix — treat as normal message + return { targeted: false, isForMe: true, message }; + } + checkAllPlayersPresent() { if (!this.task || !this.task.agent_names) { return; @@ -234,7 +328,7 @@ export class Agent { this.bot.interrupt_code = true; this.bot.stopDigging(); this.bot.collectBlock.cancelTask(); - this.bot.pathfinder.stop(); + this.bot.ashfinder.stop(); // RC25: baritone replaces pathfinder this.bot.pvp.stop(); } @@ -243,12 +337,12 @@ export class Agent { this.bot.interrupt_code = false; } - shutUp() { + async shutUp() { this.shut_up = true; if (this.self_prompter.isActive()) { this.self_prompter.stop(false); } - convoManager.endAllConversations(); + await convoManager.endAllConversations(); // RC30: properly await async } async handleMessage(source, message, max_responses=null) { @@ -269,6 +363,25 @@ export class Agent { const self_prompt = source === 'system' || source === this.name; const from_other_bot = convoManager.isOtherAgent(source); + // ── Hardcoded stop/freeze: bypasses LLM, always works ── + if (!self_prompt) { + const lower = message.toLowerCase().trim(); + if (lower === 'stop' || lower === 'freeze' || lower === 'stop!' || lower === 'freeze!') { + console.log(`[STOP] ${source} triggered "${lower}" on ${this.name}`); + await this.actions.stop(); + this.actions.cancelResume(); // prevent idle event from restarting previous action + if (this.self_prompter.isActive()) this.self_prompter.stop(false); + this.routeResponse(source, `*${this.name} stopped.*`); // send confirmation before shut_up + this.shut_up = true; + return true; + } + } + + // Human player messages take absolute priority — interrupt any ongoing action immediately + if (!self_prompt && !from_other_bot && !this.isIdle()) { + this.requestInterrupt(); + } + if (!self_prompt && !from_other_bot) { // from user, check for forced commands const user_command_name = containsCommand(message); if (user_command_name) { @@ -322,7 +435,7 @@ export class Agent { console.log(`${this.name} full response to ${source}: ""${res}""`); if (res.trim().length === 0) { - console.warn('no response') + console.warn('no response'); break; // empty response ends loop } @@ -333,9 +446,27 @@ export class Agent { this.history.add(this.name, res); if (!commandExists(command_name)) { - this.history.add('system', `Command ${command_name} does not exist.`); - console.warn('Agent hallucinated command:', command_name) - continue; + // RC27: Distinguish blocked commands from truly unknown ones + if (isCommandBlocked(command_name)) { + // RC29→RC31: Auto-redirect blocked commands to !dragonProgression + // This chains all 6 chunks (diamond pickaxe → nether portal → blaze rods → etc.) + const craftRedirectCmds = ['!craftRecipe', '!collectBlocks', '!searchForBlock', '!getCraftingPlan', '!newAction']; + if (craftRedirectCmds.includes(command_name) && commandExists('!dragonProgression')) { + console.log(`[RC31] Redirecting blocked ${command_name} → !dragonProgression`); + this.history.add('system', `${command_name} is blocked. Running !dragonProgression instead (it handles ALL progression automatically).`); + res = '!dragonProgression'; + command_name = '!dragonProgression'; + // Fall through to normal command execution below + } else { + this.history.add('system', `Command ${command_name} is disabled in your profile's blocked_actions.`); + console.log(`[RC27] Agent used blocked command: ${command_name}`); + continue; + } + } else { + this.history.add('system', `Command ${command_name} does not exist.`); + console.warn('Agent hallucinated command:', command_name); + continue; + } } if (checkInterrupt()) break; @@ -364,10 +495,35 @@ export class Agent { console.log('Agent executed:', command_name, 'and got:', execute_res); used_command = true; + if (this.learnings && command_name) { + const outcome = (execute_res && !execute_res.includes('Error') && !execute_res.includes('failed')) + ? 'success' : 'fail'; + this.learnings.record(command_name, res.substring(0, 100), outcome); + } + if (execute_res) this.history.add('system', execute_res); else break; + + // Auto-explore: if action_manager detected repeated collect failures, + // bypass the LLM and directly execute !explore(200) to relocate + if (this._forceExplore) { + const { distance, blockType } = this._forceExplore; + this._forceExplore = null; + console.log(`[AutoExplore] Forcing explore(${distance}) after repeated ${blockType} collect failures`); + const exploreRes = await executeCommand(this, `!explore(${distance})`); + if (exploreRes) { + this.history.add('system', exploreRes); + } + // Inject a hard directive so small models don't loop back to !collectBlocks + this.history.add('system', + `[ANTI-LOOP] You have failed to collect ${blockType} repeatedly. ` + + `Do NOT call !collectBlocks, !searchForBlock, or any gather command for ${blockType} again right now. ` + + `If you need tools, call !getDiamondPickaxe — it handles all wood/stone/iron internally with automatic relocation. ` + + `If you are mid-speedrun, call !beatMinecraft to resume the full chain.` + ); + } } else { // conversation response this.history.add(this.name, res); @@ -452,9 +608,7 @@ export class Agent { prev_health = this.bot.health; }); // Logging callbacks - this.bot.on('error' , (err) => { - console.error('Error event!', err); - }); + // Note: 'error' is already handled by initBot() login guard — no duplicate needed // Use connection handler for runtime disconnects this.bot.on('end', (reason) => { if (!this._disconnectHandled) { @@ -465,6 +619,7 @@ export class Agent { this.bot.on('death', () => { this.actions.cancelResume(); this.actions.stop(); + this.bot.respawnTime = Date.now(); }); this.bot.on('kicked', (reason) => { if (!this._disconnectHandled) { @@ -479,7 +634,7 @@ export class Agent { this.memory_bank.rememberPlace('last_death_position', death_pos.x, death_pos.y, death_pos.z); let death_pos_text = null; if (death_pos) { - death_pos_text = `x: ${death_pos.x.toFixed(2)}, y: ${death_pos.y.toFixed(2)}, z: ${death_pos.x.toFixed(2)}`; + death_pos_text = `x: ${death_pos.x.toFixed(2)}, y: ${death_pos.y.toFixed(2)}, z: ${death_pos.z.toFixed(2)}`; } let dimention = this.bot.game.dimension; this.handleMessage('system', `You died at position ${death_pos_text || "unknown"} in the ${dimention} dimension with the final message: '${message}'. Your place of death is saved as 'last_death_position' if you want to return. Previous actions were stopped and you have respawned.`); @@ -487,7 +642,7 @@ export class Agent { }); this.bot.on('idle', () => { this.bot.clearControlStates(); - this.bot.pathfinder.stop(); // clear any lingering pathfinder + this.bot.ashfinder.stop(); // RC25: clear any lingering baritone navigation this.bot.modes.unPauseAll(); setTimeout(() => { if (this.isIdle()) { @@ -530,8 +685,16 @@ export class Agent { cleanKill(msg='Killing agent process...', code=1) { this.history.add('system', msg); - this.bot.chat(code > 1 ? 'Restarting.': 'Exiting.'); + try { this.bot.chat(code > 1 ? 'Restarting.': 'Exiting.'); } catch {} this.history.save(); + if (this.learnings) { + this.learnings.save(); + } + if (this.prompter?.usageTracker) { + this.prompter.usageTracker.saveSync(); + this.prompter.usageTracker.destroy(); + } + try { this.bot.quit(); } catch {} process.exit(code); } async checkTaskDone() { @@ -550,4 +713,4 @@ export class Agent { killAll() { serverProxy.shutdown(); } -} \ No newline at end of file +} diff --git a/src/agent/coder.js b/src/agent/coder.js index 18a5f2618..fb072a53e 100644 --- a/src/agent/coder.js +++ b/src/agent/coder.js @@ -1,26 +1,19 @@ -import { writeFile, readFile, mkdirSync } from 'fs'; +import { writeFile, readFileSync, mkdirSync } from 'fs'; import { makeCompartment, lockdown } from './library/lockdown.js'; import * as skills from './library/skills.js'; import * as world from './library/world.js'; import { Vec3 } from 'vec3'; import {ESLint} from "eslint"; +import settings from './settings.js'; export class Coder { constructor(agent) { this.agent = agent; this.file_counter = 0; this.fp = '/bots/'+agent.name+'/action-code/'; - this.code_template = ''; - this.code_lint_template = ''; - readFile('./bots/execTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_template = data; - }); - readFile('./bots/lintTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_lint_template = data; - }); + this.code_template = readFileSync('./bots/execTemplate.js', 'utf8'); + this.code_lint_template = readFileSync('./bots/lintTemplate.js', 'utf8'); mkdirSync('.' + this.fp, { recursive: true }); } @@ -82,7 +75,18 @@ export class Coder { try { console.log('Executing code...'); - await executionModule.main(this.agent.bot); + const timeout_ms = settings.code_timeout_mins > 0 ? settings.code_timeout_mins * 60 * 1000 : null; + if (timeout_ms) { + await Promise.race([ + executionModule.main(this.agent.bot), + new Promise((_, reject) => setTimeout( + () => reject(new Error(`Code execution timed out after ${settings.code_timeout_mins} minutes.`)), + timeout_ms + )) + ]); + } else { + await executionModule.main(this.agent.bot); + } const code_output = this.agent.actions.getBotOutputSummary(); const summary = "Agent wrote this code: \n```" + this._sanitizeCode(code) + "```\nCode Output:\n" + code_output; @@ -120,12 +124,12 @@ export class Coder { } const allDocs = await this.agent.prompter.skill_libary.getAllSkillDocs(); // check function exists - const missingSkills = skills.filter(skill => !!allDocs[skill]); + const missingSkills = skills.filter(skill => !allDocs[skill]); if (missingSkills.length > 0) { result += 'These functions do not exist.\n'; result += '### FUNCTIONS NOT FOUND ###\n'; result += missingSkills.join('\n'); - console.log(result) + console.log(result); return result; } @@ -183,16 +187,20 @@ export class Coder { // This is where we determine the environment the agent's code should be exposed to. // It will only have access to these things, (in addition to basic javascript objects like Array, Object, etc.) // Note that the code may be able to modify the exposed objects. + // Freeze exposed module objects so Compartment code cannot replace + // methods on them (e.g. skills.collectBlocks = maliciousFn). + // Spread into plain objects first before freezing so ESLint's + // no-import-assign rule doesn't flag the namespace import references. const compartment = makeCompartment({ - skills, + skills: Object.freeze({ ...skills }), log: skills.log, - world, + world: Object.freeze({ ...world }), Vec3, }); const mainFn = compartment.evaluate(src); if (write_result) { - console.error('Error writing code execution file: ' + result); + console.error('Error writing code execution file: ' + write_result); return null; } return { func:{main: mainFn}, src_lint_copy: src_lint_copy }; @@ -200,7 +208,7 @@ export class Coder { _sanitizeCode(code) { code = code.trim(); - const remove_strs = ['Javascript', 'javascript', 'js'] + const remove_strs = ['Javascript', 'javascript', 'js']; for (let r of remove_strs) { if (code.startsWith(r)) { code = code.slice(r.length); diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index f348487ed..6157dfe7a 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.', @@ -247,9 +256,9 @@ export const actionsList = [ }, perform: runAsAction(async (agent, item_name, num) => { const start_loc = agent.bot.entity.position; - await skills.moveAway(agent.bot, 5); + try { await skills.moveAway(agent.bot, 5); } catch (_) { /* navigation may fail, that's ok */ } await skills.discard(agent.bot, item_name, num); - await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0); + try { await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0); } catch (_) { /* ok */ } }) }, { @@ -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/commands/index.js b/src/agent/commands/index.js index 7ada04088..97d6c8e77 100644 --- a/src/agent/commands/index.js +++ b/src/agent/commands/index.js @@ -10,10 +10,19 @@ for (let command of commandList) { commandMap[command.name] = command; } +// RC27: Track blocked commands separately so we can distinguish +// "blocked by profile" from "truly hallucinated/unknown" +const blockedCommands = new Set(); + export function getCommand(name) { return commandMap[name]; } +export function isCommandBlocked(name) { + if (!name.startsWith('!')) name = '!' + name; + return blockedCommands.has(name); +} + export function blacklistCommands(commands) { const unblockable = ['!stop', '!stats', '!inventory', '!goal']; for (let command_name of commands) { @@ -21,12 +30,17 @@ export function blacklistCommands(commands) { console.warn(`Command ${command_name} is unblockable`); continue; } + blockedCommands.add(command_name); delete commandMap[command_name]; - delete commandList.find(command => command.name === command_name); + // Fix: findIndex + splice to actually remove from the array. + // The previous `delete commandList.find(...)` called delete on the + // returned object reference, which does nothing to the array. + const idx = commandList.findIndex(cmd => cmd.name === command_name); + if (idx !== -1) commandList.splice(idx, 1); } } -const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/ +const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/; const argRegex = /-?\d+(?:\.\d+)?|true|false|"[^"]*"/g; export function containsCommand(message) { @@ -81,7 +95,7 @@ function checkInInterval(number, lowerBound, upperBound, endpointType) { case '[]': return lowerBound <= number && number <= upperBound; default: - throw new Error('Unknown endpoint type:', endpointType) + throw new Error('Unknown endpoint type:', endpointType); } } @@ -105,7 +119,7 @@ export function parseCommandMessage(message) { else args = []; const command = getCommand(commandName); - if(!command) return `${commandName} is not a command.` + if(!command) return `${commandName} is not a command.`; const params = commandParams(command); const paramNames = commandParamNames(command); @@ -135,13 +149,14 @@ export function parseCommandMessage(message) { case 'ItemName': if (arg.endsWith('plank') || arg.endsWith('seed')) arg += 's'; // add 's' to for common mistakes like "oak_plank" or "wheat_seed" + // falls through case 'string': break; default: throw new Error(`Command '${commandName}' parameter '${paramNames[i]}' has an unknown type: ${param.type}`); } if(arg === null || Number.isNaN(arg)) - return `Error: Param '${paramNames[i]}' must be of type ${param.type}.` + return `Error: Param '${paramNames[i]}' must be of type ${param.type}.`; if(typeof arg === 'number') { //Check the domain of numbers const domain = param.domain; @@ -157,15 +172,15 @@ export function parseCommandMessage(message) { //Alternatively arg could be set to the nearest value in the domain. } } else if (!suppressNoDomainWarning) { - console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`) + console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`); suppressNoDomainWarning = true; //Don't spam console. Only give the warning once. } } else if(param.type === 'BlockName') { //Check that there is a block with this name - if(getBlockId(arg) == null) return `Invalid block type: ${arg}.` + if(getBlockId(arg) == null) return `Invalid block type: ${arg}.`; } else if(param.type === 'ItemName') { //Check that there is an item with this name - if(getItemId(arg) == null) return `Invalid item type: ${arg}.` + if(getItemId(arg) == null) return `Invalid item type: ${arg}.`; } else if(param.type === 'BlockOrItemName') { - if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.` + if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.`; } args[i] = arg; } @@ -239,7 +254,7 @@ export function getCommandDocs(agent) { 'ItemName': 'string', 'BlockOrItemName': 'string', 'boolean': 'bool' - } + }; let docs = `\n*COMMAND DOCS\n You can use the following commands to perform actions and get information about the world. Use the commands with the syntax: !commandName or !commandName("arg1", 1.2, ...) if the command takes arguments.\n Do not use codeblocks. Use double quotes for strings. Only use one command in each response, trailing commands and comments will be ignored.\n`; diff --git a/src/agent/commands/queries.js b/src/agent/commands/queries.js index ad5b701ee..d9d0c3178 100644 --- a/src/agent/commands/queries.js +++ b/src/agent/commands/queries.js @@ -7,7 +7,7 @@ import { load } from 'cheerio'; const pad = (str) => { return '\n' + str + '\n'; -} +}; // queries are commands that just return strings and don't affect anything in the world export const queryList = [ @@ -49,7 +49,7 @@ export const queryList = [ let action = agent.actions.currentActionLabel; if (agent.isIdle()) action = 'Idle'; - res += `\- Current Action: ${action}`; + res += `- Current Action: ${action}`; let players = world.getNearbyPlayerNames(bot); @@ -315,7 +315,7 @@ export const queryList = [ 'query': { type: 'string', description: 'The query to search for.' } }, perform: async function (agent, query) { - const url = `https://minecraft.wiki/w/${query}` + const url = `https://minecraft.wiki/w/${query}`; try { const response = await fetch(url); if (response.status === 404) { @@ -333,7 +333,7 @@ export const queryList = [ return divContent.trim(); } catch (error) { console.error("Error fetching or parsing HTML:", error); - return `The following error occurred: ${error}` + return `The following error occurred: ${error}`; } } }, diff --git a/src/agent/connection_handler.js b/src/agent/connection_handler.js index 31a14e1ec..f68fb1343 100644 --- a/src/agent/connection_handler.js +++ b/src/agent/connection_handler.js @@ -43,7 +43,7 @@ const ERROR_DEFINITIONS = { export const log = (agentName, msg) => { // Use console.error for visibility in terminal console.error(msg); - try { sendOutputToServer(agentName || 'system', msg); } catch (_) {} + try { sendOutputToServer(agentName || 'system', msg); } catch {} }; // Analyzes the kick reason and returns a full, human-readable sentence. @@ -64,7 +64,7 @@ export function parseKickReason(reason) { try { const obj = typeof reason === 'string' ? JSON.parse(reason) : reason; fallback = obj.translate || obj.text || (obj.value?.translate) || raw; - } catch (_) {} + } catch {} return { type: 'other', msg: `Disconnected: ${fallback}`, isFatal: true }; } diff --git a/src/agent/conversation.js b/src/agent/conversation.js index 1cd781e87..2ec1d8651 100644 --- a/src/agent/conversation.js +++ b/src/agent/conversation.js @@ -191,7 +191,7 @@ class ConversationManager { await agent.self_prompter.pause(); } - _scheduleProcessInMessage(sender, received, convo); + await _scheduleProcessInMessage(sender, received, convo); } responseScheduledFor(sender) { @@ -224,25 +224,25 @@ class ConversationManager { return Object.values(this.convos).some(c => c.active); } - endConversation(sender) { + async endConversation(sender) { if (this.convos[sender]) { this.convos[sender].end(); if (this.activeConversation.name === sender) { this._stopMonitor(); this.activeConversation = null; if (agent.self_prompter.isPaused() && !this.inConversation()) { - _resumeSelfPrompter(); + await _resumeSelfPrompter(); } } } } - endAllConversations() { + async endAllConversations() { for (const sender in this.convos) { - this.endConversation(sender); + await this.endConversation(sender); // RC30: await async endConversation } if (agent.self_prompter.isPaused()) { - _resumeSelfPrompter(); + await _resumeSelfPrompter(); } } @@ -281,7 +281,7 @@ async function _scheduleProcessInMessage(sender, received, convo) { // both are busy let canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)); if (canTalkOver) - scheduleResponse(fastDelay) + scheduleResponse(fastDelay); // otherwise don't respond } else if (otherAgentBusy) diff --git a/src/agent/history.js b/src/agent/history.js index 04a72f76d..56c713612 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,7 +1,34 @@ -import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; -import { NPCData } from './npc/data.js'; +import { readFileSync, mkdirSync, existsSync, writeFile, renameSync, unlinkSync } from 'fs'; +import { promisify } from 'util'; + import settings from './settings.js'; +const writeFileAsync = promisify(writeFile); + +// RC27: Atomic write — write to .tmp file then rename, preventing corruption on crash +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await writeFileAsync(tmpPath, content, 'utf8'); + // Atomic rename (overwrites destination on most OSes) + try { renameSync(tmpPath, filepath); } catch (renameErr) { + // Windows may fail rename if destination is locked; fall back to direct write + console.warn(`[RC27] Atomic rename failed for ${filepath}, falling back to direct write:`, renameErr.message); + await writeFileAsync(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch(_e) {} + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + export class History { constructor(agent) { @@ -34,9 +61,28 @@ export class History { console.log("Storing memories..."); this.memory = await this.agent.prompter.promptMemSaving(turns); - if (this.memory.length > 500) { - this.memory = this.memory.slice(0, 500); - this.memory += '...(Memory truncated to 500 chars. Compress it more next time)'; + // ── Memory sanitization: strip false "broken" beliefs ────────── + // These are residual beliefs from past bugs that are no longer true. + // The gathering system works correctly — the bots just need to move. + const toxicPatterns = [ + /\b(?:block |my )?gathering(?:\/\w+)? (?:is |remains? |still )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b[^.;]*/gi, + /\bcollect(?:Blocks?|ion)? (?:is |command )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b/gi, + /\b(?:waiting|await(?:ing)?) (?:for )?(?:the |an )?(?:update|fix|patch)\b[^.;]*/gi, + /\bneed(?:s)? (?:an? )?(?:fix|update|patch) (?:for|to (?:fix|repair)) (?:\w+ )*(?:gathering|collect(?:ion|Blocks?)|core mechanics)\b/gi, + /\bcannot gather (?:any )?resources\b/gi, + /\bgathering(?:\/\w+)? (?:commands? )?(?:are |is )?(?:still )?(?:broken|non-?functional) for both\b[^.;]*/gi, + /\bcore (?:mechanics|systems?) (?:are |is )?(?:broken|non-?functional|bugged)\b[^.;]*/gi, + /\bcrafted items don'?t persist\b/gi, + /CRITICAL:[^.;]*(?:non-?functional|broken|bugged)[^.;]*/gi, + ]; + for (const pattern of toxicPatterns) { + this.memory = this.memory.replace(pattern, 'gathering works — relocate to find blocks'); + } + // ────────────────────────────────────────────────────────────────── + + if (this.memory.length > 800) { + this.memory = this.memory.slice(0, 800); + this.memory += '...(Memory truncated to 800 chars. Compress it more next time)'; } console.log("Memory updated to: ", this.memory); @@ -46,13 +92,13 @@ export class History { if (this.full_history_fp === undefined) { const string_timestamp = new Date().toLocaleString().replace(/[/:]/g, '-').replace(/ /g, '').replace(/,/g, '_'); this.full_history_fp = `./bots/${this.name}/histories/${string_timestamp}.json`; - writeFileSync(this.full_history_fp, '[]', 'utf8'); + await safeWriteFile(this.full_history_fp, '[]'); } try { const data = readFileSync(this.full_history_fp, 'utf8'); let full_history = JSON.parse(data); full_history.push(...to_store); - writeFileSync(this.full_history_fp, JSON.stringify(full_history, null, 4), 'utf8'); + await safeWriteFile(this.full_history_fp, JSON.stringify(full_history, null, 4)); } catch (err) { console.error(`Error reading ${this.name}'s full history file: ${err.message}`); } @@ -76,6 +122,14 @@ export class History { await this.summarizeMemories(chunk); await this.appendFullHistory(chunk); + + // Prevent context-collapse on small/local models: after summarising, + // keep at most 15 recent turns so the rolling window stays tight. + const MAX_TURNS_POST_SUMMARY = 15; + if (this.turns.length > MAX_TURNS_POST_SUMMARY) { + const overflow = this.turns.splice(0, this.turns.length - MAX_TURNS_POST_SUMMARY); + await this.appendFullHistory(overflow); + } } } @@ -89,11 +143,11 @@ export class History { taskStart: this.agent.task.taskStartTime, last_sender: this.agent.last_sender }; - writeFileSync(this.memory_fp, JSON.stringify(data, null, 2)); + await safeWriteFile(this.memory_fp, JSON.stringify(data, null, 2)); console.log('Saved memory to:', this.memory_fp); + if (this.agent.learnings?._dirty) await this.agent.learnings.save(); } catch (error) { console.error('Failed to save history:', error); - throw error; } } @@ -103,14 +157,52 @@ export class History { console.log('No memory file found.'); return null; } - const data = JSON.parse(readFileSync(this.memory_fp, 'utf8')); + const raw = readFileSync(this.memory_fp, 'utf8'); + // RC27: Guard against corrupted/empty memory files + if (!raw || !raw.trim()) { + console.warn(`[RC27] Memory file ${this.memory_fp} is empty, starting fresh.`); + return null; + } + let data; + try { + data = JSON.parse(raw); + } catch (parseErr) { + console.error(`[RC27] Corrupted memory file ${this.memory_fp}: ${parseErr.message}. Starting fresh.`); + // Rename corrupted file so it's not lost but won't block startup + const backupPath = this.memory_fp + '.corrupted.' + Date.now(); + try { renameSync(this.memory_fp, backupPath); } catch(_e) {} + return null; + } this.memory = data.memory || ''; + + // ── Sanitize stale false beliefs on load ────────────────────── + const toxicPatterns = [ + /\b(?:block |my )?gathering(?:\/\w+)? (?:is |remains? |still )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b[^.;]*/gi, + /\bcollect(?:Blocks?|ion)? (?:is |command )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b/gi, + /\b(?:waiting|await(?:ing)?) (?:for )?(?:the |an )?(?:update|fix|patch)\b[^.;]*/gi, + /\bneed(?:s)? (?:an? )?(?:fix|update|patch) (?:for|to (?:fix|repair)) (?:\w+ )*(?:gathering|collect(?:ion|Blocks?)|core mechanics)\b/gi, + /\bcannot gather (?:any )?resources\b/gi, + /\bgathering(?:\/\w+)? (?:commands? )?(?:are |is )?(?:still )?(?:broken|non-?functional) for both\b[^.;]*/gi, + /\bcore (?:mechanics|systems?) (?:are |is )?(?:broken|non-?functional|bugged)\b[^.;]*/gi, + /\bcrafted items don'?t persist\b/gi, + /CRITICAL:[^.;]*(?:non-?functional|broken|bugged)[^.;]*/gi, + ]; + const origLen = this.memory.length; + for (const pattern of toxicPatterns) { + this.memory = this.memory.replace(pattern, 'gathering works — relocate to find blocks'); + } + if (this.memory.length !== origLen) { + console.log('[Memory] Sanitized stale false beliefs from loaded memory'); + } + // ────────────────────────────────────────────────────────────── + this.turns = data.turns || []; console.log('Loaded memory:', this.memory); return data; } catch (error) { console.error('Failed to load history:', error); - throw error; + // RC27: Don't re-throw — return null to start fresh instead of crash-looping + return null; } } diff --git a/src/agent/learnings.js b/src/agent/learnings.js new file mode 100644 index 000000000..5cf55a349 --- /dev/null +++ b/src/agent/learnings.js @@ -0,0 +1,98 @@ +import { readFileSync, mkdirSync, existsSync, promises as fs, renameSync, unlinkSync } from 'fs'; +import path from 'path'; + +const MAX_ENTRIES = 100; + +// RC27: Atomic write — write to .tmp file then rename, preventing corruption on crash +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await fs.writeFile(tmpPath, content, 'utf8'); + try { renameSync(tmpPath, filepath); } catch (renameErr) { + console.warn(`[RC27] Atomic rename failed for ${filepath}, falling back:`, renameErr.message); + await fs.writeFile(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch(_e) {} + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + +export class Learnings { + constructor(agentName) { + this.agentName = agentName; + this.filePath = `./bots/${agentName}/learnings.json`; + this.entries = []; + this._dirty = false; + } + + load() { + try { + if (!existsSync(this.filePath)) return; + const raw = readFileSync(this.filePath, 'utf8'); + // RC27: Guard against empty/corrupted learnings file + if (!raw || !raw.trim()) { + console.warn(`[RC27] Learnings file ${this.filePath} is empty, starting fresh.`); + this.entries = []; + return; + } + this.entries = JSON.parse(raw); + if (!Array.isArray(this.entries)) this.entries = []; + console.log(`[Learnings] Loaded ${this.entries.length} entries for ${this.agentName}`); + } catch (err) { + console.error(`[Learnings] Failed to load for ${this.agentName}:`, err.message); + this.entries = []; + } + } + + async save() { + if (!this._dirty) return; + try { + const dir = path.dirname(this.filePath); + mkdirSync(dir, { recursive: true }); + await safeWriteFile(this.filePath, JSON.stringify(this.entries, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[Learnings] Save failed for ${this.agentName}:`, err.message); + } + } + + record(command, context, outcome) { + this.entries.push({ + command, + context: context.substring(0, 100), + outcome, // 'success' or 'fail' + timestamp: new Date().toISOString(), + }); + + // Prune oldest if over limit + if (this.entries.length > MAX_ENTRIES) { + this.entries = this.entries.slice(-MAX_ENTRIES); + } + + this._dirty = true; + } + + getRecentSummary(count = 10) { + const recent = this.entries.slice(-count); + if (recent.length === 0) return ''; + return recent.map(e => { + const icon = e.outcome === 'success' ? '+' : '-'; + return `[${icon}] ${e.command}: ${e.context}`; + }).join('\n'); + } + + getStats() { + const total = this.entries.length; + const successes = this.entries.filter(e => e.outcome === 'success').length; + const failures = total - successes; + return { total, successes, failures }; + } +} diff --git a/src/agent/library/full_state.js b/src/agent/library/full_state.js index 45a1fbe22..0314f5a57 100644 --- a/src/agent/library/full_state.js +++ b/src/agent/library/full_state.js @@ -55,7 +55,12 @@ export function getFullState(agent) { }, action: { current: agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel, - isIdle: agent.isIdle() + isIdle: agent.isIdle(), + resumeName: agent.actions.resume_name || null + }, + selfPrompter: { + prompt: agent.self_prompter.prompt || '', + state: agent.self_prompter.state }, surroundings: { below, diff --git a/src/agent/library/index.js b/src/agent/library/index.js index ae864b035..3f9540350 100644 --- a/src/agent/library/index.js +++ b/src/agent/library/index.js @@ -1,5 +1,6 @@ import * as skills from './skills.js'; import * as world from './world.js'; +import * as dragonRunner from './dragon_runner.js'; export function docHelper(functions, module_name) { @@ -19,5 +20,6 @@ export function getSkillDocs() { let docArray = []; docArray = docArray.concat(docHelper(Object.values(skills), 'skills')); docArray = docArray.concat(docHelper(Object.values(world), 'world')); + docArray = docArray.concat(docHelper(Object.values(dragonRunner), 'dragonRunner')); return docArray; } diff --git a/src/agent/library/lockdown.js b/src/agent/library/lockdown.js index 2db7e3f0c..ecf27673c 100644 --- a/src/agent/library/lockdown.js +++ b/src/agent/library/lockdown.js @@ -15,8 +15,11 @@ export function lockdown() { consoleTaming: 'unsafe', errorTaming: 'unsafe', stackFiltering: 'verbose', - // allow eval outside of created compartments - // (mineflayer dep "protodef" uses eval) + // NOTE: 'unsafeEval' is required for compatibility with mineflayer's + // 'protodef' dependency which uses eval internally. Switching to + // 'safeEval' or 'noEval' breaks mineflayer. AI-generated code still runs + // inside a sandboxed Compartment (see makeCompartment below), so the + // outer eval exposure is limited to trusted application dependencies only. evalTaming: 'unsafeEval', }); } @@ -29,4 +32,4 @@ export const makeCompartment = (endowments = {}) => { // standard endowments ...endowments }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/agent/library/skill_library.js b/src/agent/library/skill_library.js index 4470586f1..e5777f950 100644 --- a/src/agent/library/skill_library.js +++ b/src/agent/library/skill_library.js @@ -8,7 +8,7 @@ export class SkillLibrary { this.embedding_model = embedding_model; this.skill_docs_embeddings = {}; this.skill_docs = null; - this.always_show_skills = ['skills.placeBlock', 'skills.wait', 'skills.breakBlockAt'] + this.always_show_skills = ['skills.placeBlock', 'skills.wait', 'skills.breakBlockAt']; } async initSkillLibrary() { const skillDocs = getSkillDocs(); @@ -23,7 +23,7 @@ export class SkillLibrary { }); await Promise.all(embeddingPromises); } catch (error) { - console.warn('Error with embedding model, using word-overlap instead.'); + console.warn('Error with embedding model, using word-overlap instead.', error?.message || error); this.embedding_model = null; } } diff --git a/src/agent/library/skills.js b/src/agent/library/skills.js index 715455073..377a7af59 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; } } @@ -91,6 +99,21 @@ export async function craftRecipe(bot, itemName, num=1) { if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { await goToNearestBlock(bot, 'crafting_table', 4, craftingTableRange); + // If still can't reach (e.g., table is above/below in unreachable spot), place one from inventory + if (bot.entity.position.distanceTo(craftingTable.position) > 4) { + let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; + if (hasTable) { + let pos = world.getNearestFreeSpace(bot, 1, 6); + if (pos) { + await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z); + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); + if (craftingTable) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable); + placedTable = true; + } + } + } + } } const recipe = recipes[0]; @@ -100,7 +123,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 +253,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 +322,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 +331,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 +372,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 +428,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 +457,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 +489,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 +504,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 +526,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 +560,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 +592,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. Call !getDiamondPickaxe — it handles relocation and tool progression automatically.`); + break; + } continue; } } @@ -524,7 +754,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}. No ${blockType} found in this area. Call !getDiamondPickaxe — it handles wood collection and tool progression automatically with built-in relocation. Do NOT call !explore or !collectBlocks manually.`); + } else { + log(bot, `Collected ${collected} ${blockType}.`); + } return collected > 0; } @@ -536,25 +773,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; + } + // 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; + } } - pickedUp++; + nearestItem = getNearestItem(bot); } log(bot, `Picked up ${pickedUp} items.`); - return true; + return pickedUp > 0; } @@ -582,16 +830,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 +973,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 +1005,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 +1027,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 +1255,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 +1314,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 +1425,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 +1437,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 +1465,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 +1491,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 +1533,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 +1614,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 +1685,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 +1710,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 +1760,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 +1776,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 +1792,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 +1806,168 @@ 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) { + // RC29b: Terrain escape — when consecutive hops fail (ocean/cliff blocking), + // try going to the surface first then attempt an uphill path before giving up. + log(bot, `Exploration stuck after ${Math.round(totalMoved)} blocks. Trying terrain escape (surface + uphill)...`); + try { + // Step 1: Climb to surface to clear water/cliff terrain + await goToSurface(bot); + // Step 2: Try a new completely random direction from surface + const escapeAngle = Math.random() * 2 * Math.PI; + const ex = Math.floor(bot.entity.position.x + hopDist * Math.cos(escapeAngle)); + const ez = Math.floor(bot.entity.position.z + hopDist * Math.sin(escapeAngle)); + const surfY2 = Math.floor(bot.entity.position.y); + await goToPosition(bot, ex, surfY2, ez, 3); + totalMoved += currentPos.distanceTo(bot.entity.position); + consecutiveFails = 0; + angle = escapeAngle; // continue in the escape direction + } catch (_escape) { + log(bot, `Terrain escape failed. Explored ${Math.round(totalMoved)} blocks total.`); + break; + } + continue; + } + // Try perpendicular direction on first 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 +1982,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 +1997,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 +2050,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 +2111,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 +2178,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 +2202,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 +2247,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 +2302,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); @@ -1903,9 +2486,182 @@ function stringifyItem(bot, item) { return text; } +// RC30: Pillar up out of a shaft by placing blocks below the bot +export async function pillarUp(bot, distance = 10) { + /** + * Pillar up by placing blocks below the bot. Uses cobblestone, dirt, or netherrack. + * Breaks blocks above the bot's head before jumping. + * @param {MinecraftBot} bot + * @param {number} distance - number of blocks to go up + * @returns {Promise} true if successfully pillared all the way up + */ + const placeableBlocks = ['cobblestone', 'dirt', 'netherrack', 'stone', 'granite', 'diorite', 'andesite', 'deepslate', 'cobbled_deepslate']; + + for (let i = 0; i < distance; i++) { + // Find a suitable block in inventory to place + const inv = world.getInventoryCounts(bot); + const blockToPlace = placeableBlocks.find(b => (inv[b] ?? 0) > 0); + if (!blockToPlace) { + log(bot, `Ran out of blocks to place after pillaring up ${i} blocks.`); + return i > 0; + } + + // Break blocks above the bot's head (need 2 blocks of air above to jump) + const headPos = bot.entity.position.offset(0, 2, 0); + const headBlock = bot.blockAt(new Vec3(Math.floor(headPos.x), Math.floor(headPos.y), Math.floor(headPos.z))); + if (headBlock && headBlock.name !== 'air' && headBlock.name !== 'cave_air') { + const dug = await breakBlockAt(bot, headBlock.position.x, headBlock.position.y, headBlock.position.z); + if (!dug) { + log(bot, `Cannot break block above head at y=${headBlock.position.y}. Stopped after ${i} blocks.`); + return i > 0; + } + } + // Also check one more above for safety + const aboveHead = bot.blockAt(new Vec3(Math.floor(headPos.x), Math.floor(headPos.y) + 1, Math.floor(headPos.z))); + if (aboveHead && aboveHead.name !== 'air' && aboveHead.name !== 'cave_air') { + await breakBlockAt(bot, aboveHead.position.x, aboveHead.position.y, aboveHead.position.z); + } + + // Jump and place block below + await bot.equip(bot.registry.itemsByName[blockToPlace].id, 'hand'); + bot.setControlState('jump', true); + await new Promise(r => setTimeout(r, 350)); // wait to be at top of jump + bot.setControlState('jump', false); + + // Place block at the position below the bot's feet + const feetPos = bot.entity.position; + const belowBlock = bot.blockAt(new Vec3(Math.floor(feetPos.x), Math.floor(feetPos.y) - 1, Math.floor(feetPos.z))); + if (belowBlock && (belowBlock.name === 'air' || belowBlock.name === 'cave_air')) { + try { + // Find an adjacent solid face to place against + const neighbors = [ + belowBlock.position.offset(0, -1, 0), + belowBlock.position.offset(1, 0, 0), + belowBlock.position.offset(-1, 0, 0), + belowBlock.position.offset(0, 0, 1), + belowBlock.position.offset(0, 0, -1), + ]; + let placed = false; + for (const nPos of neighbors) { + const nBlock = bot.blockAt(nPos); + if (nBlock && nBlock.name !== 'air' && nBlock.name !== 'cave_air' && nBlock.name !== 'water' && nBlock.name !== 'lava') { + const face = belowBlock.position.minus(nPos); + await bot.placeBlock(nBlock, face); + placed = true; + break; + } + } + if (!placed) { + log(bot, `Could not find adjacent block to place against at y=${belowBlock.position.y}. Stopped after ${i} blocks.`); + return i > 0; + } + } catch (placeErr) { + log(bot, `Failed to place block: ${placeErr.message}. Stopped after ${i} blocks.`); + return i > 0; + } + } + await new Promise(r => setTimeout(r, 250)); // small delay between pillars + } + log(bot, `Pillared up ${distance} blocks.`); + return true; +} + +// RC30: Strip-mine horizontally to find ore when pathfinder can't reach any +export async function stripMineForOre(bot, oreNames, length = 32) { + /** + * Dig a 1x2 horizontal tunnel to find ore. Mines in the direction the bot is facing. + * @param {MinecraftBot} bot + * @param {string[]} oreNames - ore block names to look for (e.g. ['iron_ore', 'deepslate_iron_ore']) + * @param {number} length - how far to dig (blocks) + * @returns {Promise} true if ore was found and collected + */ + // Determine direction from bot's yaw + const yaw = bot.entity.yaw; + let dx = 0, dz = 0; + // Normalize yaw to cardinal direction + const facing = ((yaw % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + if (facing >= 5.5 || facing < 0.785) { dz = 1; } // south + else if (facing >= 0.785 && facing < 2.356) { dx = -1; } // west + else if (facing >= 2.356 && facing < 3.927) { dz = -1; } // north + else { dx = 1; } // east + + log(bot, `Strip-mining ${length} blocks (dx=${dx}, dz=${dz}) looking for ${oreNames.join('/')}`); + + const startPos = bot.entity.position.clone(); + let oresCollected = 0; + + for (let i = 1; i <= length; i++) { + if (bot.interrupt_code) break; + + const x = Math.floor(startPos.x) + dx * i; + const y = Math.floor(startPos.y); + const z = Math.floor(startPos.z) + dz * i; + + // Break the two blocks in front (feet level and head level) + for (const yOff of [0, 1]) { + const block = bot.blockAt(new Vec3(x, y + yOff, z)); + if (!block || block.name === 'air' || block.name === 'cave_air') continue; + + // Check for lava + if (block.name === 'lava' || block.name === 'water') { + log(bot, `Hit ${block.name} while strip-mining. Stopping.`); + return oresCollected > 0; + } + + // Check if this IS an ore we want + if (oreNames.includes(block.name)) { + oresCollected++; + log(bot, `Found ${block.name} while strip-mining at (${x}, ${y + yOff}, ${z})!`); + } + + await breakBlockAt(bot, x, y + yOff, z); + } + + // Also check blocks to the sides and above/below for ore + const sideOffsets = [ + [0, 2, 0], // above head + [0, -1, 0], // below feet + [-dz, 0, dx], // left wall (feet) + [-dz, 1, dx], // left wall (head) + [dz, 0, -dx], // right wall (feet) + [dz, 1, -dx], // right wall (head) + ]; + for (const [ox, oy, oz] of sideOffsets) { + const sideBlock = bot.blockAt(new Vec3(x + ox, y + oy, z + oz)); + if (sideBlock && oreNames.includes(sideBlock.name)) { + oresCollected++; + log(bot, `Found ${sideBlock.name} adjacent to tunnel at (${x + ox}, ${y + oy}, ${z + oz})!`); + await breakBlockAt(bot, x + ox, y + oy, z + oz); + } + } + + // Move into the newly cleared space + try { + await bot.waitForTicks(2); + // Simple movement: walk forward into the cleared space + await goToPosition(bot, x + 0.5, y, z + 0.5, 0); + } catch (_moveErr) { + // If pathfinder fails, try teleporting via simple walk + bot.setControlState('forward', true); + await new Promise(r => setTimeout(r, 500)); + bot.setControlState('forward', false); + } + + // Pick up dropped items every few blocks + if (i % 4 === 0) { + await pickupNearbyItems(bot); + } + } + + await pickupNearbyItems(bot); + log(bot, `Strip-mine complete. Found ${oresCollected} ore blocks in ${length}-block tunnel.`); + return oresCollected > 0; +} + export async function digDown(bot, distance = 10) { /** - * Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot. + * Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=6 blocks below the bot. + * For drops of 3-5 blocks, places blocks to create a safe staircase down. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {int} distance, distance to dig down. * @returns {Promise} true if successfully dug all the way down. @@ -1926,21 +2682,23 @@ 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; } - const MAX_FALL_BLOCKS = 2; + // RC30: Increased from 2 to 5. Count air blocks below. + const MAX_FALL_BLOCKS = 5; let num_fall_blocks = 0; + let checkBlock = belowBlock; for (let j = 0; j <= MAX_FALL_BLOCKS; j++) { - if (!belowBlock || (belowBlock.name !== 'air' && belowBlock.name !== 'cave_air')) { + if (!checkBlock || (checkBlock.name !== 'air' && checkBlock.name !== 'cave_air')) { break; } num_fall_blocks++; - belowBlock = bot.blockAt(belowBlock.position.offset(0, -1, 0)); + checkBlock = bot.blockAt(start_block_pos.offset(0, -i-1-j-1, 0)); } if (num_fall_blocks > MAX_FALL_BLOCKS) { - log(bot, `Dug down ${i-1} blocks, but reached a drop below the next block.`); + log(bot, `Dug down ${i-1} blocks, but reached a large drop (${num_fall_blocks} blocks) below.`); return false; } @@ -1973,7 +2731,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 +2819,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 +2848,933 @@ 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. + **/ + console.log('[RC30] getDiamondPickaxe: starting'); + + // Clear near-full inventory first so items can be picked up during collection + const invSize = Object.values(world.getInventoryCounts(bot)).reduce((a, b) => a + b, 0); + console.log(`[RC30] getDiamondPickaxe: invSize=${invSize}`); + if (invSize >= 30) { + log(bot, `Inventory near-full (${invSize} stacks). Clearing junk before collecting resources...`); + await autoManageInventory(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']) { + // Skip wooden pickaxe entirely if we already have materials for stone pickaxe + if ((inv['cobblestone'] ?? 0) >= 3 && (inv['stick'] ?? 0) >= 2) { + log(bot, 'Already have cobblestone and sticks — skipping wooden pickaxe, going straight to stone.'); + // Fall through to tier 2 + } else { + 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, 'No logs nearby. Exploring 200 blocks to find trees...'); + await explore(bot, 200); + for (const lt of logTypes) { + if (await collectBlock(bot, lt, 3)) { logType = lt; break; } + } + } + if (!logType) { + log(bot, 'Cannot find any logs even after exploring. Run !getDiamondPickaxe again from a different area.'); + return false; + } + // Re-read inventory — collectBlock may have picked up a different log type + // (e.g., RC27 expanded search returns oak while hunting birch). + inv = world.getInventoryCounts(bot); + const actualLog = logTypes.find(l => (inv[l] ?? 0) >= 1); + if (actualLog && actualLog !== logType) { + log(bot, `Collected ${actualLog} (was hunting ${logType}), adjusting.`); + logType = actualLog; + } + const plankType = logType.replace('_log', '_planks'); + + // Craft ALL logs into planks (need ≥9: 4 crafting_table + 2 sticks + 3 pickaxe) + const logsAvail = inv[logType] ?? 0; + if (!await craftRecipe(bot, plankType, Math.max(logsAvail, 3))) { + log(bot, `Failed to craft ${plankType}.`); + return false; + } + + // Craft a crafting table — required for all pickaxe recipes + inv = world.getInventoryCounts(bot); + const nearTable = world.getNearestBlock(bot, 'crafting_table', 16); + const isReachable = nearTable && bot.entity.position.distanceTo(nearTable.position) <= 4; + if (!(inv['crafting_table'] > 0) && !isReachable) { + if (!await craftRecipe(bot, 'crafting_table', 1)) { + log(bot, 'Failed to craft crafting table.'); + 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.'); + } // end else (wooden pickaxe craft) + } + + // Helper: ensure we have enough sticks & a crafting table for upcoming pickaxe + async function ensureSticksAndTable() { + const inv2 = world.getInventoryCounts(bot); + // Need at least 2 sticks + if ((inv2['stick'] ?? 0) < 2) { + // Need planks for sticks — check if we have some + const anyPlanks = ['oak_planks','birch_planks','spruce_planks','dark_oak_planks', + 'acacia_planks','jungle_planks','mangrove_planks'].find(p => (inv2[p] ?? 0) >= 2); + if (anyPlanks) { + await craftRecipe(bot, 'stick', 1); + } else { + // Collect 1 log and make planks + sticks + const logTypes2 = ['oak_log','birch_log','spruce_log','dark_oak_log', + 'acacia_log','jungle_log','mangrove_log']; + for (const lt of logTypes2) { + if (await collectBlock(bot, lt, 1)) { + const pt = lt.replace('_log', '_planks'); + await craftRecipe(bot, pt, 1); + await craftRecipe(bot, 'stick', 1); + break; + } + } + } + } + // Ensure crafting table available (in inventory, not just "nearby" which might be unreachable) + const inv3 = world.getInventoryCounts(bot); + const nearbyTable = world.getNearestBlock(bot, 'crafting_table', 16); + const tableReachable = nearbyTable && bot.entity.position.distanceTo(nearbyTable.position) <= 4; + if (!(inv3['crafting_table'] > 0) && !tableReachable) { + let anyPlanks = ['oak_planks','birch_planks','spruce_planks','dark_oak_planks', + 'acacia_planks','jungle_planks','mangrove_planks'].find(p => (inv3[p] ?? 0) >= 4); + if (!anyPlanks) { + // Convert logs to planks if we have any + const logTypes3 = ['oak_log','birch_log','spruce_log','dark_oak_log', + 'acacia_log','jungle_log','mangrove_log']; + const logType3 = logTypes3.find(l => (inv3[l] ?? 0) >= 1); + if (logType3) { + const pt3 = logType3.replace('_log', '_planks'); + await craftRecipe(bot, pt3, 1); // 1 log → 4 planks + anyPlanks = pt3; + } else { + // Collect a log if none in inventory + for (const lt of logTypes3) { + if (await collectBlock(bot, lt, 1)) { + const pt3 = lt.replace('_log', '_planks'); + await craftRecipe(bot, pt3, 1); + anyPlanks = pt3; + break; + } + } + } + } + if (anyPlanks) { + await craftRecipe(bot, 'crafting_table', 1); + } + } + } + + // ── TIER 2: stone pickaxe ──────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['stone_pickaxe'] && !inv['iron_pickaxe']) { + if ((inv['cobblestone'] ?? 0) < 3) { + 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; + } + } else { + log(bot, 'Already have enough cobblestone for stone pickaxe.'); + } + await ensureSticksAndTable(); + 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); + console.log(`[RC30] Tier 3 check: iron_pickaxe=${inv['iron_pickaxe'] ?? 0}`); + if (!inv['iron_pickaxe']) { + try { + log(bot, 'Collecting iron ore for iron pickaxe...'); + const ironOres = ['iron_ore', 'deepslate_iron_ore']; + + // Check if we already have raw_iron or iron_ingot in inventory + inv = world.getInventoryCounts(bot); + const rawIronHave = (inv['raw_iron'] ?? 0); + const ironIngotHave = (inv['iron_ingot'] ?? 0); + const ironNeeded = 3 - ironIngotHave; // need 3 total iron ingots + const oreNeeded = Math.max(0, ironNeeded - rawIronHave); + + let gotIron = oreNeeded <= 0; + + if (!gotIron) { + // Attempt 1: regular collectBlock + gotIron = await collectBlock(bot, 'iron_ore', oreNeeded); + if (!gotIron) gotIron = await collectBlock(bot, 'deepslate_iron_ore', oreNeeded); + } + + if (!gotIron) { + // Attempt 2: strip-mine at current level (works even in shafts) + log(bot, 'Cannot reach iron ore via pathfinding. Strip-mining to find iron...'); + const currentY = Math.floor(bot.entity.position.y); + + // If we're deep underground in a shaft, try to escape upward first + if (currentY < 50) { + log(bot, 'Underground — trying to pillar up to better terrain...'); + const pillarDist = Math.min(20, 60 - currentY); // aim for ~y=60 (surface-ish) + if (pillarDist > 0) { + await pillarUp(bot, pillarDist); + } + } + + // Now try strip-mining at current position (any y-level from 0-64 has iron) + gotIron = await stripMineForOre(bot, ironOres, 40); + } + + if (!gotIron) { + // Attempt 3: explore on surface, then dig fresh shaft + log(bot, 'Strip-mine did not find iron. Exploring to find a new area...'); + try { await goToSurface(bot); } catch (_e) { + // goToSurface may fail in enclosed spaces — pillar up instead + await pillarUp(bot, 30); + } + await explore(bot, 200); + const newY = Math.floor(bot.entity.position.y); + if (newY > 16) { + await digDown(bot, Math.min(newY - 16, 40)); + } + gotIron = await collectBlock(bot, 'iron_ore', oreNeeded); + if (!gotIron) gotIron = await collectBlock(bot, 'deepslate_iron_ore', oreNeeded); + if (!gotIron) gotIron = await stripMineForOre(bot, ironOres, 40); + } + + if (!gotIron) { + log(bot, 'Could not find iron ore after multiple attempts. Try !getDiamondPickaxe again later.'); + return false; + } + + // Check how much raw iron we have now + inv = world.getInventoryCounts(bot); + const totalRaw = (inv['raw_iron'] ?? 0); + const totalIngots = (inv['iron_ingot'] ?? 0); + + if (totalIngots < 3 && totalRaw > 0) { + // RC30: Ensure we have a furnace before smelting + const furnaceInv = (inv['furnace'] ?? 0); + const furnaceNearby = world.getNearestBlock(bot, 'furnace', 16); + if (!furnaceInv && !furnaceNearby) { + log(bot, 'Crafting furnace for smelting (8 cobblestone)...'); + await ensureSticksAndTable(); // need crafting table + if (!await craftRecipe(bot, 'furnace', 1)) { + log(bot, 'Failed to craft furnace. Need 8 cobblestone.'); + return false; + } + } + + // RC30: Ensure we have fuel — collect coal if no fuel in inventory + const fuelItems = ['coal', 'charcoal', 'oak_planks', 'birch_planks', 'spruce_planks', + 'dark_oak_planks', 'acacia_planks', 'jungle_planks', 'mangrove_planks', + 'oak_log', 'birch_log', 'spruce_log']; + const hasFuel = fuelItems.some(f => (inv[f] ?? 0) > 0); + if (!hasFuel) { + log(bot, 'No fuel for furnace. Collecting coal...'); + let gotFuel = await collectBlock(bot, 'coal_ore', 2); + if (!gotFuel) { + // Use wood as backup fuel — collect a log + const logTypes4 = ['oak_log','birch_log','spruce_log','dark_oak_log','acacia_log','jungle_log']; + for (const lt of logTypes4) { + if (await collectBlock(bot, lt, 2)) { gotFuel = true; break; } + } + } + if (!gotFuel) { + log(bot, 'Cannot find fuel for smelting. Try again from a different area.'); + return false; + } + } + + const smeltCount = Math.min(totalRaw, 3 - totalIngots); + if (!await smeltItem(bot, 'raw_iron', smeltCount)) { + log(bot, 'Failed to smelt raw iron into iron ingots.'); + return false; + } + } else if (totalIngots < 3 && totalRaw === 0) { + log(bot, 'Collected ore blocks but no raw_iron in inventory. May need a better pickaxe or retry.'); + return false; + } + + await ensureSticksAndTable(); + if (!await craftRecipe(bot, 'iron_pickaxe', 1)) { + log(bot, 'Failed to craft iron pickaxe.'); + return false; + } + log(bot, 'Iron pickaxe crafted.'); + } catch (tier3Err) { + log(bot, `[RC30] Iron tier error: ${tier3Err.message}. Will retry on next !getDiamondPickaxe call.`); + console.error('[RC30] Tier 3 error:', tier3Err); + return false; + } + } + + // ── TIER 4: diamond pickaxe ────────────────────────────────────────────── + // RC31: Quick-check — if we already have a diamond pickaxe, return immediately + if (world.getInventoryCounts(bot)['diamond_pickaxe']) { + log(bot, 'Already have a diamond pickaxe! Skipping tier 4.'); + return true; + } + console.log('[RC30] Entering tier 4: diamond pickaxe'); + try { + const targetY = -11; + const MAX_DIG_ATTEMPTS = 4; + let reachedDiamondLevel = false; + + for (let attempt = 0; attempt < MAX_DIG_ATTEMPTS && !reachedDiamondLevel; attempt++) { + const currentY = Math.floor(bot.entity.position.y); + log(bot, `Digging to diamond level (y=${targetY}), attempt ${attempt + 1}/${MAX_DIG_ATTEMPTS} from y=${currentY}...`); + + if (currentY <= targetY + 5) { + reachedDiamondLevel = true; + break; + } + + const dist = currentY - targetY; + const digSuccess = await digDown(bot, dist); + const afterY = Math.floor(bot.entity.position.y); + console.log(`[RC30] Tier4 dig attempt ${attempt + 1}: digSuccess=${digSuccess}, afterY=${afterY}`); + + if (digSuccess || afterY <= targetY + 10) { + reachedDiamondLevel = true; + } else { + // Failed — move horizontally and try a new shaft (NO goToSurface — it times out) + log(bot, `Dig attempt ${attempt + 1} stopped at y=${afterY}. Moving sideways to try new shaft...`); + try { + // Pick a random cardinal direction and walk 15 blocks + const dirs = [{x:15,z:0},{x:-15,z:0},{x:0,z:15},{x:0,z:-15}]; + const dir = dirs[attempt % dirs.length]; + const pos = bot.entity.position; + await goToPosition(bot, pos.x + dir.x, afterY, pos.z + dir.z, 2); + } catch (moveErr) { + console.log(`[RC30] Tier4 sideways move failed: ${moveErr.message}. Trying pillarUp...`); + try { await pillarUp(bot, 5); } catch (_pe) { /* ignore */ } + // Fall through and try dig from new position anyway + } + } + } + + if (!reachedDiamondLevel) { + const finalY = Math.floor(bot.entity.position.y); + if (finalY > targetY + 15) { + log(bot, `Could not reach diamond level after ${MAX_DIG_ATTEMPTS} attempts (y=${finalY}). Call !getDiamondPickaxe again.`); + return false; + } + log(bot, `Not at target but y=${finalY} is close enough. Trying anyway...`); + } + + log(bot, 'Searching for diamond ore...'); + const diamondOres = ['deepslate_diamond_ore', 'diamond_ore']; + let gotDiamonds = false; + try { gotDiamonds = await collectBlock(bot, 'deepslate_diamond_ore', 3); } catch (_ce) { /* timeout safe */ } + if (!gotDiamonds) { + try { gotDiamonds = await collectBlock(bot, 'diamond_ore', 3); } catch (_ce) { /* timeout safe */ } + } + + if (!gotDiamonds) { + log(bot, 'No diamond ore found via pathfinding. Strip-mining at diamond level...'); + try { gotDiamonds = await stripMineForOre(bot, diamondOres, 50); } catch (_se) { /* timeout safe */ } + } + + if (!gotDiamonds) { + // Try a second strip-mine in perpendicular direction + log(bot, 'First strip-mine found nothing. Trying perpendicular branch...'); + try { + // Rotate bot ~90 degrees by nudging sideways + const p = bot.entity.position; + try { await goToPosition(bot, p.x + 3, p.y, p.z, 1); } catch (_) { /* ok */ } + gotDiamonds = await stripMineForOre(bot, diamondOres, 50); + } catch (_se2) { /* timeout safe */ } + } + + if (!gotDiamonds) { + log(bot, 'No diamond ore found after mining. Explore and try !getDiamondPickaxe again.'); + return false; + } + + await ensureSticksAndTable(); + 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; + } + + try { await pillarUp(bot, 30); } catch (_e) { /* best effort */ } + log(bot, 'Diamond pickaxe obtained!'); + return true; + } catch (tier4Err) { + log(bot, `[RC30] Diamond tier error: ${tier4Err.message}. Will retry on next call.`); + console.error('[RC30] Tier 4 error:', tier4Err); + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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/src/agent/memory_bank.js b/src/agent/memory_bank.js index a32ab7839..9210b31b6 100644 --- a/src/agent/memory_bank.js +++ b/src/agent/memory_bank.js @@ -12,7 +12,7 @@ export class MemoryBank { } getJson() { - return this.memory + return this.memory; } loadJson(json) { @@ -20,6 +20,6 @@ export class MemoryBank { } getKeys() { - return Object.keys(this.memory).join(', ') + return Object.keys(this.memory).join(', '); } } \ No newline at end of file diff --git a/src/agent/mindserver_proxy.js b/src/agent/mindserver_proxy.js index d0b9be838..667fb28dd 100644 --- a/src/agent/mindserver_proxy.js +++ b/src/agent/mindserver_proxy.js @@ -18,15 +18,25 @@ class MindServerProxy { MindServerProxy.instance = this; } - async connect(name, port) { + async connect(name, urlOrPort, remoteSettings = null) { if (this.connected) return; - + this.name = name; - this.socket = io(`http://localhost:${port}`); + const url = (typeof urlOrPort === 'string' && urlOrPort.startsWith('http')) + ? urlOrPort + : `http://localhost:${urlOrPort}`; + this.socket = io(url); await new Promise((resolve, reject) => { - this.socket.on('connect', resolve); + const timeout = setTimeout(() => { + reject(new Error(`MindServer connection timed out after 30s (${url})`)); + }, 30000); + this.socket.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); this.socket.on('connect_error', (err) => { + clearTimeout(timeout); console.error('Connection failed:', err); reject(err); }); @@ -57,7 +67,13 @@ class MindServerProxy { }); this.socket.on('restart-agent', (agentName) => { - console.log(`Restarting agent: ${agentName}`); + // Ignore unnamed/broadcast restarts (e.g. from stale UI settings updates) + if (!agentName) { + console.log('Ignoring unnamed restart-agent event'); + return; + } + if (agentName !== this.agent.name) return; + console.log(`Restarting agent: ${this.agent.name}`); this.agent.cleanKill(); }); @@ -79,22 +95,45 @@ class MindServerProxy { } }); - // Request settings and wait for response - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Settings request timed out after 5 seconds')); - }, 5000); + this.socket.on('get-usage', (callback) => { + try { + const snapshot = this.agent?.prompter?.usageTracker?.getSnapshot() || null; + callback(snapshot); + } catch (error) { + console.error('Error getting usage:', error); + callback(null); + } + }); - this.socket.emit('get-settings', name, (response) => { - clearTimeout(timeout); - if (response.error) { - return reject(new Error(response.error)); - } - setSettings(response.settings); - this.socket.emit('connect-agent-process', name); - resolve(); + if (remoteSettings) { + // Remote mode: register ourselves on the remote MindServer + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Remote agent registration timed out after 10s')); + }, 10000); + this.socket.emit('register-remote-agent', remoteSettings, (response) => { + clearTimeout(timeout); + if (response.error) return reject(new Error(response.error)); + setSettings(response.settings); + this.socket.emit('connect-agent-process', name); + resolve(); + }); }); - }); + } else { + // Local mode: request settings from MindServer + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Settings request timed out after 5 seconds')); + }, 5000); + this.socket.emit('get-settings', name, (response) => { + clearTimeout(timeout); + if (response.error) return reject(new Error(response.error)); + setSettings(response.settings); + this.socket.emit('connect-agent-process', name); + resolve(); + }); + }); + } } setAgent(agent) { diff --git a/src/agent/modes.js b/src/agent/modes.js index 21b7b955e..fb61ef2a7 100644 --- a/src/agent/modes.js +++ b/src/agent/modes.js @@ -1,7 +1,7 @@ import * as skills from './library/skills.js'; import * as world from './library/world.js'; import * as mc from '../utils/mcdata.js'; -import settings from './settings.js' +import settings from './settings.js'; import convoManager from './conversation.js'; async function say(agent, message) { @@ -35,41 +35,54 @@ const modes_list = [ let blockAbove = bot.blockAt(bot.entity.position.offset(0, 1, 0)); if (!block) block = {name: 'air'}; // hacky fix when blocks are not loaded if (!blockAbove) blockAbove = {name: 'air'}; - if (blockAbove.name === 'water') { - // does not call execute so does not interrupt other actions - if (!bot.pathfinder.goal) { - bot.setControlState('jump', true); + // Drowning prevention: swim up when underwater or low on air + const isSubmerged = blockAbove.name === 'water'; + const oxygenLevel = bot.oxygenLevel != null ? bot.oxygenLevel : 20; + if (isSubmerged && oxygenLevel < 12) { + // Low on air — interrupt everything and swim to surface + bot.setControlState('jump', true); + bot.setControlState('sprint', false); + if (oxygenLevel < 6) { + // Critical — also try to navigate up + await execute(this, agent, async () => { + const pos = bot.entity.position; + await skills.goToPosition(bot, pos.x, pos.y + 10, pos.z, 1); + }); } } + else if (isSubmerged) { + // Underwater but air is okay — keep jumping to stay afloat + bot.setControlState('jump', true); + } else if (this.fall_blocks.some(name => blockAbove.name.includes(name))) { - execute(this, agent, async () => { + await execute(this, agent, async () => { await skills.moveAway(bot, 2); }); } else if (block.name === 'lava' || block.name === 'fire' || blockAbove.name === 'lava' || blockAbove.name === 'fire') { - say(agent, 'I\'m on fire!'); + await say(agent, 'I\'m on fire!'); // if you have a water bucket, use it let waterBucket = bot.inventory.items().find(item => item.name === 'water_bucket'); if (waterBucket) { - execute(this, agent, async () => { + await execute(this, agent, async () => { let success = await skills.placeBlock(bot, 'water_bucket', block.position.x, block.position.y, block.position.z); - if (success) say(agent, 'Placed some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Placed some water, ahhhh that\'s better!'); }); } else { - execute(this, agent, async () => { + await execute(this, agent, async () => { let waterBucket = bot.inventory.items().find(item => item.name === 'water_bucket'); if (waterBucket) { let success = await skills.placeBlock(bot, 'water_bucket', block.position.x, block.position.y, block.position.z); - if (success) say(agent, 'Placed some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Placed some water, ahhhh that\'s better!'); return; } let nearestWater = world.getNearestBlock(bot, 'water', 20); if (nearestWater) { const pos = nearestWater.position; let success = await skills.goToPosition(bot, pos.x, pos.y, pos.z, 0.2); - if (success) say(agent, 'Found some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Found some water, ahhhh that\'s better!'); return; } await skills.moveAway(bot, 5); @@ -77,8 +90,8 @@ const modes_list = [ } } else if (Date.now() - bot.lastDamageTime < 3000 && (bot.health < 5 || bot.lastDamageTaken >= bot.health)) { - say(agent, 'I\'m dying!'); - execute(this, agent, async () => { + await say(agent, 'I\'m dying!'); + await execute(this, agent, async () => { await skills.moveAway(bot, 20); }); } @@ -120,13 +133,28 @@ const modes_list = [ } const max_stuck_time = cur_dig_block?.name === 'obsidian' ? this.max_stuck_time * 2 : this.max_stuck_time; if (this.stuck_time > max_stuck_time) { - say(agent, 'I\'m stuck!'); + await say(agent, 'I\'m stuck!'); this.stuck_time = 0; - execute(this, agent, async () => { - const crashTimeout = setTimeout(() => { agent.cleanKill("Got stuck and couldn't get unstuck") }, 10000); - await skills.moveAway(bot, 5); - clearTimeout(crashTimeout); - say(agent, 'I\'m free.'); + await execute(this, agent, async () => { + const crashTimeout = setTimeout(() => { agent.cleanKill("Got stuck and couldn't get unstuck") }, 30000); + try { + await skills.moveAway(bot, 5); + clearTimeout(crashTimeout); + void say(agent, 'I\'m free.'); + } catch (moveErr) { + console.warn(`[Unstuck] moveAway failed: ${moveErr.message}. Brute-force walking...`); + // Brute-force fallback: random yaw + forward + jump for 3s + const randomYaw = Math.random() * Math.PI * 2; + const randomPitch = 0; + bot.look(randomYaw, randomPitch, true); + bot.setControlState('forward', true); + bot.setControlState('jump', true); + bot.setControlState('sprint', true); + await new Promise(resolve => setTimeout(resolve, 3000)); + bot.clearControlStates(); + clearTimeout(crashTimeout); + void say(agent, 'Broke free by brute force.'); + } }); } this.last_time = Date.now(); @@ -146,8 +174,8 @@ const modes_list = [ update: async function (agent) { const enemy = world.getNearestEntityWhere(agent.bot, entity => mc.isHostile(entity), 16); if (enemy && await world.isClearPath(agent.bot, enemy)) { - say(agent, `Aaa! A ${enemy.name.replace("_", " ")}!`); - execute(this, agent, async () => { + await say(agent, `Aaa! A ${enemy.name.replace("_", " ")}!`); + await execute(this, agent, async () => { await skills.avoidEnemies(agent.bot, 24); }); } @@ -160,10 +188,12 @@ const modes_list = [ on: true, active: false, update: async function (agent) { - const enemy = world.getNearestEntityWhere(agent.bot, entity => mc.isHostile(entity), 8); - if (enemy && await world.isClearPath(agent.bot, enemy)) { - say(agent, `Fighting ${enemy.name}!`); - execute(this, agent, async () => { + const bot = agent.bot; + if (Date.now() - (bot.respawnTime || 0) < 5000) return; + const enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), 8); + if (enemy && await world.isClearPath(bot, enemy)) { + await say(agent, `Fighting ${enemy.name}!`); + await execute(this, agent, async () => { await skills.defendSelf(agent.bot, 8); }); } @@ -178,8 +208,8 @@ const modes_list = [ update: async function (agent) { const huntable = world.getNearestEntityWhere(agent.bot, entity => mc.isHuntable(entity), 8); if (huntable && await world.isClearPath(agent.bot, huntable)) { - execute(this, agent, async () => { - say(agent, `Hunting ${huntable.name}!`); + await execute(this, agent, async () => { + void say(agent, `Hunting ${huntable.name}!`); await skills.attackEntity(agent.bot, huntable); }); } @@ -203,9 +233,9 @@ const modes_list = [ this.noticed_at = Date.now(); } if (Date.now() - this.noticed_at > this.wait * 1000) { - say(agent, `Picking up item!`); + await say(agent, `Picking up item!`); this.prev_item = item; - execute(this, agent, async () => { + await execute(this, agent, async () => { await skills.pickupNearbyItems(agent.bot); }); this.noticed_at = -1; @@ -224,10 +254,10 @@ const modes_list = [ active: false, cooldown: 5, last_place: Date.now(), - update: function (agent) { + update: async function (agent) { if (world.shouldPlaceTorch(agent.bot)) { if (Date.now() - this.last_place < this.cooldown * 1000) return; - execute(this, agent, async () => { + await execute(this, agent, async () => { const pos = agent.bot.entity.position; await skills.placeBlock(agent.bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); }); @@ -235,6 +265,91 @@ const modes_list = [ } } }, + { + // RC26: Auto-navigate to bed and sleep at night + name: 'night_bed', + description: 'Automatically find and sleep in a bed at night. Crafts and places a bed if none nearby.', + interrupts: ['action:followPlayer'], + on: true, + active: false, + cooldown: 30, // seconds between attempts + lastAttempt: 0, + update: async function (agent) { + const bot = agent.bot; + const time = bot.time?.timeOfDay ?? 0; + + // Only trigger at night (12500 = dusk, can sleep at 12542+) + if (time < 12500 || time > 23000) return; + // Already sleeping + if (bot.isSleeping) return; + // Cooldown to avoid spamming + if (Date.now() - this.lastAttempt < this.cooldown * 1000) return; + // Don't interrupt Nether/End (no night cycle) + const dim = bot.game?.dimension; + if (dim === 'the_nether' || dim === 'the_end') return; + + this.lastAttempt = Date.now(); + + await execute(this, agent, async () => { + // Phase 1: Look for existing bed within 64 blocks + const beds = bot.findBlocks({ + matching: (block) => block.name.includes('bed'), + maxDistance: 64, + count: 1 + }); + + if (beds.length > 0) { + void say(agent, 'Night time — heading to bed.'); + const success = await skills.goToBed(bot); + if (success) return; + // goToBed failed — fall through to craft attempt + } + + // Phase 2: No bed found (or sleep failed) — try to craft and place one + const inv = world.getInventoryCounts(bot); + const woolTypes = [ + 'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', + 'yellow_wool', 'lime_wool', 'pink_wool', 'gray_wool', + 'light_gray_wool', 'cyan_wool', 'purple_wool', 'blue_wool', + 'brown_wool', 'green_wool', 'red_wool', 'black_wool' + ]; + const plankTypes = [ + 'oak_planks', 'spruce_planks', 'birch_planks', 'jungle_planks', + 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', + 'cherry_planks', 'bamboo_planks', 'crimson_planks', 'warped_planks' + ]; + + const woolCount = woolTypes.reduce((sum, w) => sum + (inv[w] || 0), 0); + const plankCount = plankTypes.reduce((sum, p) => sum + (inv[p] || 0), 0); + + if (woolCount >= 3 && plankCount >= 3) { + void say(agent, 'No bed nearby — crafting one.'); + try { + // Find which wool and plank type we actually have + const woolType = woolTypes.find(w => (inv[w] || 0) >= 3) + || woolTypes.find(w => (inv[w] || 0) > 0); + // craftRecipe will try the default bed recipe + const bedName = woolType ? woolType.replace('_wool', '_bed') : 'white_bed'; + await skills.craftRecipe(bot, bedName); + + // Place the bed + const bedItem = bot.inventory.items().find(i => i.name.includes('bed')); + if (bedItem) { + const pos = bot.entity.position; + await skills.placeBlock(bot, bedItem.name, pos.x + 1, pos.y, pos.z, 'bottom'); + // Now sleep in it + await skills.goToBed(bot); + } + } catch (err) { + console.log(`[night_bed] Craft/place failed: ${err.message}`); + } + } else { + // Not enough materials — just log once quietly + console.log('[night_bed] No bed nearby and insufficient materials to craft one.'); + } + }); + } + }, { name: 'elbow_room', description: 'Move away from nearby players when idle.', @@ -245,7 +360,7 @@ const modes_list = [ update: async function (agent) { const player = world.getNearestEntityWhere(agent.bot, entity => entity.type === 'player', this.distance); if (player) { - execute(this, agent, async () => { + await execute(this, agent, async () => { // wait a random amount of time to avoid identical movements with other bots const wait_time = Math.random() * 1000; await new Promise(resolve => setTimeout(resolve, wait_time)); @@ -293,13 +408,84 @@ const modes_list = [ } } }, + { + name: 'auto_eat', + description: 'Automatically eat food when hunger drops below 14 or health drops below 50%. Prioritizes golden apples at critical health. Interrupts non-critical actions.', + interrupts: ['all'], + on: true, + active: false, + lastEat: 0, + update: async function (agent) { + const bot = agent.bot; + // RC30: Hunger safety net — force eat when health < 50% regardless of hunger + const healthCritical = bot.health < 10; + if (bot.food >= 14 && !healthCritical) return; + if (Date.now() - this.lastEat < (healthCritical ? 3000 : 10000)) return; // faster cooldown when health critical + + // RC30: Prioritize golden apples when health is critical + const criticalFoodPriority = [ + 'enchanted_golden_apple', 'golden_apple', + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'cooked_salmon', 'cooked_cod', 'cooked_rabbit', 'bread', 'baked_potato', + 'apple', 'carrot', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'dried_kelp', 'rotten_flesh' + ]; + const normalFoodPriority = [ + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'cooked_salmon', 'cooked_cod', 'cooked_rabbit', 'bread', 'baked_potato', + 'golden_apple', 'apple', 'carrot', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'dried_kelp', 'rotten_flesh' + ]; + const foodPriority = healthCritical ? criticalFoodPriority : normalFoodPriority; + + const inv = world.getInventoryCounts(bot); + let foodItem = null; + for (const f of foodPriority) { + if ((inv[f] || 0) > 0) { foodItem = f; break; } + } + + if (foodItem) { + this.lastEat = Date.now(); + await say(agent, healthCritical + ? `Emergency eating ${foodItem}! (health: ${bot.health.toFixed(1)}, hunger: ${bot.food})` + : `Eating ${foodItem} (hunger: ${bot.food}).`); + await execute(this, agent, async () => { + await skills.consume(agent.bot, foodItem); + }); + } + } + }, + { + name: 'panic_defense', + description: 'Build emergency cobblestone shelter when health is critically low (< 6) and under attack.', + interrupts: ['all'], + on: true, + active: false, + lastPanic: 0, + update: async function (agent) { + const bot = agent.bot; + if (bot.health >= 6) return; + if (Date.now() - this.lastPanic < 60000) return; // 60s cooldown + if (Date.now() - bot.lastDamageTime > 5000) return; // only if recently damaged + + const inv = world.getInventoryCounts(bot); + const cobble = (inv['cobblestone'] || 0); + if (cobble < 12) return; // not enough to bother + + this.lastPanic = Date.now(); + await say(agent, 'Critical health! Building emergency shelter!'); + await execute(this, agent, async () => { + await skills.buildPanicRoom(agent.bot); + }); + } + }, { name: 'cheat', description: 'Use cheats to instantly place blocks and teleport.', interrupts: [], on: false, active: false, - update: function (agent) { /* do nothing */ } + update: function (_agent) { /* do nothing */ } } ]; @@ -403,7 +589,12 @@ class ModeController { for (let mode of modes_list) { let interruptible = mode.interrupts.some(i => i === 'all') || mode.interrupts.some(i => i === _agent.actions.currentActionLabel); if (mode.on && !mode.paused && !mode.active && (_agent.isIdle() || interruptible)) { - await mode.update(_agent); + try { + await mode.update(_agent); + } catch (err) { + console.error(`Mode ${mode.name} error:`, err.message); + mode.active = false; + } } if (mode.active) break; } diff --git a/src/agent/npc/build_goal.js b/src/agent/npc/build_goal.js index ebca78f80..db12c0d53 100644 --- a/src/agent/npc/build_goal.js +++ b/src/agent/npc/build_goal.js @@ -1,7 +1,6 @@ import { Vec3 } from 'vec3'; import * as skills from '../library/skills.js'; import * as world from '../library/world.js'; -import * as mc from '../../utils/mcdata.js'; import { blockSatisfied, getTypeOfGeneric, rotateXZ } from './utils.js'; diff --git a/src/agent/npc/controller.js b/src/agent/npc/controller.js index 9af3f3e40..1c35bc548 100644 --- a/src/agent/npc/controller.js +++ b/src/agent/npc/controller.js @@ -1,11 +1,10 @@ import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; import { NPCData } from './data.js'; import { ItemGoal } from './item_goal.js'; import { BuildGoal } from './build_goal.js'; import { itemSatisfied, rotateXZ } from './utils.js'; import * as skills from '../library/skills.js'; -import * as world from '../library/world.js'; -import * as mc from '../../utils/mcdata.js'; export class NPCContoller { @@ -40,12 +39,15 @@ export class NPCContoller { init() { try { - for (let file of readdirSync('src/agent/npc/construction')) { + const constructionDir = path.resolve('src/agent/npc/construction'); + for (let file of readdirSync(constructionDir)) { if (file.endsWith('.json')) { - this.constructions[file.slice(0, -5)] = JSON.parse(readFileSync('src/agent/npc/construction/' + file, 'utf8')); + const filePath = path.resolve(constructionDir, file); + if (!filePath.startsWith(constructionDir + path.sep)) continue; + this.constructions[file.slice(0, -5)] = JSON.parse(readFileSync(filePath, 'utf8')); } } - } catch (e) { + } catch (_e) { console.log('Error reading construction file'); } @@ -151,7 +153,7 @@ export class NPCContoller { // If we need more blocks to complete a building, get those first let goals = this.temp_goals.concat(this.data.goals); if (this.data.curr_goal) - goals = goals.concat([this.data.curr_goal]) + goals = goals.concat([this.data.curr_goal]); this.temp_goals = []; let acted = false; @@ -170,7 +172,7 @@ export class NPCContoller { // Build construction goal else { let res = null; - if (this.data.built.hasOwnProperty(goal.name)) { + if (Object.prototype.hasOwnProperty.call(this.data.built, goal.name)) { res = await this.build_goal.executeNext( this.constructions[goal.name], this.data.built[goal.name].position, @@ -191,7 +193,7 @@ export class NPCContoller { this.temp_goals.push({ name: block_name, quantity: res.missing[block_name] - }) + }); } if (res.acted) { acted = true; diff --git a/src/agent/npc/item_goal.js b/src/agent/npc/item_goal.js index 9055f54a0..48639fc23 100644 --- a/src/agent/npc/item_goal.js +++ b/src/agent/npc/item_goal.js @@ -17,7 +17,7 @@ const blacklist = [ 'crimson', 'warped', 'dye' -] +]; class ItemNode { @@ -204,7 +204,7 @@ class ItemWrapper { } createChildren() { - let recipes = mc.getItemCraftingRecipes(this.name).map(([recipe, craftedCount]) => recipe); + let recipes = mc.getItemCraftingRecipes(this.name).map(([recipe, _craftedCount]) => recipe); if (recipes) { for (let recipe of recipes) { let includes_blacklisted = false; @@ -218,7 +218,7 @@ class ItemWrapper { if (includes_blacklisted) break; } if (includes_blacklisted) continue; - this.add_method(new ItemNode(this.manager, this, this.name).setRecipe(recipe)) + this.add_method(new ItemNode(this.manager, this, this.name).setRecipe(recipe)); } } @@ -263,7 +263,7 @@ class ItemWrapper { best_method = method; } } - return best_method + return best_method; } isDone(q=1) { diff --git a/src/agent/self_prompter.js b/src/agent/self_prompter.js index 3251f0ee6..7be70ea4c 100644 --- a/src/agent/self_prompter.js +++ b/src/agent/self_prompter.js @@ -1,6 +1,6 @@ -const STOPPED = 0 -const ACTIVE = 1 -const PAUSED = 2 +const STOPPED = 0; +const ACTIVE = 1; +const PAUSED = 2; export class SelfPrompter { constructor(agent) { this.agent = agent; @@ -58,7 +58,7 @@ export class SelfPrompter { console.warn('Self-prompt loop is already active. Ignoring request.'); return; } - console.log('starting self-prompt loop') + console.log('starting self-prompt loop'); this.loop_active = true; let no_command_count = 0; const MAX_NO_COMMAND = 3; @@ -81,7 +81,7 @@ export class SelfPrompter { await new Promise(r => setTimeout(r, this.cooldown)); } } - console.log('self prompt loop stopped') + console.log('self prompt loop stopped'); this.loop_active = false; this.interrupt = false; } @@ -109,7 +109,7 @@ export class SelfPrompter { // you can call this without await if you don't need to wait for it to finish if (this.interrupt) return; - console.log('stopping self-prompt loop') + console.log('stopping self-prompt loop'); this.interrupt = true; while (this.loop_active) { await new Promise(r => setTimeout(r, 500)); diff --git a/src/agent/speak.js b/src/agent/speak.js index 003655ea8..6d4e2f4a7 100644 --- a/src/agent/speak.js +++ b/src/agent/speak.js @@ -1,4 +1,4 @@ -import { exec, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -8,7 +8,7 @@ import { TTSConfig as geminiTTSConfig } from '../models/gemini.js'; let speakingQueue = []; // each item: {text, model, audioData, ready} let isSpeaking = false; -export function speak(text, speak_model) { +export async function speak(text, speak_model) { const model = speak_model || 'system'; const item = { text, model, audioData: null, ready: null }; @@ -23,7 +23,7 @@ export function speak(text, speak_model) { } speakingQueue.push(item); - if (!isSpeaking) processQueue(); + if (!isSpeaking) await processQueue(); } async function fetchRemoteAudio(txt, model) { @@ -61,10 +61,10 @@ async function processQueue() { return; } const item = speakingQueue.shift(); - const { text: txt, model, audioData } = item; + const { text: txt, model, audioData: _audioData } = item; if (txt.trim() === '') { isSpeaking = false; - processQueue(); + await processQueue(); return; } @@ -78,25 +78,35 @@ async function processQueue() { } catch (err) { console.error('[TTS] preprocess error', err); isSpeaking = false; - processQueue(); + await processQueue(); return; } if (model === 'system') { - // system TTS - const cmd = isWin - ? `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; \ - $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate=2; \ - $s.Speak('${txt.replace(/'/g,"''")}'); $s.Dispose()"` - : isMac - ? `say "${txt.replace(/"/g,'\\"')}"` - : `espeak "${txt.replace(/"/g,'\\"')}"`; - - exec(cmd, err => { - if (err) console.error('TTS error', err); - isSpeaking = false; - processQueue(); - }); + // system TTS — use spawn with argument arrays to prevent command injection + let proc; + if (isWin) { + // Pass text via stdin so it never touches the shell command line + const psScript = `Add-Type -AssemblyName System.Speech +$s = New-Object System.Speech.Synthesis.SpeechSynthesizer +$s.Rate = 2 +$txt = [System.Console]::In.ReadToEnd() +$s.Speak($txt) +$s.Dispose()`; + proc = spawn('powershell', ['-NoProfile', '-Command', psScript], + { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }); + proc.stdin.write(txt, 'utf8'); + proc.stdin.end(); + } else if (isMac) { + // Guard against text starting with '-' which 'say' would treat as a flag. + const safeTxt = txt.startsWith('-') ? ` ${txt}` : txt; + proc = spawn('say', [safeTxt], { stdio: 'ignore' }); + } else { + // '--' signals end of options so leading '-' in LLM text is not a flag. + proc = spawn('espeak', ['--', txt], { stdio: 'ignore' }); + } + proc.on('error', err => console.error('TTS error', err)); + proc.on('exit', async () => { isSpeaking = false; await processQueue(); }); } else { @@ -106,7 +116,7 @@ async function processQueue() { if (!audioData) { console.error('[TTS] No audio data ready'); isSpeaking = false; - processQueue(); + await processQueue(); return; } @@ -122,12 +132,12 @@ async function processQueue() { console.error('[TTS] ffplay error', err); try { await fs.unlink(tmpPath); } catch {} isSpeaking = false; - processQueue(); + await processQueue(); }); player.on('exit', async () => { try { await fs.unlink(tmpPath); } catch {} isSpeaking = false; - processQueue(); + await processQueue(); }); } else { @@ -136,15 +146,15 @@ async function processQueue() { }); player.stdin.write(Buffer.from(audioData, 'base64')); player.stdin.end(); - player.on('exit', () => { + player.on('exit', async () => { isSpeaking = false; - processQueue(); + await processQueue(); }); } } catch (e) { console.error('[TTS] Audio error', e); isSpeaking = false; - processQueue(); + await processQueue(); } } } diff --git a/src/agent/tasks/construction_tasks.js b/src/agent/tasks/construction_tasks.js index 0fcd3da81..28a97f963 100644 --- a/src/agent/tasks/construction_tasks.js +++ b/src/agent/tasks/construction_tasks.js @@ -140,7 +140,7 @@ export class Blueprint { return explanation; } check(bot) { - if (!bot || typeof bot !== 'object' || !bot.hasOwnProperty('blockAt')) { + if (!bot || typeof bot !== 'object' || !Object.prototype.hasOwnProperty.call(bot, 'blockAt')) { throw new Error('Invalid bot object. Expected a mineflayer bot.'); } const levels = this.data.levels; @@ -215,14 +215,14 @@ export class Blueprint { */ autoBuild() { const commands = []; - let blueprint = this.data + let blueprint = this.data; let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; let minZ = Infinity, maxZ = -Infinity; for (const level of blueprint.levels) { - console.log(level.level) + console.log(level.level); const baseX = level.coordinates[0]; const baseY = level.coordinates[1]; const baseZ = level.coordinates[2]; @@ -264,9 +264,9 @@ export class Blueprint { * */ autoDelete() { - console.log("auto delete called!") + console.log("auto delete called!"); const commands = []; - let blueprint = this.data + let blueprint = this.data; let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; @@ -348,7 +348,7 @@ export function proceduralGeneration(m = 20, ); // todo: extrapolate into another param? then have set materials be dynamic? - let roomMaterials = ["stone", "terracotta", "quartz_block", "copper_block", "purpur_block"] + let roomMaterials = ["stone", "terracotta", "quartz_block", "copper_block", "purpur_block"]; if (complexity < roomMaterials.length) { roomMaterials = roomMaterials.slice(0, complexity + 1); @@ -504,9 +504,9 @@ export function proceduralGeneration(m = 20, const matrixDepth = matrix.length; const matrixLength = matrix[0].length; const matrixWidth = matrix[0][0].length; - const windowX = Math.ceil(minRoomWidth / 2) - const windowY = Math.ceil(minRoomLength / 2) - const windowZ = Math.ceil(minRoomDepth / 2) + const windowX = Math.ceil(minRoomWidth / 2); + const windowY = Math.ceil(minRoomLength / 2); + const windowZ = Math.ceil(minRoomDepth / 2); // Helper function to check if coordinates are within bounds function isInBounds(z, x, y) { @@ -700,7 +700,7 @@ export function proceduralGeneration(m = 20, } } - function addLadder(matrix, x, y, z) { + function _addLadder(matrix, x, y, z) { let currentZ = z + 1; // turn the floor into air where person would go up @@ -735,10 +735,10 @@ export function proceduralGeneration(m = 20, case 0: break; case 1: - addWindowsAsSquares(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material) + addWindowsAsSquares(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material); break; case 2: - addWindowsAsPlane(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material) + addWindowsAsPlane(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material); } @@ -749,7 +749,7 @@ export function proceduralGeneration(m = 20, addCarpet(0.3, matrix, newX, newY, newZ, newLength, newWidth, material); break; case 2: - addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth, material) + addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth, material); break; } @@ -795,7 +795,7 @@ export function proceduralGeneration(m = 20, // Back side addDoor(matrix, newX + Math.floor(newLength / 2), newY + newWidth - 1, newZ, material); - addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth) + addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth); } break; @@ -809,13 +809,13 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z + lastRoom.depth - 1; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); // addLadder(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), // lastRoom.y + Math.floor(lastRoom.width / 2), // newZ); // Adding the ladder - addStairs(matrix, newX, newY, newZ, newLength, newWidth, material) + addStairs(matrix, newX, newY, newZ, newLength, newWidth, material); lastRoom = {x: newX, y: newY, z: newZ, length: newLength, width: newWidth, depth: newDepth}; @@ -832,7 +832,7 @@ export function proceduralGeneration(m = 20, if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x, lastRoom.y + Math.floor(lastRoom.width / 2), lastRoom.z, material); @@ -851,7 +851,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + lastRoom.length - 1, @@ -872,7 +872,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), @@ -893,7 +893,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), @@ -924,7 +924,7 @@ export function proceduralGeneration(m = 20, // uncomment to visualize blueprint output // printMatrix(matrix) - return matrixToBlueprint(matrix, startCoord) + return matrixToBlueprint(matrix, startCoord); } @@ -934,7 +934,7 @@ export function proceduralGeneration(m = 20, * for cutesy output * @param matrix */ -function printMatrix(matrix) { +function _printMatrix(matrix) { matrix.forEach((layer, layerIndex) => { console.log(`Layer ${layerIndex}:`); layer.forEach(row => { @@ -949,7 +949,7 @@ function printMatrix(matrix) { case 'oak_stairs[facing=east]': return 'S'; // Stairs case 'oak_stairs[facing=south]': return 'S'; // Stairs case 'oak_stairs[facing=west]': return 'S'; // Stairs - case 'glass': return 'W' + case 'glass': return 'W'; default: return '?'; // Unknown or unmarked space @@ -970,7 +970,7 @@ function printMatrix(matrix) { function matrixToBlueprint(matrix, startCoord) { // Validate inputs if (!Array.isArray(matrix) || !Array.isArray(startCoord) || startCoord.length !== 3) { - console.log(matrix) + console.log(matrix); throw new Error('Invalid input format'); } @@ -1038,24 +1038,24 @@ export async function worldToBlueprint(startCoord, y_amount, x_amount, z_amount, level: y, coordinates: coordinates, placement: placement - }) + }); } console.log(levels); const blueprint_data = { materials: materials, levels: levels - } - return blueprint_data + }; + return blueprint_data; } export function blueprintToTask(blueprint_data, num_agents) { - let initialInventory = {} + let initialInventory = {}; for (let j = 0; j < num_agents; j++) { initialInventory[JSON.stringify(j)] = {"diamond_pickaxe": 1, "diamond_axe": 1, "diamond_shovel": 1}; } let give_agent = 0; - console.log("materials", blueprint_data.materials) + console.log("materials", blueprint_data.materials); for (const key of Object.keys(blueprint_data.materials)) { initialInventory[JSON.stringify(give_agent)][key] = blueprint_data.materials[key]; give_agent = (give_agent + 1) % num_agents; diff --git a/src/agent/tasks/cooking_tasks.js b/src/agent/tasks/cooking_tasks.js index a88e3ac23..95a229958 100644 --- a/src/agent/tasks/cooking_tasks.js +++ b/src/agent/tasks/cooking_tasks.js @@ -154,7 +154,7 @@ export class CookingTaskInitiator { // // Place the chest // await bot.chat(`/setblock ${x} ${y} ${z} chest`); - const cookingItems = [ + const _cookingItems = [ ['minecraft:milk_bucket', 1], // Non-stackable ['minecraft:egg', 16], // Stacks to 16 ['minecraft:dandelion', 64], // Stacks to 64 @@ -348,10 +348,10 @@ export class CookingTaskInitiator { await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 3} crafting_table`); await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 5} furnace`); // Add fuel to the furnace - await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 5} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`) + await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 5} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`); await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 7} smoker`); // Add fuel to the smoker - await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 7} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`) + await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 7} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`); await this.bot.chat(`/setblock ${startX + depth - 3} ${startY + 1} ${startZ + 2} bed`); await new Promise(resolve => setTimeout(resolve, 300)); } diff --git a/src/agent/tasks/tasks.js b/src/agent/tasks/tasks.js index b82540e1b..41c67263d 100644 --- a/src/agent/tasks/tasks.js +++ b/src/agent/tasks/tasks.js @@ -298,11 +298,11 @@ export class Task { } this.name = this.agent.name; - this.available_agents = [] + this.available_agents = []; } updateAvailableAgents(agents) { - this.available_agents = agents + this.available_agents = agents; } // Add this method if you want to manually reset the hells_kitchen progress @@ -344,7 +344,7 @@ export class Task { if (this.task_type === 'techtree') { if (this.data.agent_count > 2) { - add_string = '\nMake sure to share resources among all agents and to talk to all the agents using startConversation command to coordinate the task instead of talking to just one agent. You can even end current conversation with any agent using endConversation command and then talk to a new agent using startConversation command.' + add_string = '\nMake sure to share resources among all agents and to talk to all the agents using startConversation command to coordinate the task instead of talking to just one agent. You can even end current conversation with any agent using endConversation command and then talk to a new agent using startConversation command.'; } } @@ -374,7 +374,7 @@ export class Task { // this.agent.bot.chat(`/clear @a`); return {"message": 'Task successful', "score": res.score}; } - let other_names = this.available_agents.filter(n => n !== this.name); + let _other_names = this.available_agents.filter(n => n !== this.name); const elapsedTime = (Date.now() - this.taskStartTime) / 1000; if (elapsedTime >= 30 && this.available_agents.length !== this.data.agent_count) { @@ -439,14 +439,13 @@ export class Task { initialInventory = this.data.initial_inventory[this.agent.count_id.toString()] || {}; console.log("Initial inventory for agent", this.agent.count_id, ":", initialInventory); - console.log("") + console.log(""); if (this.data.human_count > 0 && this.agent.count_id === 0) { // this.num_humans = num_keys - this.data.num_agents; if (this.data.human_count !== this.data.usernames.length) { console.log(`Number of human players ${this.human_count} does not match the number of usernames provided. ${this.data.usernames.length}`); throw new Error(`Number of human players ${this.human_count} does not match the number of usernames provided. ${this.data.usernames.length}`); - return; } const starting_idx = this.data.agent_count; @@ -522,18 +521,18 @@ export class Task { const player = bot.players[playerName]; if (!this.available_agents.some((n) => n === playerName)) { console.log('Found human player:', player.username); - human_player_name = player.username + human_player_name = player.username; break; } } // go the human if there is one and not required for the task if (human_player_name && this.data.human_count === 0) { - console.log(`Teleporting ${this.name} to human ${human_player_name}`) - bot.chat(`/tp ${this.name} ${human_player_name}`) + console.log(`Teleporting ${this.name} to human ${human_player_name}`); + bot.chat(`/tp ${this.name} ${human_player_name}`); } else { - console.log(`Teleporting ${this.name} to ${this.available_agents[0]}`) + console.log(`Teleporting ${this.name} to ${this.available_agents[0]}`); bot.chat(`/tp ${this.name} ${this.available_agents[0]}`); } @@ -587,7 +586,7 @@ export class Task { } } else{ - console.log('no construction blueprint?') + console.log('no construction blueprint?'); } } } diff --git a/src/agent/vision/browser_viewer.js b/src/agent/vision/browser_viewer.js index 6cce3ed03..41c0381c7 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: '127.0.0.1', 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/utils/examples.js b/src/utils/examples.js index 470663d20..02e0b71d6 100644 --- a/src/utils/examples.js +++ b/src/utils/examples.js @@ -37,7 +37,7 @@ export class Examples { // Wait for all embeddings to complete await Promise.all(embeddingPromises); - } catch (err) { + } catch (_err) { console.warn('Error with embedding model, using word-overlap instead.'); this.model = null; } @@ -70,7 +70,7 @@ export class Examples { console.log('selected examples:'); for (let example of selected_examples) { - console.log('Example:', example[0].content) + console.log('Example:', example[0].content); } let msg = 'Examples of how to respond:\n'; diff --git a/src/utils/mcdata.js b/src/utils/mcdata.js index d3601c52f..dbf365271 100644 --- a/src/utils/mcdata.js +++ b/src/utils/mcdata.js @@ -5,9 +5,11 @@ import prismarine_items from 'prismarine-item'; import { pathfinder } from 'mineflayer-pathfinder'; import { plugin as pvp } from 'mineflayer-pvp'; import { plugin as collectblock } from 'mineflayer-collectblock'; -import { plugin as autoEat } from 'mineflayer-auto-eat'; +import { loader as autoEat } from 'mineflayer-auto-eat'; import plugin from 'mineflayer-armor-manager'; +import baritoneModule from '@miner-org/mineflayer-baritone'; const armorManager = plugin; +const baritone = baritoneModule.loader; let mc_version = settings.minecraft_version; let mcdata = null; let Item = null; @@ -65,11 +67,13 @@ export function initBot(username) { } const bot = createBot(options); + bot.setMaxListeners(25); bot.loadPlugin(pathfinder); bot.loadPlugin(pvp); bot.loadPlugin(collectblock); bot.loadPlugin(autoEat); bot.loadPlugin(armorManager); // auto equip armor + bot.loadPlugin(baritone); // RC25: Baritone A* pathfinding via bot.ashfinder bot.once('resourcePack', () => { bot.acceptResourcePack(); }); @@ -80,6 +84,20 @@ export function initBot(username) { Item = prismarine_items(mc_version); }); + // RC25b: Configure Baritone pathfinding defaults after spawn + bot.once('spawn', () => { + if (bot.ashfinder) { + bot.ashfinder.config.thinkTimeout = 10000; // 10s path compute timeout + bot.ashfinder.config.breakBlocks = false; // RC26: prefer doors/routing over breaking — last-resort break handled by startDoorInterval + bot.ashfinder.config.placeBlocks = false; // RC25b: DISABLED — Paper rejects client-side placements, creating ghost blocks + bot.ashfinder.config.parkour = false; // disable parkour (can get stuck) + bot.ashfinder.config.swimming = true; // allow swimming + bot.ashfinder.config.maxFallDist = 4; // safe fall distance + bot.ashfinder.config.maxWaterDist = 256; // water travel limit + bot.ashfinder.config.stuckTimeout = 2000; // RC25b: detect stuck faster (default 5000) + } + }); + return bot; } diff --git a/src/utils/usage_tracker.js b/src/utils/usage_tracker.js new file mode 100644 index 000000000..307f8f320 --- /dev/null +++ b/src/utils/usage_tracker.js @@ -0,0 +1,161 @@ +import { writeFileSync, readFileSync, mkdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Cost per million tokens (USD) — active providers only +const COST_TABLE = { + // Google Gemini + 'gemini-2.5-pro': { input: 1.25, output: 10.00 }, + 'gemini-2.5-flash': { input: 0.15, output: 0.60 }, + 'gemini-embedding-001': { input: 0.00, output: 0.00 }, + // xAI Grok + 'grok-4-1-fast-non-reasoning': { input: 0.20, output: 0.50 }, + 'grok-4-1-fast': { input: 0.20, output: 0.50 }, + 'grok-code-fast-1': { input: 0.20, output: 1.50 }, + 'grok-3-mini-latest': { input: 0.30, output: 0.50 }, + 'grok-2-vision-1212': { input: 2.00, output: 10.00 }, + // Ollama (local) — free + '_ollama_default': { input: 0.00, output: 0.00 }, + // vLLM (local) — free + '_vllm_default': { input: 0.00, output: 0.00 }, +}; + +function getCostPerMillion(modelName) { + if (!modelName) return null; + if (COST_TABLE[modelName]) return COST_TABLE[modelName]; + for (const [key, val] of Object.entries(COST_TABLE)) { + if (key.startsWith('_') ) continue; // skip local defaults + if (modelName.startsWith(key)) return val; + } + return null; +} + +export class UsageTracker { + constructor(agentName) { + this.agentName = agentName; + this.filePath = path.join(__dirname, `../../bots/${agentName}/usage.json`); + this.data = this._defaultData(); + this._dirty = false; + this._saveInterval = null; + this._recentCalls = []; // rolling window for RPM/TPM + } + + _defaultData() { + return { + agent: this.agentName, + session_start: new Date().toISOString(), + models: {}, + totals: { + calls: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + estimated_cost_usd: 0, + }, + }; + } + + load() { + try { + const raw = readFileSync(this.filePath, 'utf8'); + this.data = JSON.parse(raw); + } catch (_err) { + this.data = this._defaultData(); + } + // Clear any existing interval to prevent double-timer leak + if (this._saveInterval) clearInterval(this._saveInterval); + this._saveInterval = setInterval(() => { + if (this._dirty) this.saveSync(); + }, 30_000); + } + + saveSync() { + try { + const dir = path.dirname(this.filePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(this.filePath, JSON.stringify(this.data, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[UsageTracker] Save failed for ${this.agentName}:`, err.message); + } + } + + record(modelName, provider, callType, usage) { + const key = modelName || `${provider}/unknown`; + if (!this.data.models[key]) { + this.data.models[key] = { + provider: provider, + calls: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + estimated_cost_usd: 0, + by_type: {}, + }; + } + const m = this.data.models[key]; + m.calls++; + + const pt = usage?.prompt_tokens || 0; + const ct = usage?.completion_tokens || 0; + const tt = usage?.total_tokens || pt + ct; + + m.prompt_tokens += pt; + m.completion_tokens += ct; + m.total_tokens += tt; + + if (!m.by_type[callType]) { + m.by_type[callType] = { calls: 0, prompt_tokens: 0, completion_tokens: 0 }; + } + m.by_type[callType].calls++; + m.by_type[callType].prompt_tokens += pt; + m.by_type[callType].completion_tokens += ct; + + const isLocal = provider === 'ollama' || provider === 'vllm'; + const costInfo = isLocal ? COST_TABLE._vllm_default : getCostPerMillion(modelName); + if (costInfo) { + const callCost = (pt * costInfo.input + ct * costInfo.output) / 1_000_000; + m.estimated_cost_usd += callCost; + this.data.totals.estimated_cost_usd += callCost; + } + + this.data.totals.calls++; + this.data.totals.prompt_tokens += pt; + this.data.totals.completion_tokens += ct; + this.data.totals.total_tokens += tt; + this.data.last_call = new Date().toISOString(); + this._dirty = true; + + // Rolling window for RPM/TPM (cap at 1000 entries to prevent unbounded growth) + this._recentCalls.push({ timestamp: Date.now(), tokens: tt }); + if (this._recentCalls.length > 1000) { + this._cleanRollingWindow(); + } + } + + _cleanRollingWindow() { + const cutoff = Date.now() - 60_000; + this._recentCalls = this._recentCalls.filter(c => c.timestamp > cutoff); + } + + _getRPM() { + this._cleanRollingWindow(); + return this._recentCalls.length; + } + + _getTPM() { + this._cleanRollingWindow(); + return this._recentCalls.reduce((sum, c) => sum + c.tokens, 0); + } + + getSnapshot() { + return { ...this.data, rpm: this._getRPM(), tpm: this._getTPM() }; + } + + destroy() { + if (this._saveInterval) clearInterval(this._saveInterval); + if (this._dirty) this.saveSync(); + } +}