diff --git a/AGENTS.md b/AGENTS.md index 1da1ee1..5628e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,46 @@ The two files that shape your day-to-day choices: When no operator task is active and chat is quiet, you work towards the goal. See Operating principle #5 below for the priority order. +## Perception → decision → action (read FIRST before any world action) + +You used to be blind: you knew your coords but nothing about what was around you. Now you have proper perception. The cycle is: + +1. **Observe.** Call `mc_observe` first. You get a JSON snapshot of position, health, food, time of day, weather, biome, nearby block types, nearby entities (mobs + players + items), inventory counts. +2. **Decide.** Based on the snapshot, pick the **single** most relevant next action. Don't plan 5 steps deep; the snapshot will change after you act. +3. **Act.** Call ONE high-level tool from the catalog below. +4. **Loop.** Observe again. Append one short diary line if a milestone shifted. + +### Tool catalog — mindcraft-skills.ts (use these) + +**Perception (cheap, read-only):** + +- `mc_observe(radius?)` — one-shot full snapshot. **Use this first in autonomous mode.** +- `mc_inventory()` — items as `{name: count}`. +- `mc_nearby_blocks(radius?)` — distinct block types within radius (default 16). +- `mc_nearby_entities(radius?)` — players, mobs, dropped items with approximate distances. + +**Actions (write to world, may take seconds-to-minutes):** + +- `mc_collect_block(blockType, count?)` — walk to a block of that type, equip the right tool, mine N of them, pick them up. The all-in-one "gather wood / stone / iron" primitive. +- `mc_place_block(blockType, x, y, z)` — place one block from inventory at exact coords. +- `mc_go_to(x, y, z, minDistance?)` — pathfinder navigation with permission to dig soft obstacles (leaves) and jump. +- `mc_go_to_block(blockType, minDistance?, range?)` — find nearest block of type within `range` and walk there. +- `mc_craft(itemName, num?)` — craft from inventory. Uses a nearby crafting table when needed. +- `mc_equip(itemName)` — hold a tool or wear armor. +- `mc_consume(itemName?)` — eat food. Empty arg = first food in inventory. +- `mc_defend_self(range?)` — attack hostile mobs within range until clear. Uses best weapon. +- `mc_avoid_enemies(distance?)` — run away from nearest hostiles by ~N blocks. +- `mc_stay(seconds?)` — stand still N seconds (default 30). Use to wait out night or regen. +- `mc_pickup_nearby()` — collect dropped items in vicinity. + +### Deprecated (do not use) + +- `mc_build_pyramid_5x5` — narrow-purpose, pre-perception era. Build via `mc_place_block` loops if needed. +- `mc_dig(x, y, z)` — too low-level. Use `mc_collect_block(blockType, n)` which handles the whole cycle. +- `mc_goto(x, y, z)` — had over-strict safety guards that refused legitimate paths. Replaced by `mc_go_to` from mindcraft-skills. + +The deprecated tools may still appear in your registry for now; ignore them. They will be removed in a follow-up cleanup. + ## Your tools right now When you start, you have: diff --git a/extensions/lib/LICENSE-MINDCRAFT b/extensions/lib/LICENSE-MINDCRAFT new file mode 100644 index 0000000..1d5880c --- /dev/null +++ b/extensions/lib/LICENSE-MINDCRAFT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Kolby Nottingham + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/lib/mcdata.js b/extensions/lib/mcdata.js new file mode 100644 index 0000000..918b917 --- /dev/null +++ b/extensions/lib/mcdata.js @@ -0,0 +1,596 @@ +import minecraftData from 'minecraft-data'; +import settings from './settings.js'; +import { createBot } from 'mineflayer'; +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'; +// mineflayer-auto-eat removed: our installed 5.x exports a default plugin +// instead of a named one; we skip it entirely because skills.consume() +// works via bot.consume() on plain Mineflayer. +// import { plugin as autoEat } from 'mineflayer-auto-eat'; +import plugin from 'mineflayer-armor-manager'; +const armorManager = plugin; +let mc_version = settings.minecraft_version; +let mcdata = null; +let Item = null; + +/** + * @typedef {string} ItemName + * @typedef {string} BlockName +*/ + +export const WOOD_TYPES = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', 'mangrove', 'cherry']; +export const MATCHING_WOOD_BLOCKS = [ + 'log', + 'planks', + 'sign', + 'boat', + 'fence_gate', + 'door', + 'fence', + 'slab', + 'stairs', + 'button', + 'pressure_plate', + 'trapdoor' +] +export const WOOL_COLORS = [ + 'white', + 'orange', + 'magenta', + 'light_blue', + 'yellow', + 'lime', + 'pink', + 'gray', + 'light_gray', + 'cyan', + 'purple', + 'blue', + 'brown', + 'green', + 'red', + 'black' +] + + +export function initBot(username) { + const options = { + username: username, + host: settings.host, + port: settings.port, + auth: settings.auth, + version: mc_version, + checkTimeoutInterval: 60000, // 60s keep-alive check (default 30s) — reduces disconnects on slow servers + } + if (!mc_version || mc_version === "auto") { + delete options.version; + } + + const bot = createBot(options); + + // Throttle position packets to avoid kicks on Paper/Spigot servers + // Paper enforces stricter packet rate limits than vanilla, causing ECONNRESET + // when mineflayer sends position updates faster than 50ms apart + let lastPositionUpdate = 0; + let pendingPositionPacket = null; + const POSITION_THROTTLE_MS = 50; + const originalWrite = bot._client.write.bind(bot._client); + bot._client.write = function(name, data) { + if (name === 'position' || name === 'position_look' || name === 'look') { + const now = Date.now(); + if (now - lastPositionUpdate < POSITION_THROTTLE_MS) { + // Queue this packet so the last position update is never lost + if (!pendingPositionPacket) { + pendingPositionPacket = setTimeout(() => { + pendingPositionPacket = null; + lastPositionUpdate = Date.now(); + originalWrite(name, data); + }, POSITION_THROTTLE_MS - (now - lastPositionUpdate)); + } + return; + } + lastPositionUpdate = now; + if (pendingPositionPacket) { + clearTimeout(pendingPositionPacket); + pendingPositionPacket = null; + } + } + return originalWrite(name, data); + }; + + // Suppress PartialReadError for non-critical packets + // Paper servers sometimes send packets that node-minecraft-protocol + // can't fully parse (scoreboard, resource_pack, custom_payload, etc.) + // These errors crash the bot but the packets aren't needed for gameplay + const originalEmit = bot._client.emit.bind(bot._client); + bot._client.emit = function(event, ...args) { + if (event === 'error' && args[0]) { + const err = args[0]; + const errStr = err instanceof Error ? err.message : String(err); + if (errStr.includes('PartialReadError')) { + console.warn('[mcdata] Suppressed PartialReadError:', errStr.substring(0, 120)); + return true; // Swallow the error + } + } + return originalEmit(event, ...args); + }; + + bot.loadPlugin(pathfinder); + bot.loadPlugin(pvp); + bot.loadPlugin(collectblock); + // bot.loadPlugin(autoEat); // removed — see import comment + bot.loadPlugin(armorManager); // auto equip armor + bot.once('resourcePack', () => { + bot.acceptResourcePack(); + }); + + bot.once('login', () => { + mc_version = bot.version; + mcdata = minecraftData(mc_version); + Item = prismarine_items(mc_version); + }); + + return bot; +} + +// pepa-pi-bot addition: attach pathing/combat/collect plugins to an +// EXTERNALLY-created bot (we use our own mineflayer.createBot() in +// mineflayer-bridge.ts). Mindcraft's initBot() above creates the bot itself; +// this exposes the same plugin+login wiring for our case. +// +// auto-eat is intentionally NOT loaded — our installed version is 5.x while +// Mindcraft's 3.x has diverged. skills.consume() falls back to bot.consume() +// which works on plain Mineflayer without the plugin. +export function attachPluginsAndInit(bot) { + bot.loadPlugin(pathfinder); + bot.loadPlugin(pvp); + bot.loadPlugin(collectblock); + bot.loadPlugin(armorManager); + bot.once('login', () => { + mc_version = bot.version; + mcdata = minecraftData(mc_version); + Item = prismarine_items(mc_version); + }); + return bot; +} + +export function isHuntable(mob) { + if (!mob || !mob.name) return false; + const animals = ['chicken', 'cow', 'llama', 'mooshroom', 'pig', 'rabbit', 'sheep']; + return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16]; // metadata 16 is not baby +} + +export function isHostile(mob) { + if (!mob || !mob.name) return false; + return (mob.type === 'mob' || mob.type === 'hostile') && mob.name !== 'iron_golem' && mob.name !== 'snow_golem'; +} + +// blocks that don't work with collectBlock, need to be manually collected +export function mustCollectManually(blockName) { + // all crops (that aren't normal blocks), torches, buttons, levers, redstone, + const full_names = ['wheat', 'carrots', 'potatoes', 'beetroots', 'nether_wart', 'cocoa', 'sugar_cane', 'kelp', 'short_grass', 'fern', 'tall_grass', 'bamboo', + 'poppy', 'dandelion', 'blue_orchid', 'allium', 'azure_bluet', 'oxeye_daisy', 'cornflower', 'lilac', 'wither_rose', 'lily_of_the_valley', 'wither_rose', + 'lever', 'redstone_wire', 'lantern'] + const partial_names = ['sapling', 'torch', 'button', 'carpet', 'pressure_plate', 'mushroom', 'tulip', 'bush', 'vines', 'fern'] + return full_names.includes(blockName.toLowerCase()) || partial_names.some(partial => blockName.toLowerCase().includes(partial)); +} + +export function getItemId(itemName) { + let item = mcdata.itemsByName[itemName]; + if (item) { + return item.id; + } + return null; +} + +export function getItemName(itemId) { + let item = mcdata.items[itemId] + if (item) { + return item.name; + } + return null; +} + +export function getBlockId(blockName) { + let block = mcdata.blocksByName[blockName]; + if (block) { + return block.id; + } + return null; +} + +export function getBlockName(blockId) { + let block = mcdata.blocks[blockId] + if (block) { + return block.name; + } + return null; +} + +export function getEntityId(entityName) { + let entity = mcdata.entitiesByName[entityName]; + if (entity) { + return entity.id; + } + return null; +} + +export function getAllItems(ignore) { + if (!ignore) { + ignore = []; + } + let items = [] + for (const itemId in mcdata.items) { + const item = mcdata.items[itemId]; + if (!ignore.includes(item.name)) { + items.push(item); + } + } + return items; +} + +export function getAllItemIds(ignore) { + const items = getAllItems(ignore); + let itemIds = []; + for (const item of items) { + itemIds.push(item.id); + } + return itemIds; +} + +export function getAllBlocks(ignore) { + if (!ignore) { + ignore = []; + } + let blocks = [] + for (const blockId in mcdata.blocks) { + const block = mcdata.blocks[blockId]; + if (!ignore.includes(block.name)) { + blocks.push(block); + } + } + return blocks; +} + +export function getAllBlockIds(ignore) { + const blocks = getAllBlocks(ignore); + let blockIds = []; + for (const block of blocks) { + blockIds.push(block.id); + } + return blockIds; +} + +export function getAllBiomes() { + return mcdata.biomes; +} + +export function getItemCraftingRecipes(itemName) { + let itemId = getItemId(itemName); + if (!mcdata.recipes[itemId]) { + return null; + } + + let recipes = []; + for (let r of mcdata.recipes[itemId]) { + let recipe = {}; + let ingredients = []; + if (r.ingredients) { + ingredients = r.ingredients; + } else if (r.inShape) { + ingredients = r.inShape.flat(); + } + for (let ingredient of ingredients) { + let ingredientName = getItemName(ingredient); + if (ingredientName === null) continue; + if (!recipe[ingredientName]) + recipe[ingredientName] = 0; + recipe[ingredientName]++; + } + recipes.push([ + recipe, + {craftedCount : r.result.count} + ]); + } + // sort recipes by if their ingredients include common items + const commonItems = ['oak_planks', 'oak_log', 'coal', 'cobblestone']; + recipes.sort((a, b) => { + let commonCountA = Object.keys(a[0]).filter(key => commonItems.includes(key)).reduce((acc, key) => acc + a[0][key], 0); + let commonCountB = Object.keys(b[0]).filter(key => commonItems.includes(key)).reduce((acc, key) => acc + b[0][key], 0); + return commonCountB - commonCountA; + }); + + return recipes; +} + +export function isSmeltable(itemName) { + const misc_smeltables = ['beef', 'chicken', 'cod', 'mutton', 'porkchop', 'rabbit', 'salmon', 'tropical_fish', 'potato', 'kelp', 'sand', 'cobblestone', 'clay_ball']; + return itemName.includes('raw') || itemName.includes('log') || misc_smeltables.includes(itemName); +} + +export function getSmeltingFuel(bot) { + let fuel = bot.inventory.items().find(i => i.name === 'coal' || i.name === 'charcoal' || i.name === 'blaze_rod') + if (fuel) + return fuel; + fuel = bot.inventory.items().find(i => i.name.includes('log') || i.name.includes('planks')) + if (fuel) + return fuel; + return bot.inventory.items().find(i => i.name === 'coal_block' || i.name === 'lava_bucket'); +} + +export function getFuelSmeltOutput(fuelName) { + if (fuelName === 'coal' || fuelName === 'charcoal') + return 8; + if (fuelName === 'blaze_rod') + return 12; + if (fuelName.includes('log') || fuelName.includes('planks')) + return 1.5 + if (fuelName === 'coal_block') + return 80; + if (fuelName === 'lava_bucket') + return 100; + return 0; +} + +export function getItemSmeltingIngredient(itemName) { + return { + baked_potato: 'potato', + steak: 'raw_beef', + cooked_chicken: 'raw_chicken', + cooked_cod: 'raw_cod', + cooked_mutton: 'raw_mutton', + cooked_porkchop: 'raw_porkchop', + cooked_rabbit: 'raw_rabbit', + cooked_salmon: 'raw_salmon', + dried_kelp: 'kelp', + iron_ingot: 'raw_iron', + gold_ingot: 'raw_gold', + copper_ingot: 'raw_copper', + glass: 'sand' + }[itemName]; +} + +export function getItemBlockSources(itemName) { + let itemId = getItemId(itemName); + let sources = []; + for (let block of getAllBlocks()) { + if (block.drops.includes(itemId)) { + sources.push(block.name); + } + } + return sources; +} + +export function getItemAnimalSource(itemName) { + return { + raw_beef: 'cow', + raw_chicken: 'chicken', + raw_cod: 'cod', + raw_mutton: 'sheep', + raw_porkchop: 'pig', + raw_rabbit: 'rabbit', + raw_salmon: 'salmon', + leather: 'cow', + wool: 'sheep' + }[itemName]; +} + +export function getBlockTool(blockName) { + let block = mcdata.blocksByName[blockName]; + if (!block || !block.harvestTools) { + return null; + } + return getItemName(Object.keys(block.harvestTools)[0]); // Double check first tool is always simplest +} + +export function makeItem(name, amount=1) { + return new Item(getItemId(name), amount); +} + +/** + * Returns the number of ingredients required to use the recipe once. + * + * @param {Recipe} recipe + * @returns {Object} an object describing the number of each ingredient. + */ +export function ingredientsFromPrismarineRecipe(recipe) { + let requiredIngedients = {}; + if (recipe.inShape) + for (const ingredient of recipe.inShape.flat()) { + if(ingredient.id<0) continue; //prismarine-recipe uses id -1 as an empty crafting slot + const ingredientName = getItemName(ingredient.id); + requiredIngedients[ingredientName] ??=0; + requiredIngedients[ingredientName] += ingredient.count; + } + if (recipe.ingredients) + for (const ingredient of recipe.ingredients) { + if(ingredient.id<0) continue; + const ingredientName = getItemName(ingredient.id); + requiredIngedients[ingredientName] ??=0; + requiredIngedients[ingredientName] -= ingredient.count; + //Yes, the `-=` is intended. + //prismarine-recipe uses positive numbers for the shaped ingredients but negative for unshaped. + //Why this is the case is beyond my understanding. + } + return requiredIngedients; +} + +/** + * Calculates the number of times an action, such as a crafing recipe, can be completed before running out of resources. + * @template T - doesn't have to be an item. This could be any resource. + * @param {Object.} availableItems - The resources available; e.g, `{'cobble_stone': 7, 'stick': 10}` + * @param {Object.} requiredItems - The resources required to complete the action once; e.g, `{'cobble_stone': 3, 'stick': 2}` + * @param {boolean} discrete - Is the action discrete? + * @returns {{num: number, limitingResource: (T | null)}} the number of times the action can be completed and the limmiting resource; e.g `{num: 2, limitingResource: 'cobble_stone'}` + */ +export function calculateLimitingResource(availableItems, requiredItems, discrete=true) { + let limitingResource = null; + let num = Infinity; + for (const itemType in requiredItems) { + if (availableItems[itemType] < requiredItems[itemType] * num) { + limitingResource = itemType; + num = availableItems[itemType] / requiredItems[itemType]; + } + } + if(discrete) num = Math.floor(num); + return {num, limitingResource} +} + +let loopingItems = new Set(); + +export function initializeLoopingItems() { + + loopingItems = new Set(['coal', + 'wheat', + 'bone_meal', + 'diamond', + 'emerald', + 'raw_iron', + 'raw_gold', + 'redstone', + 'blue_wool', + 'packed_mud', + 'raw_copper', + 'iron_ingot', + 'dried_kelp', + 'gold_ingot', + 'slime_ball', + 'black_wool', + 'quartz_slab', + 'copper_ingot', + 'lapis_lazuli', + 'honey_bottle', + 'rib_armor_trim_smithing_template', + 'eye_armor_trim_smithing_template', + 'vex_armor_trim_smithing_template', + 'dune_armor_trim_smithing_template', + 'host_armor_trim_smithing_template', + 'tide_armor_trim_smithing_template', + 'wild_armor_trim_smithing_template', + 'ward_armor_trim_smithing_template', + 'coast_armor_trim_smithing_template', + 'spire_armor_trim_smithing_template', + 'snout_armor_trim_smithing_template', + 'shaper_armor_trim_smithing_template', + 'netherite_upgrade_smithing_template', + 'raiser_armor_trim_smithing_template', + 'sentry_armor_trim_smithing_template', + 'silence_armor_trim_smithing_template', + 'wayfinder_armor_trim_smithing_template']); +} + + +/** + * Gets a detailed plan for crafting an item considering current inventory + */ +export function getDetailedCraftingPlan(targetItem, count = 1, current_inventory = {}) { + initializeLoopingItems(); + if (!targetItem || count <= 0 || !getItemId(targetItem)) { + return "Invalid input. Please provide a valid item name and positive count."; + } + + if (isBaseItem(targetItem)) { + const available = current_inventory[targetItem] || 0; + if (available >= count) return "You have all required items already in your inventory!"; + return `${targetItem} is a base item, you need to find ${count - available} more in the world`; + } + + const inventory = { ...current_inventory }; + const leftovers = {}; + const plan = craftItem(targetItem, count, inventory, leftovers); + return formatPlan(targetItem, plan); +} + +function isBaseItem(item) { + return loopingItems.has(item) || getItemCraftingRecipes(item) === null; +} + +function craftItem(item, count, inventory, leftovers, crafted = { required: {}, steps: [], leftovers: {} }) { + // Check available inventory and leftovers first + const availableInv = inventory[item] || 0; + const availableLeft = leftovers[item] || 0; + const totalAvailable = availableInv + availableLeft; + + if (totalAvailable >= count) { + // Use leftovers first, then inventory + const useFromLeft = Math.min(availableLeft, count); + leftovers[item] = availableLeft - useFromLeft; + + const remainingNeeded = count - useFromLeft; + if (remainingNeeded > 0) { + inventory[item] = availableInv - remainingNeeded; + } + return crafted; + } + + // Use whatever is available + const stillNeeded = count - totalAvailable; + if (availableLeft > 0) leftovers[item] = 0; + if (availableInv > 0) inventory[item] = 0; + + if (isBaseItem(item)) { + crafted.required[item] = (crafted.required[item] || 0) + stillNeeded; + return crafted; + } + + const recipe = getItemCraftingRecipes(item)?.[0]; + if (!recipe) { + crafted.required[item] = stillNeeded; + return crafted; + } + + const [ingredients, result] = recipe; + const craftedPerRecipe = result.craftedCount; + const batchCount = Math.ceil(stillNeeded / craftedPerRecipe); + const totalProduced = batchCount * craftedPerRecipe; + + // Add excess to leftovers + if (totalProduced > stillNeeded) { + leftovers[item] = (leftovers[item] || 0) + (totalProduced - stillNeeded); + } + + // Process each ingredient + for (const [ingredientName, ingredientCount] of Object.entries(ingredients)) { + const totalIngredientNeeded = ingredientCount * batchCount; + craftItem(ingredientName, totalIngredientNeeded, inventory, leftovers, crafted); + } + + // Add crafting step + const stepIngredients = Object.entries(ingredients) + .map(([name, amount]) => `${amount * batchCount} ${name}`) + .join(' + '); + crafted.steps.push(`Craft ${stepIngredients} -> ${totalProduced} ${item}`); + + return crafted; +} + +function formatPlan(targetItem, { required, steps, leftovers }) { + const lines = []; + + if (Object.keys(required).length > 0) { + lines.push('You are missing the following items:'); + Object.entries(required).forEach(([item, count]) => + lines.push(`- ${count} ${item}`)); + lines.push('\nOnce you have these items, here\'s your crafting plan:'); + } else { + lines.push('You have all items required to craft this item!'); + lines.push('Here\'s your crafting plan:'); + } + + lines.push(''); + lines.push(...steps); + + if (Object.keys(required).some(item => item.includes('oak')) && !targetItem.includes('oak')) { + lines.push('Note: Any varient of wood can be used for this recipe.'); + } + + if (Object.keys(leftovers).length > 0) { + lines.push('\nYou will have leftover:'); + Object.entries(leftovers).forEach(([item, count]) => + lines.push(`- ${count} ${item}`)); + } + + return lines.join('\n'); +} diff --git a/extensions/lib/settings.js b/extensions/lib/settings.js new file mode 100644 index 0000000..0c57984 --- /dev/null +++ b/extensions/lib/settings.js @@ -0,0 +1,11 @@ +// Minimal settings stub adapted from Mindcraft for the pepa-pi-bot vendored +// world.js / skills.js / mcdata.js library. Mindcraft expects a settings module +// with these defaults; we use sane farmer-bot values. +// +// Source: github.com/kolbytn/mindcraft (MIT) +export default { + block_place_delay: 0, + minecraft_version: "auto", + allow_insecure_coding: false, + log_all_prompts: false, +}; diff --git a/extensions/lib/skills.js b/extensions/lib/skills.js new file mode 100644 index 0000000..26f310c --- /dev/null +++ b/extensions/lib/skills.js @@ -0,0 +1,2093 @@ +import * as mc from "./mcdata.js"; +import * as world from "./world.js"; +import pf from 'mineflayer-pathfinder'; +import Vec3 from 'vec3'; +import settings from "./settings.js"; + +const blockPlaceDelay = settings.block_place_delay == null ? 0 : settings.block_place_delay; +const useDelay = blockPlaceDelay > 0; + +export function log(bot, message) { + bot.output += message + '\n'; +} + +async function autoLight(bot) { + if (world.shouldPlaceTorch(bot)) { + try { + const pos = world.getPosition(bot); + return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); + } catch (err) {return false;} + } + return false; +} + +async function equipHighestAttack(bot) { + let weapons = bot.inventory.items().filter(item => item.name.includes('sword') || (item.name.includes('axe') && !item.name.includes('pickaxe'))); + if (weapons.length === 0) + weapons = bot.inventory.items().filter(item => item.name.includes('pickaxe') || item.name.includes('shovel')); + if (weapons.length === 0) + return; + weapons.sort((a, b) => b.attackDamage - a.attackDamage); + let weapon = weapons[0]; + if (weapon) + await bot.equip(weapon, 'hand'); +} + +export async function craftRecipe(bot, itemName, num=1) { + /** + * Attempt to craft the given item name from a recipe. May craft many items. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item name to craft. + * @returns {Promise} true if the recipe was crafted, false otherwise. + * @example + * await skills.craftRecipe(bot, "stick"); + **/ + let placedTable = false; + + if (mc.getItemCraftingRecipes(itemName).length == 0) { + log(bot, `${itemName} is either not an item, or it does not have a crafting recipe!`); + return false; + } + + // get recipes that don't require a crafting table + let recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, null); + let craftingTable = null; + const craftingTableRange = 16; + placeTable: if (!recipes || recipes.length === 0) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, true); + if(!recipes || recipes.length === 0) break placeTable; //Don't bother going to the table if we don't have the required resources. + + // Look for crafting table + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); + if (craftingTable === null){ + + // Try to place crafting table + let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; + if (hasTable) { + let pos = world.getNearestFreeSpace(bot, 1, 6); + 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; + } + } + else { + log(bot, `Crafting ${itemName} requires a crafting table.`) + return false; + } + } + else { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable); + } + } + if (!recipes || recipes.length === 0) { + log(bot, `You do not have the resources to craft a ${itemName}. It requires: ${Object.entries(mc.getItemCraftingRecipes(itemName)[0][0]).map(([key, value]) => `${key}: ${value}`).join(', ')}.`); + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1); + } + return false; + } + + if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { + await goToNearestBlock(bot, 'crafting_table', 4, craftingTableRange); + } + + const recipe = recipes[0]; + console.log('crafting...'); + //Check that the agent has sufficient items to use the recipe `num` times. + const inventory = world.getInventoryCounts(bot); //Items in the agents inventory + 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); + if(craftLimit.num} true if the wait was successful, false otherwise. + * @example + * await skills.wait(bot, 1000); + **/ + // setTimeout is disabled to prevent unawaited code, so this is a safe alternative that enables interrupts + let timeLeft = milliseconds; + let startTime = Date.now(); + + while (timeLeft > 0) { + if (bot.interrupt_code) return false; + + let waitTime = Math.min(2000, timeLeft); + await new Promise(resolve => setTimeout(resolve, waitTime)); + + let elapsed = Date.now() - startTime; + timeLeft = milliseconds - elapsed; + } + return true; +} + +export async function smeltItem(bot, itemName, num=1) { + /** + * Puts 1 coal in furnace and smelts the given item name, waits until the furnace runs out of fuel or input items. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item name to smelt. Ores must contain "raw" like raw_iron. + * @param {number} num, the number of items to smelt. Defaults to 1. + * @returns {Promise} true if the item was smelted, false otherwise. Fail + * @example + * await skills.smeltItem(bot, "raw_iron"); + * await skills.smeltItem(bot, "beef"); + **/ + + if (!mc.isSmeltable(itemName)) { + log(bot, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`); + return false; + } + + let placedFurnace = false; + let furnaceBlock = undefined; + const furnaceRange = 16; + furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange); + if (!furnaceBlock){ + // Try to place furnace + let hasFurnace = world.getInventoryCounts(bot)['furnace'] > 0; + if (hasFurnace) { + let pos = world.getNearestFreeSpace(bot, 1, furnaceRange); + await placeBlock(bot, 'furnace', pos.x, pos.y, pos.z); + furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange); + placedFurnace = true; + } + } + if (!furnaceBlock){ + log(bot, `There is no furnace nearby and you have no furnace.`) + return false; + } + if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToNearestBlock(bot, 'furnace', 4, furnaceRange); + } + bot.modes.pause('unstuck'); + await bot.lookAt(furnaceBlock.position); + + console.log('smelting...'); + const furnace = await bot.openFurnace(furnaceBlock); + // check if the furnace is already smelting something + let input_item = furnace.inputItem(); + if (input_item && input_item.type !== mc.getItemId(itemName) && input_item.count > 0) { + // TODO: check if furnace is currently burning fuel. furnace.fuel is always null, I think there is a bug. + // This only checks if the furnace has an input item, but it may not be smelting it and should be cleared. + log(bot, `The furnace is currently smelting ${mc.getItemName(input_item.type)}.`); + if (placedFurnace) + await collectBlock(bot, 'furnace', 1); + return false; + } + // check if the bot has enough items to smelt + let inv_counts = world.getInventoryCounts(bot); + if (!inv_counts[itemName] || inv_counts[itemName] < num) { + log(bot, `You do not have enough ${itemName} to smelt.`); + if (placedFurnace) + await collectBlock(bot, 'furnace', 1); + return false; + } + + // fuel the furnace + if (!furnace.fuelItem()) { + let fuel = mc.getSmeltingFuel(bot); + if (!fuel) { + log(bot, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`); + if (placedFurnace) + await collectBlock(bot, 'furnace', 1); + return false; + } + log(bot, `Using ${fuel.name} as fuel.`); + + const put_fuel = Math.ceil(num / mc.getFuelSmeltOutput(fuel.name)); + + if (fuel.count < put_fuel) { + log(bot, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${put_fuel}.`); + if (placedFurnace) + await collectBlock(bot, 'furnace', 1); + return false; + } + 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.`) + } + // put the items in the furnace + await furnace.putInput(mc.getItemId(itemName), null, num); + // wait for the items to smelt + let total = 0; + let smelted_item = null; + await new Promise(resolve => setTimeout(resolve, 200)); + let last_collected = Date.now(); + while (total < num) { + await new Promise(resolve => setTimeout(resolve, 1000)); + if (furnace.outputItem()) { + smelted_item = await furnace.takeOutput(); + if (smelted_item) { + total += smelted_item.count; + last_collected = Date.now(); + } + } + if (Date.now() - last_collected > 11000) { + break; // if nothing has been collected in 11 seconds, stop + } + if (bot.interrupt_code) { + break; + } + } + // take all remaining in input/fuel slots + if (furnace.inputItem()) { + await furnace.takeInput(); + } + if (furnace.fuelItem()) { + await furnace.takeFuel(); + } + + await bot.closeWindow(furnace); + + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1); + } + if (total === 0) { + log(bot, `Failed to smelt ${itemName}.`); + return false; + } + if (total < num) { + log(bot, `Only smelted ${total} ${mc.getItemName(smelted_item.type)}.`); + return false; + } + log(bot, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smelted_item.type)}.`); + return true; +} + +export async function clearNearestFurnace(bot) { + /** + * Clears the nearest furnace of all items. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if the furnace was cleared, false otherwise. + * @example + * await skills.clearNearestFurnace(bot); + **/ + let furnaceBlock = world.getNearestBlock(bot, 'furnace', 32); + if (!furnaceBlock) { + log(bot, `No furnace nearby to clear.`); + return false; + } + if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToNearestBlock(bot, 'furnace', 4, 32); + } + + console.log('clearing furnace...'); + const furnace = await bot.openFurnace(furnaceBlock); + console.log('opened furnace...') + // take the items out of the furnace + let smelted_item, intput_item, fuel_item; + if (furnace.outputItem()) + smelted_item = await furnace.takeOutput(); + if (furnace.inputItem()) + intput_item = await furnace.takeInput(); + if (furnace.fuelItem()) + fuel_item = await furnace.takeFuel(); + 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`; + log(bot, `Cleared furnace, received ${smelted_name}, ${input_name}, and ${fuel_name}.`); + return true; + +} + + +export async function attackNearest(bot, mobType, kill=true) { + /** + * Attack mob of the given type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} mobType, the type of mob to attack. + * @param {boolean} kill, whether or not to continue attacking until the mob is dead. Defaults to true. + * @returns {Promise} true if the mob was attacked, false if the mob type was not found. + * @example + * await skills.attackNearest(bot, "zombie", true); + **/ + bot.modes.pause('cowardice'); + if (mobType === 'drowned' || mobType === 'cod' || mobType === 'salmon' || mobType === 'tropical_fish' || mobType === 'squid') + bot.modes.pause('self_preservation'); // so it can go underwater. TODO: have an drowning mode so we don't turn off all self_preservation + const mob = world.getNearbyEntities(bot, 24).find(entity => entity.name === mobType); + if (mob) { + return await attackEntity(bot, mob, kill); + } + log(bot, 'Could not find any '+mobType+' to attack.'); + return false; +} + +export async function attackEntity(bot, entity, kill=true) { + /** + * Attack mob of the given type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {Entity} entity, the entity to attack. + * @returns {Promise} true if the entity was attacked, false if interrupted + * @example + * await skills.attackEntity(bot, entity); + **/ + + let pos = entity.position; + await equipHighestAttack(bot) + + if (!kill) { + if (bot.entity.position.distanceTo(pos) > 5) { + console.log('moving to mob...') + await goToPosition(bot, pos.x, pos.y, pos.z); + } + console.log('attacking mob...') + await bot.attack(entity); + } + else { + bot.pvp.attack(entity); + while (world.getNearbyEntities(bot, 24).includes(entity)) { + await new Promise(resolve => setTimeout(resolve, 1000)); + if (bot.interrupt_code) { + bot.pvp.stop(); + return false; + } + } + log(bot, `Successfully killed ${entity.name}.`); + await pickupNearbyItems(bot); + return true; + } +} + +export async function defendSelf(bot, range=9) { + /** + * Defend yourself from all nearby hostile mobs until there are no more. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} range, the range to look for mobs. Defaults to 8. + * @returns {Promise} true if the bot found any enemies and has killed them, false if no entities were found. + * @example + * await skills.defendSelf(bot); + * **/ + bot.modes.pause('self_defense'); + bot.modes.pause('cowardice'); + let attacked = false; + let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range); + while (enemy) { + 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 */} + } + 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 */} + } + bot.pvp.attack(enemy); + attacked = true; + await new Promise(resolve => setTimeout(resolve, 500)); + enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range); + if (bot.interrupt_code) { + bot.pvp.stop(); + return false; + } + } + bot.pvp.stop(); + if (attacked) + log(bot, `Successfully defended self.`); + else + log(bot, `No enemies nearby to defend self from.`); + return attacked; +} + + + +export async function collectBlock(bot, blockType, num=1, exclude=null) { + /** + * Collect one of the given block type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} blockType, the type of block to collect. + * @param {number} num, the number of blocks to collect. Defaults to 1. + * @param {list} exclude, a list of positions to exclude from the search. Defaults to null. + * @returns {Promise} true if the block was collected, false if the block type was not found. + * @example + * await skills.collectBlock(bot, "oak_log"); + **/ + if (num < 1) { + log(bot, `Invalid number of blocks to collect: ${num}.`); + return false; + } + let blocktypes = [blockType]; + if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' || blockType === 'redstone') + blocktypes.push(blockType+'_ore'); + if (blockType.endsWith('ore')) + blocktypes.push('deepslate_'+blockType); + if (blockType === 'dirt') + blocktypes.push('grass_block'); + if (blockType === 'cobblestone') + blocktypes.push('stone'); + const isLiquid = blockType === 'lava' || blockType === 'water'; + + let collected = 0; + + const movements = new pf.Movements(bot); + movements.dontMineUnderFallingBlock = false; + movements.dontCreateFlow = true; + + // Blocks to ignore safety for, usually next to lava/water + const unsafeBlocks = ['obsidian']; + + for (let i=0; i { + if (!blocktypes.includes(block.name)) { + return false; + } + if (exclude) { + for (let position of exclude) { + if (block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { + return false; + } + } + } + if (isLiquid) { + // collect only source blocks + return block.metadata === 0; + } + + return movements.safeToBreak(block) || unsafeBlocks.includes(block.name); + }, 64, 1); + + if (blocks.length === 0) { + if (collected === 0) + log(bot, `No ${blockType} nearby to collect.`); + else + log(bot, `No more ${blockType} nearby to collect.`); + break; + } + const block = blocks[0]; + await bot.tool.equipForBlock(block); + if (isLiquid) { + const bucket = bot.inventory.findInventoryItem('bucket'); + if (!bucket) { + log(bot, `Don't have bucket to harvest ${blockType}.`); + return false; + } + await bot.equip(bucket, 'hand'); + } + 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; + 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; + } + else { + await bot.collectBlock.collect(block); + success = true; + } + if (success) + collected++; + await autoLight(bot); + } + catch (err) { + if (err.name === 'NoChests') { + log(bot, `Failed to collect ${blockType}: Inventory full, no place to deposit.`); + break; + } + else { + log(bot, `Failed to collect ${blockType}: ${err}.`); + continue; + } + } + + if (bot.interrupt_code) + break; + } + log(bot, `Collected ${collected} ${blockType}.`); + return collected > 0; +} + +export async function pickupNearbyItems(bot) { + /** + * Pick up all nearby items. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if the items were picked up, false otherwise. + * @example + * await skills.pickupNearbyItems(bot); + **/ + const distance = 8; + 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; + } + pickedUp++; + } + log(bot, `Picked up ${pickedUp} items.`); + return true; +} + + +export async function breakBlockAt(bot, x, y, z) { + /** + * Break the block at the given position. Will use the bot's equipped item. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} x, the x coordinate of the block to break. + * @param {number} y, the y coordinate of the block to break. + * @param {number} z, the z coordinate of the block to break. + * @returns {Promise} true if the block was broken, false otherwise. + * @example + * let position = world.getPosition(bot); + * await skills.breakBlockAt(bot, position.x, position.y - 1, position.x); + **/ + if (x == null || y == null || z == null) throw new Error('Invalid position to break block at.'); + let block = bot.blockAt(Vec3(x, y, z)); + if (block.name !== 'air' && block.name !== 'water' && block.name !== 'lava') { + if (bot.modes.isOn('cheat')) { + if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } + let msg = '/setblock ' + Math.floor(x) + ' ' + Math.floor(y) + ' ' + Math.floor(z) + ' air'; + bot.chat(msg); + log(bot, `Used /setblock to break block at ${x}, ${y}, ${z}.`); + return true; + } + + 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)); + } + if (bot.game.gameMode !== 'creative') { + await bot.tool.equipForBlock(block); + 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; + } + } + await bot.dig(block, true); + log(bot, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`); + } + else { + log(bot, `Skipping block at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)} because it is ${block.name}.`); + return false; + } + return true; +} + + +export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dontCheat=false) { + /** + * Place the given block type at the given position. It will build off from any adjacent blocks. Will fail if there is a block in the way or nothing to build off of. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} blockType, the type of block to place, which can be a block or item name. + * @param {number} x, the x coordinate of the block to place. + * @param {number} y, the y coordinate of the block to place. + * @param {number} z, the z coordinate of the block to place. + * @param {string} placeOn, the preferred side of the block to place on. Can be 'top', 'bottom', 'north', 'south', 'east', 'west', or 'side'. Defaults to bottom. Will place on first available side if not possible. + * @param {boolean} dontCheat, overrides cheat mode to place the block normally. Defaults to false. + * @returns {Promise} true if the block was placed, false otherwise. + * @example + * let p = world.getPosition(bot); + * await skills.placeBlock(bot, "oak_log", p.x + 2, p.y, p.x); + * await skills.placeBlock(bot, "torch", p.x + 1, p.y, p.x, 'side'); + **/ + const target_dest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)); + + if (blockType === 'air') { + log(bot, `Placing air (removing block) at ${target_dest}.`); + return await breakBlockAt(bot, x, y, z); + } + + if (bot.modes.isOn('cheat') && !dontCheat) { + if (bot.restrict_to_inventory) { + let block = bot.inventory.findInventoryItem(blockType); + if (!block) { + log(bot, `Cannot place ${blockType}, you are restricted to your current inventory.`); + return false; + } + } + + // invert the facing direction + let face = placeOn === 'north' ? 'south' : placeOn === 'south' ? 'north' : placeOn === 'east' ? 'west' : 'east'; + if (blockType.includes('torch') && placeOn !== 'bottom') { + // insert wall_ before torch + blockType = blockType.replace('torch', 'wall_torch'); + if (placeOn !== 'side' && placeOn !== 'top') { + blockType += `[facing=${face}]`; + } + } + if (blockType.includes('button') || blockType === 'lever') { + if (placeOn === 'top') { + blockType += `[face=ceiling]`; + } + else if (placeOn === 'bottom') { + blockType += `[face=floor]`; + } + else { + blockType += `[facing=${face}]`; + } + } + if (blockType === 'ladder' || blockType === 'repeater' || blockType === 'comparator') { + blockType += `[facing=${face}]`; + } + if (blockType.includes('stairs')) { + blockType += `[facing=${face}]`; + } + if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } + let msg = '/setblock ' + Math.floor(x) + ' ' + Math.floor(y) + ' ' + Math.floor(z) + ' ' + blockType; + bot.chat(msg); + if (blockType.includes('door')) + if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } + bot.chat('/setblock ' + Math.floor(x) + ' ' + Math.floor(y+1) + ' ' + Math.floor(z) + ' ' + blockType + '[half=upper]'); + if (blockType.includes('bed')) + if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } + bot.chat('/setblock ' + Math.floor(x) + ' ' + Math.floor(y) + ' ' + Math.floor(z-1) + ' ' + blockType + '[part=head]'); + log(bot, `Used /setblock to place ${blockType} at ${target_dest}.`); + return true; + } + + let item_name = blockType; + if (item_name == "redstone_wire") + item_name = "redstone"; + else if (item_name === 'water') { + item_name = 'water_bucket'; + } + else if (item_name === 'lava') { + item_name = 'lava_bucket'; + } + let block_item = bot.inventory.findInventoryItem(item_name); + if (!block_item && bot.game.gameMode === 'creative' && !bot.restrict_to_inventory) { + await bot.creative.setInventorySlot(36, mc.makeItem(item_name, 1)); // 36 is first hotbar slot + block_item = bot.inventory.findInventoryItem(item_name); + } + if (!block_item) { + log(bot, `Don't have any ${item_name} to place.`); + return false; + } + + const targetBlock = bot.blockAt(target_dest); + if (targetBlock.name === blockType || (targetBlock.name === 'grass_block' && blockType === 'dirt')) { + log(bot, `${blockType} already at ${targetBlock.position}.`); + return false; + } + const empty_blocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern']; + if (!empty_blocks.includes(targetBlock.name)) { + log(bot, `${targetBlock.name} in the way at ${targetBlock.position}.`); + const removed = await breakBlockAt(bot, x, y, z); + if (!removed) { + log(bot, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`); + return false; + } + await new Promise(resolve => setTimeout(resolve, 200)); // wait for block to break + } + // get the buildoffblock and facevec based on whichever adjacent block is not empty + let buildOffBlock = null; + let faceVec = null; + const dir_map = { + 'top': Vec3(0, 1, 0), + 'bottom': Vec3(0, -1, 0), + 'north': Vec3(0, 0, -1), + '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']); + } + else if (dir_map[placeOn] !== undefined) { + dirs.push(dir_map[placeOn]); + } + else { + dirs.push(dir_map['bottom']); + log(bot, `Unknown placeOn value "${placeOn}". Defaulting to bottom.`); + } + dirs.push(...Object.values(dir_map).filter(d => !dirs.includes(d))); + + for (let d of dirs) { + const block = bot.blockAt(target_dest.plus(d)); + if (!empty_blocks.includes(block.name)) { + buildOffBlock = block; + faceVec = new Vec3(-d.x, -d.y, -d.z); // invert + break; + } + } + if (!buildOffBlock) { + log(bot, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`); + return false; + } + + const pos = bot.entity.position; + const pos_above = pos.plus(Vec3(0,1,0)); + 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); + } + 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)); + } + + // will throw error if an entity is in the way, and sometimes even if the block was placed + try { + if (item_name.includes('bucket')) { + await useToolOnBlock(bot, item_name, buildOffBlock); + } + else { + await bot.equip(block_item, 'hand'); + await bot.lookAt(buildOffBlock.position.offset(0.5, 0.5, 0.5)); + await bot.placeBlock(buildOffBlock, faceVec); + log(bot, `Placed ${blockType} at ${target_dest}.`); + await new Promise(resolve => setTimeout(resolve, 200)); + return true; + } + } catch (err) { + log(bot, `Failed to place ${blockType} at ${target_dest}.`); + return false; + } +} + +export async function equip(bot, itemName) { + /** + * Equip the given item to the proper body part, like tools or armor. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item or block name to equip. + * @returns {Promise} true if the item was equipped, false otherwise. + * @example + * await skills.equip(bot, "iron_pickaxe"); + **/ + if (itemName === 'hand') { + await bot.unequip('hand'); + log(bot, `Unequipped hand.`); + return true; + } + let item = bot.inventory.slots.find(slot => slot && slot.name === itemName); + if (!item) { + if (bot.game.gameMode === "creative") { + await bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)); + item = bot.inventory.findInventoryItem(itemName); + } + else { + log(bot, `You do not have any ${itemName} to equip.`); + return false; + } + } + if (itemName.includes('leggings')) { + await bot.equip(item, 'legs'); + } + else if (itemName.includes('boots')) { + await bot.equip(item, 'feet'); + } + else if (itemName.includes('helmet')) { + await bot.equip(item, 'head'); + } + else if (itemName.includes('chestplate') || itemName.includes('elytra')) { + await bot.equip(item, 'torso'); + } + else if (itemName.includes('shield')) { + await bot.equip(item, 'off-hand'); + } + else { + await bot.equip(item, 'hand'); + } + log(bot, `Equipped ${itemName}.`); + return true; +} + +export async function discard(bot, itemName, num=-1) { + /** + * Discard the given item. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item or block name to discard. + * @param {number} num, the number of items to discard. Defaults to -1, which discards all items. + * @returns {Promise} true if the item was discarded, false otherwise. + * @example + * await skills.discard(bot, "oak_log"); + **/ + let discarded = 0; + while (true) { + let item = bot.inventory.findInventoryItem(itemName); + if (!item) { + break; + } + let to_discard = num === -1 ? item.count : Math.min(num - discarded, item.count); + await bot.toss(item.type, null, to_discard); + discarded += to_discard; + if (num !== -1 && discarded >= num) { + break; + } + } + if (discarded === 0) { + log(bot, `You do not have any ${itemName} to discard.`); + return false; + } + log(bot, `Discarded ${discarded} ${itemName}.`); + return true; +} + +export async function putInChest(bot, itemName, num=-1) { + /** + * Put the given item in the nearest chest. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item or block name to put in the chest. + * @param {number} num, the number of items to put in the chest. Defaults to -1, which puts all items. + * @returns {Promise} true if the item was put in the chest, false otherwise. + * @example + * await skills.putInChest(bot, "oak_log"); + **/ + let chest = world.getNearestBlock(bot, 'chest', 32); + if (!chest) { + log(bot, `Could not find a chest nearby.`); + return false; + } + let item = bot.inventory.findInventoryItem(itemName); + if (!item) { + log(bot, `You do not have any ${itemName} to put in the chest.`); + return false; + } + let to_put = num === -1 ? item.count : Math.min(num, item.count); + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2); + const chestContainer = await bot.openContainer(chest); + await chestContainer.deposit(item.type, null, to_put); + await chestContainer.close(); + log(bot, `Successfully put ${to_put} ${itemName} in the chest.`); + return true; +} + +export async function takeFromChest(bot, itemName, num=-1) { + /** + * Take the given item from the nearest chest, potentially from multiple slots. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item or block name to take from the chest. + * @param {number} num, the number of items to take from the chest. Defaults to -1, which takes all items. + * @returns {Promise} true if the item was taken from the chest, false otherwise. + * @example + * await skills.takeFromChest(bot, "oak_log"); + * **/ + let chest = world.getNearestBlock(bot, 'chest', 32); + if (!chest) { + log(bot, `Could not find a chest nearby.`); + return false; + } + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2); + const chestContainer = await bot.openContainer(chest); + + // Find all matching items in the chest + let matchingItems = chestContainer.containerItems().filter(item => item.name === itemName); + if (matchingItems.length === 0) { + log(bot, `Could not find any ${itemName} in the chest.`); + await chestContainer.close(); + return false; + } + + let totalAvailable = matchingItems.reduce((sum, item) => sum + item.count, 0); + let remaining = num === -1 ? totalAvailable : Math.min(num, totalAvailable); + let totalTaken = 0; + + // Take items from each slot until we've taken enough or run out + for (const item of matchingItems) { + if (remaining <= 0) break; + + let toTakeFromSlot = Math.min(remaining, item.count); + await chestContainer.withdraw(item.type, null, toTakeFromSlot); + + totalTaken += toTakeFromSlot; + remaining -= toTakeFromSlot; + } + + await chestContainer.close(); + log(bot, `Successfully took ${totalTaken} ${itemName} from the chest.`); + return totalTaken > 0; +} + +export async function viewChest(bot) { + /** + * View the contents of the nearest chest. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if the chest was viewed, false otherwise. + * @example + * await skills.viewChest(bot); + * **/ + let chest = world.getNearestBlock(bot, 'chest', 32); + if (!chest) { + log(bot, `Could not find a chest nearby.`); + return false; + } + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2); + const chestContainer = await bot.openContainer(chest); + let items = chestContainer.containerItems(); + if (items.length === 0) { + log(bot, `The chest is empty.`); + } + else { + log(bot, `The chest contains:`); + for (let item of items) { + log(bot, `${item.count} ${item.name}`); + } + } + await chestContainer.close(); + return true; +} + +export async function consume(bot, itemName="") { + /** + * Eat/drink the given item. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemName, the item to eat/drink. + * @returns {Promise} true if the item was eaten, false otherwise. + * @example + * await skills.eat(bot, "apple"); + **/ + let item, name; + if (itemName) { + item = bot.inventory.findInventoryItem(itemName); + name = itemName; + } + if (!item) { + log(bot, `You do not have any ${name} to eat.`); + return false; + } + await bot.equip(item, 'hand'); + await bot.consume(); + log(bot, `Consumed ${item.name}.`); + return true; +} + + +export async function giveToPlayer(bot, itemType, username, num=1) { + /** + * Give one of the specified item to the specified player + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} itemType, the name of the item to give. + * @param {string} username, the username of the player to give the item to. + * @param {number} num, the number of items to give. Defaults to 1. + * @returns {Promise} true if the item was given, false otherwise. + * @example + * await skills.giveToPlayer(bot, "oak_log", "player1"); + **/ + if (bot.username === username) { + log(bot, `You cannot give items to yourself.`); + return false; + } + let player = bot.players[username].entity + if (!player) { + log(bot, `Could not find ${username}.`); + return false; + } + await goToPlayer(bot, username, 3); + // if we are 2 below the player + log(bot, bot.entity.position.y, player.position.y); + if (bot.entity.position.y < player.position.y - 1) { + await goToPlayer(bot, username, 1); + } + // if we are too close, make some distance + if (bot.entity.position.distanceTo(player.position) < 2) { + let too_close = true; + let start_moving_away = Date.now(); + await moveAwayFromEntity(bot, player, 2); + while (too_close && !bot.interrupt_code) { + await new Promise(resolve => setTimeout(resolve, 500)); + too_close = bot.entity.position.distanceTo(player.position) < 5; + if (too_close) { + await moveAwayFromEntity(bot, player, 5); + } + if (Date.now() - start_moving_away > 3000) { + break; + } + } + if (too_close) { + log(bot, `Failed to give ${itemType} to ${username}, too close.`); + return false; + } + } + + await bot.lookAt(player.position); + if (await discard(bot, itemType, num)) { + let given = false; + bot.once('playerCollect', (collector, collected) => { + console.log(collected.name); + if (collector.username === username) { + log(bot, `${username} received ${itemType}.`); + given = true; + } + }); + let start = Date.now(); + while (!given && !bot.interrupt_code) { + await new Promise(resolve => setTimeout(resolve, 500)); + if (given) { + return true; + } + if (Date.now() - start > 3000) { + break; + } + } + } + log(bot, `Failed to give ${itemType} to ${username}, it was never received.`); + return false; +} + +export async function goToGoal(bot, goal) { + /** + * Navigate to the given goal. Use doors and attempt minimally destructive movements. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {pf.goals.Goal} goal, the 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)); + } + nonDestructiveMovements.placeCost = 2; + nonDestructiveMovements.digCost = 10; + + const destructiveMovements = new pf.Movements(bot); + + let final_movements = destructiveMovements; + + 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 doorCheckInterval = startDoorInterval(bot); + + bot.pathfinder.setMovements(final_movements); + try { + await bot.pathfinder.goto(goal); + clearInterval(doorCheckInterval); + return true; + } catch (err) { + clearInterval(doorCheckInterval); + // we need to catch so we can clean up the door check interval, then rethrow the error + throw err; + } +} + +let _doorInterval = null; +function startDoorInterval(bot) { + /** + * Start helper interval that opens nearby doors if the bot is stuck. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {number} the interval id. + **/ + if (_doorInterval) { + clearInterval(_doorInterval); + } + let prev_pos = bot.entity.position.clone(); + let prev_check = Date.now(); + let stuck_time = 0; + + + const doorCheckInterval = setInterval(() => { + const now = Date.now(); + if (bot.entity.position.distanceTo(prev_pos) >= 0.1) { + stuck_time = 0; + } else { + stuck_time += now - prev_check; + } + + if (stuck_time > 1200) { + // shuffle positions so we're not always opening the same door + const positions = [ + bot.entity.position.clone(), + bot.entity.position.offset(0, 0, 1), + 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 + positions.push(bot.entity.position.offset(0, -1, 0)); // below feet + + let currentIndex = positions.length; + while (currentIndex != 0) { + let randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + [positions[currentIndex], positions[randomIndex]] = [ + positions[randomIndex], positions[currentIndex]]; + } + + for (let position of positions) { + let block = bot.blockAt(position); + if (block && block.name && + !block.name.includes('iron') && + (block.name.includes('door') || + block.name.includes('fence_gate') || + block.name.includes('trapdoor'))) + { + bot.activateBlock(block); + break; + } + } + stuck_time = 0; + } + prev_pos = bot.entity.position.clone(); + prev_check = now; + }, 200); + _doorInterval = doorCheckInterval; + return doorCheckInterval; +} + +export async function goToPosition(bot, x, y, z, min_distance=2) { + /** + * Navigate to the given position. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} x, the x coordinate to navigate to. If null, the bot's current x coordinate will be used. + * @param {number} y, the y coordinate to navigate to. If null, the bot's current y coordinate will be used. + * @param {number} z, the z coordinate to navigate to. If null, the bot's current z coordinate will be used. + * @param {number} distance, the distance to keep from the position. Defaults to 2. + * @returns {Promise} true if the position was reached, false otherwise. + * @example + * let position = world.world.getNearestBlock(bot, "oak_log", 64).position; + * await skills.goToPosition(bot, position.x, position.y, position.x + 20); + **/ + if (x == null || y == null || z == null) { + log(bot, `Missing coordinates, given x:${x} y:${y} z:${z}`); + return false; + } + if (bot.modes.isOn('cheat')) { + bot.chat('/tp @s ' + x + ' ' + y + ' ' + z); + log(bot, `Teleported to ${x}, ${y}, ${z}.`); + return true; + } + + 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(); + } + } + }; + + const progressInterval = setInterval(checkDigProgress, 1000); + + try { + await goToGoal(bot, new pf.goals.GoalNear(x, y, z, min_distance)); + clearInterval(progressInterval); + const distance = bot.entity.position.distanceTo(new Vec3(x, y, z)); + if (distance <= min_distance+1) { + log(bot, `You have reached at ${x}, ${y}, ${z}.`); + return true; + } + else { + log(bot, `Unable to reach ${x}, ${y}, ${z}, you are ${Math.round(distance)} blocks away.`); + return false; + } + } catch (err) { + log(bot, `Pathfinding stopped: ${err.message}.`); + clearInterval(progressInterval); + return false; + } +} + +export async function goToNearestBlock(bot, blockType, min_distance=2, range=64) { + /** + * Navigate to the nearest block of the given type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} blockType, the type of block to navigate to. + * @param {number} min_distance, the distance to keep from the block. Defaults to 2. + * @param {number} range, the range to look for the block. Defaults to 64. + * @returns {Promise} true if the block was reached, false otherwise. + * @example + * await skills.goToNearestBlock(bot, "oak_log", 64, 2); + * **/ + const MAX_RANGE = 512; + if (range > MAX_RANGE) { + log(bot, `Maximum search range capped at ${MAX_RANGE}. `); + range = MAX_RANGE; + } + let block = null; + if (blockType === 'water' || blockType === 'lava') { + let blocks = world.getNearestBlocksWhere(bot, block => block.name === blockType && block.metadata === 0, range, 1); + if (blocks.length === 0) { + log(bot, `Could not find any source ${blockType} in ${range} blocks, looking for uncollectable flowing instead...`); + blocks = world.getNearestBlocksWhere(bot, block => block.name === blockType, range, 1); + } + block = blocks[0]; + } + else { + block = world.getNearestBlock(bot, blockType, range); + } + 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); + return true; +} + +export async function goToNearestEntity(bot, entityType, min_distance=2, range=64) { + /** + * Navigate to the nearest entity of the given type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} entityType, the type of entity to navigate to. + * @param {number} min_distance, the distance to keep from the entity. Defaults to 2. + * @param {number} range, the range to look for the entity. Defaults to 64. + * @returns {Promise} true if the entity was reached, false otherwise. + **/ + let entity = world.getNearestEntityWhere(bot, entity => entity.name === entityType, range); + if (!entity) { + log(bot, `Could not find any ${entityType} in ${range} blocks.`); + return false; + } + let distance = bot.entity.position.distanceTo(entity.position); + log(bot, `Found ${entityType} ${distance} blocks away.`); + await goToPosition(bot, entity.position.x, entity.position.y, entity.position.z, min_distance); + return true; +} + +export async function goToPlayer(bot, username, distance=3) { + /** + * Navigate to the given player. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} username, the username of the player to navigate to. + * @param {number} distance, the goal distance to the player. + * @returns {Promise} true if the player was found, false otherwise. + * @example + * await skills.goToPlayer(bot, "player"); + **/ + if (bot.username === username) { + log(bot, `You are already at ${username}.`); + return true; + } + if (bot.modes.isOn('cheat')) { + bot.chat('/tp @s ' + username); + log(bot, `Teleported to ${username}.`); + return true; + } + + bot.modes.pause('self_defense'); + bot.modes.pause('cowardice'); + 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); + + await goToGoal(bot, goal, true); + + log(bot, `You have reached ${username}.`); +} + + +export async function followPlayer(bot, username, distance=4) { + /** + * Follow the given player endlessly. Will not return until the code is manually stopped. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} username, the username of the player to follow. + * @returns {Promise} true if the player was found, false otherwise. + * @example + * await skills.followPlayer(bot, "player"); + **/ + let player = bot.players[username].entity + if (!player) + return false; + + const move = new pf.Movements(bot); + move.digCost = 10; + bot.pathfinder.setMovements(move); + let doorCheckInterval = startDoorInterval(bot); + + bot.pathfinder.setGoal(new pf.goals.GoalFollow(player, distance), true); + log(bot, `You are now actively following player ${username}.`); + + + while (!bot.interrupt_code) { + await new Promise(resolve => setTimeout(resolve, 500)); + // in cheat mode, if the distance is too far, teleport to the player + const distance_from_player = bot.entity.position.distanceTo(player.position); + + const teleport_distance = 100; + const ignore_modes_distance = 30; + const nearby_distance = distance + 2; + + if (distance_from_player > teleport_distance && bot.modes.isOn('cheat')) { + // teleport with cheat mode + await goToPlayer(bot, username); + } + else if (distance_from_player > ignore_modes_distance) { + // these modes slow down the bot, and we want to catch up + bot.modes.pause('item_collecting'); + bot.modes.pause('hunting'); + bot.modes.pause('torch_placing'); + } + else if (distance_from_player <= ignore_modes_distance) { + bot.modes.unpause('item_collecting'); + bot.modes.unpause('hunting'); + bot.modes.unpause('torch_placing'); + } + + if (distance_from_player <= nearby_distance) { + clearInterval(doorCheckInterval); + doorCheckInterval = null; + bot.modes.pause('unstuck'); + bot.modes.pause('elbow_room'); + } + else { + if (!doorCheckInterval) { + doorCheckInterval = startDoorInterval(bot); + } + bot.modes.unpause('unstuck'); + bot.modes.unpause('elbow_room'); + } + } + clearInterval(doorCheckInterval); + return true; +} + + +export async function moveAway(bot, distance) { + /** + * Move away from current position in any direction. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} distance, the distance to move away. + * @returns {Promise} true if the bot moved away, false otherwise. + * @example + * 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)); + + 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) { + let x = Math.floor(last_move.x); + let y = Math.floor(last_move.y); + let z = Math.floor(last_move.z); + bot.chat('/tp @s ' + x + ' ' + y + ' ' + z); + return true; + } + } + + await goToGoal(bot, inverted_goal); + let new_pos = bot.entity.position; + log(bot, `Moved away from ${pos.floored()} to ${new_pos.floored()}.`); + return true; +} + +export async function moveAwayFromEntity(bot, entity, distance=16) { + /** + * Move away from the given entity. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {Entity} entity, the entity to move away from. + * @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; +} + +export async function avoidEnemies(bot, distance=16) { + /** + * Move a given distance away from all nearby enemy mobs. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} distance, the distance to move away. + * @returns {Promise} true if the bot moved away, false otherwise. + * @example + * await skills.avoidEnemies(bot, 8); + **/ + 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); + await new Promise(resolve => setTimeout(resolve, 500)); + enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), distance); + if (bot.interrupt_code) { + break; + } + if (enemy && bot.entity.position.distanceTo(enemy.position) < 3) { + await attackEntity(bot, enemy, false); + } + } + bot.pathfinder.stop(); + log(bot, `Moved ${distance} away from enemies.`); + return true; +} + +export async function stay(bot, seconds=30) { + /** + * Stay in the current position until interrupted. Disables all modes. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} seconds, the number of seconds to stay. Defaults to 30. -1 for indefinite. + * @returns {Promise} true if the bot stayed, false otherwise. + * @example + * await skills.stay(bot); + **/ + bot.modes.pause('self_preservation'); + bot.modes.pause('unstuck'); + bot.modes.pause('cowardice'); + bot.modes.pause('self_defense'); + bot.modes.pause('hunting'); + bot.modes.pause('torch_placing'); + bot.modes.pause('item_collecting'); + let start = Date.now(); + while (!bot.interrupt_code && (seconds === -1 || Date.now() - start < seconds*1000)) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + log(bot, `Stayed for ${(Date.now() - start)/1000} seconds.`); + return true; +} + +export async function useDoor(bot, door_pos=null) { + /** + * Use the door at the given position. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {Vec3} door_pos, the position of the door to use. If null, the nearest door will be used. + * @returns {Promise} true if the door was used, false otherwise. + * @example + * let door = world.getNearestBlock(bot, "oak_door", 16).position; + * await skills.useDoor(bot, door); + **/ + if (!door_pos) { + for (let door_type of ['oak_door', 'spruce_door', 'birch_door', 'jungle_door', 'acacia_door', 'dark_oak_door', + 'mangrove_door', 'cherry_door', 'bamboo_door', 'crimson_door', 'warped_door']) { + door_pos = world.getNearestBlock(bot, door_type, 16).position; + if (door_pos) break; + } + } else { + door_pos = Vec3(door_pos.x, door_pos.y, door_pos.z); + } + if (!door_pos) { + log(bot, `Could not find a door to use.`); + 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); + + log(bot, `Used door at ${door_pos}.`); + return true; +} + +export async function goToBed(bot) { + /** + * Sleep in the nearest bed. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if the bed was found, false otherwise. + * @example + * await skills.goToBed(bot); + **/ + const beds = bot.findBlocks({ + matching: (block) => { + return block.name.includes('bed'); + }, + maxDistance: 32, + count: 1 + }); + if (beds.length === 0) { + log(bot, `Could not find a bed to sleep in.`); + return false; + } + let loc = beds[0]; + await goToPosition(bot, loc.x, loc.y, loc.z); + const bed = bot.blockAt(loc); + await bot.sleep(bed); + log(bot, `You are in bed.`); + bot.modes.pause('unstuck'); + while (bot.isSleeping) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + log(bot, `You have woken up.`); + return true; +} + +export async function tillAndSow(bot, x, y, z, seedType=null) { + /** + * Till the ground at the given position and plant the given seed type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} x, the x coordinate to till. + * @param {number} y, the y coordinate to till. + * @param {number} z, the z coordinate to till. + * @param {string} plantType, the type of plant to plant. Defaults to none, which will only till the ground. + * @returns {Promise} true if the ground was tilled, false otherwise. + * @example + * let position = world.getPosition(bot); + * await skills.tillAndSow(bot, position.x, position.y - 1, position.x, "wheat"); + **/ + let pos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)); + let block = bot.blockAt(pos); + log(bot, `Planting ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`); + + if (bot.modes.isOn('cheat')) { + let to_remove = ['_seed', '_seeds']; + for (let remove of to_remove) { + if (seedType.endsWith(remove)) { + seedType = seedType.replace(remove, ''); + } + } + placeBlock(bot, 'farmland', x, y, z); + placeBlock(bot, seedType, x, y+1, z); + return true; + } + + if (block.name !== 'grass_block' && block.name !== 'dirt' && block.name !== 'farmland') { + log(bot, `Cannot till ${block.name}, must be grass_block or dirt.`); + return false; + } + let above = bot.blockAt(new Vec3(x, y+1, z)); + if (above.name !== 'air') { + if (block.name === 'farmland') { + log(bot, `Land is already farmed with ${above.name}.`); + return true; + } + let broken = await breakBlockAt(bot, x, y+1, z); + if (!broken) { + log(bot, `Cannot cannot break above block to till.`); + return false; + } + } + // 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)); + } + if (block.name !== 'farmland') { + let hoe = bot.inventory.items().find(item => item.name.includes('hoe')); + let to_equip = hoe?.name || 'diamond_hoe'; + if (!await equip(bot, to_equip)) { + log(bot, `Cannot till, no hoes.`); + return false; + } + await bot.activateBlock(block); + log(bot, `Tilled block x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`); + } + + if (seedType) { + if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) + seedType += 's'; // fixes common mistake + let equipped_seeds = await equip(bot, seedType); + if (!equipped_seeds) { + log(bot, `No ${seedType} to plant.`); + return false; + } + + await bot.activateBlock(block); + log(bot, `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`); + } + return true; +} + +export async function activateNearestBlock(bot, type) { + /** + * Activate the nearest block of the given type. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} type, the type of block to activate. + * @returns {Promise} true if the block was activated, false otherwise. + * @example + * await skills.activateNearestBlock(bot, "lever"); + * **/ + let block = world.getNearestBlock(bot, type, 16); + if (!block) { + log(bot, `Could not find any ${type} to activate.`); + return false; + } + 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)); + } + 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)}.`); + return true; +} + +/** + * Helper function to find and navigate to a villager for trading + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager + * @returns {Promise} the villager entity if found and reachable, null otherwise + */ +async function findAndGoToVillager(bot, id) { + id = id+""; + const entity = bot.entities[id]; + + if (!entity) { + log(bot, `Cannot find villager with id ${id}`); + let entities = world.getNearbyEntities(bot, 16); + let villager_list = "Available villagers:\n"; + for (let entity of entities) { + if (entity.name === 'villager') { + if (entity.metadata && entity.metadata[16] === 1) { + villager_list += `${entity.id}: baby villager\n`; + } else { + const profession = world.getVillagerProfession(entity); + villager_list += `${entity.id}: ${profession}\n`; + } + } + } + if (villager_list === "Available villagers:\n") { + log(bot, "No villagers found nearby."); + return null; + } + log(bot, villager_list); + return null; + } + + if (entity.entityType !== bot.registry.entitiesByName.villager.id) { + log(bot, 'Entity is not a villager'); + return null; + } + + if (entity.metadata && entity.metadata[16] === 1) { + log(bot, 'This is either a baby villager or a villager with no job - neither can trade'); + return null; + } + + const distance = bot.entity.position.distanceTo(entity.position); + if (distance > 4) { + log(bot, `Villager is ${distance.toFixed(1)} blocks away, moving closer...`); + try { + bot.modes.pause('unstuck'); + const goal = new pf.goals.GoalFollow(entity, 2); + await goToGoal(bot, goal); + + + log(bot, 'Successfully reached villager'); + } catch (err) { + log(bot, 'Failed to reach villager - pathfinding error or villager moved'); + console.log(err); + return null; + } finally { + bot.modes.unpause('unstuck'); + } + } + + return entity; +} + +/** + * Show available trades for a specified villager + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager to show trades for + * @returns {Promise} true if trades were shown successfully, false otherwise + * @example + * await skills.showVillagerTrades(bot, "123"); + */ +export async function showVillagerTrades(bot, id) { + const villagerEntity = await findAndGoToVillager(bot, id); + if (!villagerEntity) { + return false; + } + + try { + const villager = await bot.openVillager(villagerEntity); + + if (!villager.trades || villager.trades.length === 0) { + log(bot, 'This villager has no trades available - might be sleeping, a baby, or jobless'); + villager.close(); + return false; + } + + log(bot, `Villager has ${villager.trades.length} available trades:`); + stringifyTrades(bot, villager.trades).forEach((trade, i) => { + const tradeInfo = `${i + 1}: ${trade}`; + console.log(tradeInfo); + log(bot, tradeInfo); + }); + + villager.close(); + return true; + } catch (err) { + log(bot, 'Failed to open villager trading interface - they might be sleeping, a baby, or jobless'); + console.log('Villager trading error:', err.message); + return false; + } +} + +/** + * Trade with a specified villager + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager to trade with + * @param {number} index - the index (1-based) of the trade to execute + * @param {number} count - how many times to execute the trade (optional) + * @returns {Promise} true if trade was successful, false otherwise + * @example + * await skills.tradeWithVillager(bot, "123", "1", "2"); + */ +export async function tradeWithVillager(bot, id, index, count) { + const villagerEntity = await findAndGoToVillager(bot, id); + if (!villagerEntity) { + return false; + } + + try { + const villager = await bot.openVillager(villagerEntity); + + if (!villager.trades || villager.trades.length === 0) { + log(bot, 'This villager has no trades available - might be sleeping, a baby, or jobless'); + villager.close(); + return false; + } + + const tradeIndex = parseInt(index) - 1; // Convert to 0-based index + const trade = villager.trades[tradeIndex]; + + if (!trade) { + log(bot, `Trade ${index} not found. This villager has ${villager.trades.length} trades available.`); + villager.close(); + return false; + } + + if (trade.disabled) { + log(bot, `Trade ${index} is currently disabled`); + villager.close(); + return false; + } + + const item_2 = trade.inputItem2 ? stringifyItem(bot, trade.inputItem2)+' ' : ''; + log(bot, `Trading ${stringifyItem(bot, trade.inputItem1)} ${item_2}for ${stringifyItem(bot, trade.outputItem)}...`); + + const maxPossibleTrades = trade.maximumNbTradeUses - trade.nbTradeUses; + const requestedCount = count; + const actualCount = Math.min(requestedCount, maxPossibleTrades); + + if (actualCount <= 0) { + log(bot, `Trade ${index} has been used to its maximum limit`); + villager.close(); + return false; + } + + if (!hasResources(villager.slots, trade, actualCount)) { + log(bot, `Don't have enough resources to execute trade ${index} ${actualCount} time(s)`); + villager.close(); + return false; + } + + log(bot, `Executing trade ${index} ${actualCount} time(s)...`); + + try { + await bot.trade(villager, tradeIndex, actualCount); + log(bot, `Successfully traded ${actualCount} time(s)`); + villager.close(); + return true; + } catch (tradeErr) { + log(bot, 'An error occurred while trying to execute the trade'); + console.log('Trade execution error:', tradeErr.message); + villager.close(); + return false; + } + } catch (err) { + log(bot, 'Failed to open villager trading interface'); + console.log('Villager interface error:', err.message); + return false; + } +} + +function hasResources(window, trade, count) { + const first = enough(trade.inputItem1, count); + const second = !trade.inputItem2 || enough(trade.inputItem2, count); + return first && second; + + function enough(item, count) { + let c = 0; + window.forEach((element) => { + if (element && element.type === item.type && element.metadata === item.metadata) { + c += element.count; + } + }); + return c >= item.count * count; + } +} + +function stringifyTrades(bot, trades) { + return trades.map((trade) => { + let text = stringifyItem(bot, trade.inputItem1); + if (trade.inputItem2) text += ` & ${stringifyItem(bot, trade.inputItem2)}`; + if (trade.disabled) text += ' x '; else text += ' » '; + text += stringifyItem(bot, trade.outputItem); + return `(${trade.nbTradeUses}/${trade.maximumNbTradeUses}) ${text}`; + }); +} + +function stringifyItem(bot, item) { + if (!item) return 'nothing'; + let text = `${item.count} ${item.displayName}`; + if (item.nbt && item.nbt.value) { + const ench = item.nbt.value.ench; + const StoredEnchantments = item.nbt.value.StoredEnchantments; + const Potion = item.nbt.value.Potion; + const display = item.nbt.value.display; + + if (Potion) text += ` of ${Potion.value.replace(/_/g, ' ').split(':')[1] || 'unknown type'}`; + if (display) text += ` named ${display.value.Name.value}`; + if (ench || StoredEnchantments) { + text += ` enchanted with ${(ench || StoredEnchantments).value.value.map((e) => { + const lvl = e.lvl.value; + const id = e.id.value; + return bot.registry.enchantments[id].displayName + ' ' + lvl; + }).join(' ')}`; + } + } + return text; +} + +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. + * @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. + * @example + * await skills.digDown(bot, 10); + **/ + + let start_block_pos = bot.blockAt(bot.entity.position).position; + for (let i = 1; i <= distance; i++) { + const targetBlock = bot.blockAt(start_block_pos.offset(0, -i, 0)); + let belowBlock = bot.blockAt(start_block_pos.offset(0, -i-1, 0)); + + if (!targetBlock || !belowBlock) { + log(bot, `Dug down ${i-1} blocks, but reached the end of the world.`); + return true; + } + + // 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)'}`) + return false; + } + + const MAX_FALL_BLOCKS = 2; + let num_fall_blocks = 0; + for (let j = 0; j <= MAX_FALL_BLOCKS; j++) { + if (!belowBlock || (belowBlock.name !== 'air' && belowBlock.name !== 'cave_air')) { + break; + } + num_fall_blocks++; + belowBlock = bot.blockAt(belowBlock.position.offset(0, -1, 0)); + } + if (num_fall_blocks > MAX_FALL_BLOCKS) { + log(bot, `Dug down ${i-1} blocks, but reached a drop below the next block.`); + return false; + } + + if (targetBlock.name === 'air' || targetBlock.name === 'cave_air') { + log(bot, 'Skipping air block'); + console.log(targetBlock.position); + continue; + } + + let dug = await breakBlockAt(bot, targetBlock.position.x, targetBlock.position.y, targetBlock.position.z); + if (!dug) { + log(bot, 'Failed to dig block at position:' + targetBlock.position); + return false; + } + } + log(bot, `Dug down ${distance} blocks.`); + return true; +} + +export async function goToSurface(bot) { + /** + * Navigate to the surface (highest non-air block at current x,z). + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if the surface was reached, false otherwise. + **/ + const pos = bot.entity.position; + for (let y = 360; y > -64; y--) { // probably not the best way to find the surface but it works + const block = bot.blockAt(new Vec3(pos.x, y, pos.z)); + if (!block || block.name === 'air' || block.name === 'cave_air') { + 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}.`);`` + return true; + } + return false; +} + +export async function useToolOn(bot, toolName, targetName) { + /** + * Equip a tool and use it on the nearest target. + * @param {MinecraftBot} bot + * @param {string} toolName - item name of the tool to equip, or "hand" for no tool. + * @param {string} targetName - entity type, block type, or "nothing" for no target + * @returns {Promise} true if action succeeded + */ + if (!bot.inventory.slots.find(slot => slot && slot.name === toolName) && !bot.game.gameMode === 'creative') { + log(bot, `You do not have any ${toolName} to use.`); + return false; + } + + targetName = targetName.toLowerCase(); + if (targetName === 'nothing') { + const equipped = await equip(bot, toolName); + if (!equipped) { + return false; + } + await bot.activateItem(); + log(bot, `Used ${toolName}.`); + } else if (world.isEntityType(targetName)) { + const entity = world.getNearestEntityWhere(bot, e => e.name === targetName, 64); + if (!entity) { + log(bot, `Could not find any ${targetName}.`); + return false; + } + await goToPosition(bot, entity.position.x, entity.position.y, entity.position.z); + if (toolName === 'hand') { + await bot.unequip('hand'); + } + else { + const equipped = await equip(bot, toolName); + if (!equipped) return false; + } + await bot.useOn(entity); + log(bot, `Used ${toolName} on ${targetName}.`); + } else { + let block = null; + if (targetName === 'water' || targetName === 'lava') { + // we want to get liquid source blocks, not flowing blocks + // so search for blocks with metadata 0 (not flowing) + let blocks = world.getNearestBlocksWhere(bot, block => block.name === targetName && block.metadata === 0, 64, 1); + if (blocks.length === 0) { + log(bot, `Could not find any source ${targetName}.`); + return false; + } + block = blocks[0]; + } + else { + block = world.getNearestBlock(bot, targetName, 64); + } + if (!block) { + log(bot, `Could not find any ${targetName}.`); + return false; + } + return await useToolOnBlock(bot, toolName, block); + } + + return true; + } + + export async function useToolOnBlock(bot, toolName, block) { + /** + * Use a tool on a specific block. + * @param {MinecraftBot} bot + * @param {string} toolName - item name of the tool to equip, or "hand" for no tool. + * @param {Block} block - the block reference to use the tool on. + * @returns {Promise} true if action succeeded + */ + + const distance = toolName === 'water_bucket' && block.name !== 'lava' ? 1.5 : 2; + await goToPosition(bot, block.position.x, block.position.y, block.position.z, distance); + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5)); + + // if block in view is closer than the target block, it is in our way. try to move closer + const viewBlocked = () => { + const blockInView = bot.blockAtCursor(5); + const headPos = bot.entity.position.offset(0, bot.entity.height, 0); + 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...`); + // choose random block next to target block, go to it + const nearbyPos = block.position.offset(Math.random() * 2 - 1, 0, Math.random() * 2 - 1); + await goToPosition(bot, nearbyPos.x, nearbyPos.y, nearbyPos.z, 1); + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5)); + if (viewBlocked()) { + const blockInView = bot.blockAtCursor(5); + log(bot, `Block ${blockInView.name} is in the way, not using ${toolName}.`); + return false; + } + } + + const equipped = await equip(bot, toolName); + + if (!equipped) { + log(bot, `Could not equip ${toolName}.`); + return false; + } + if (toolName.includes('bucket')) { + await bot.activateItem(); + } + else { + await bot.activateBlock(block); + } + log(bot, `Used ${toolName} on ${block.name}.`); + return true; + } diff --git a/extensions/lib/world.js b/extensions/lib/world.js new file mode 100644 index 0000000..3a61e83 --- /dev/null +++ b/extensions/lib/world.js @@ -0,0 +1,431 @@ +import pf from 'mineflayer-pathfinder'; +import * as mc from './mcdata.js'; + + +export function getNearestFreeSpace(bot, size=1, distance=8) { + /** + * Get the nearest empty space with solid blocks beneath it of the given size. + * @param {Bot} bot - The bot to get the nearest free space for. + * @param {number} size - The (size x size) of the space to find, default 1. + * @param {number} distance - The maximum distance to search, default 8. + * @returns {Vec3} - The south west corner position of the nearest free space. + * @example + * let position = world.getNearestFreeSpace(bot, 1, 8); + **/ + let empty_pos = bot.findBlocks({ + matching: (block) => { + return block && block.name == 'air'; + }, + maxDistance: distance, + count: 1000 + }); + for (let i = 0; i < empty_pos.length; i++) { + let empty = true; + for (let x = 0; x < size; x++) { + for (let z = 0; z < size; z++) { + let top = bot.blockAt(empty_pos[i].offset(x, 0, z)); + let bottom = bot.blockAt(empty_pos[i].offset(x, -1, z)); + if (!top || !top.name == 'air' || !bottom || bottom.drops.length == 0 || !bottom.diggable) { + empty = false; + break; + } + } + if (!empty) break; + } + if (empty) { + return empty_pos[i]; + } + } +} + + +export function getBlockAtPosition(bot, x=0, y=0, z=0) { + /** + * Get a block from the bot's relative position + * @param {Bot} bot - The bot to get the block for. + * @param {number} x - The relative x offset to serach, default 0. + * @param {number} y - The relative y offset to serach, default 0. + * @param {number} y - The relative z offset to serach, default 0. + * @returns {Block} - The nearest block. + * @example + * let blockBelow = world.getBlockAtPosition(bot, 0, -1, 0); + * let blockAbove = world.getBlockAtPosition(bot, 0, 2, 0); since minecraft position is at the feet + **/ + let block = bot.blockAt(bot.entity.position.offset(x, y, z)); + if (!block) block = {name: 'air'}; + + return block; +} + + +export function getSurroundingBlocks(bot) { + /** + * Get the surrounding blocks from the bot's environment. + * @param {Bot} bot - The bot to get the block for. + * @returns {string[]} - A list of block results as strings. + * @example + **/ + // Create a list of block position results that can be unpacked. + let res = []; + res.push(`Block Below: ${getBlockAtPosition(bot, 0, -1, 0).name}`); + res.push(`Block at Legs: ${getBlockAtPosition(bot, 0, 0, 0).name}`); + res.push(`Block at Head: ${getBlockAtPosition(bot, 0, 1, 0).name}`); + + return res; +} + + +export function getFirstBlockAboveHead(bot, ignore_types=null, distance=32) { + /** + * Searches a column from the bot's position for the first solid block above its head + * @param {Bot} bot - The bot to get the block for. + * @param {string[]} ignore_types - The names of the blocks to ignore. + * @param {number} distance - The maximum distance to search, default 32. + * @returns {string} - The fist block above head. + * @example + * let firstBlockAboveHead = world.getFirstBlockAboveHead(bot, null, 32); + **/ + // if ignore_types is not a list, make it a list. + let ignore_blocks = []; + if (ignore_types === null) ignore_blocks = ['air', 'cave_air']; + else { + if (!Array.isArray(ignore_types)) + ignore_types = [ignore_types]; + for(let ignore_type of ignore_types) { + if (mc.getBlockId(ignore_type)) ignore_blocks.push(ignore_type); + } + } + // The block above, stops when it finds a solid block . + let block_above = {name: 'air'}; + 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'}; + // Ignore and continue + if (ignore_blocks.includes(block.name)) continue; + // Defaults to any block + block_above = block; + height = i; + break; + } + + if (ignore_blocks.includes(block_above.name)) return 'none'; + + return `${block_above.name} (${height} blocks up)`; +} + + +export function getNearestBlocks(bot, block_types=null, distance=8, count=10000) { + /** + * Get a list of the nearest blocks of the given types. + * @param {Bot} bot - The bot to get the nearest block for. + * @param {string[]} block_types - The names of the blocks to search for. + * @param {number} distance - The maximum distance to search, default 16. + * @param {number} count - The maximum number of blocks to find, default 10000. + * @returns {Block[]} - The nearest blocks of the given type. + * @example + * let woodBlocks = world.getNearestBlocks(bot, ['oak_log', 'birch_log'], 16, 1); + **/ + // if blocktypes is not a list, make it a list + let block_ids = []; + if (block_types === null) { + block_ids = mc.getAllBlockIds(['air']); + } + else { + if (!Array.isArray(block_types)) + block_types = [block_types]; + for(let block_type of block_types) { + block_ids.push(mc.getBlockId(block_type)); + } + } + return getNearestBlocksWhere(bot, block_ids, distance, count); +} + +export function getNearestBlocksWhere(bot, predicate, distance=8, count=10000) { + /** + * Get a list of the nearest blocks that satisfy the given predicate. + * @param {Bot} bot - The bot to get the nearest blocks for. + * @param {function} predicate - The predicate to filter the blocks. + * @param {number} distance - The maximum distance to search, default 16. + * @param {number} count - The maximum number of blocks to find, default 10000. + * @returns {Block[]} - The nearest blocks that satisfy the given predicate. + * @example + * let waterBlocks = world.getNearestBlocksWhere(bot, block => block.name === 'water', 16, 10); + **/ + let positions = bot.findBlocks({matching: predicate, maxDistance: distance, count: count}); + let blocks = positions.map(position => bot.blockAt(position)); + return blocks; +} + + +export function getNearestBlock(bot, block_type, distance=16) { + /** + * Get the nearest block of the given type. + * @param {Bot} bot - The bot to get the nearest block for. + * @param {string} block_type - The name of the block to search for. + * @param {number} distance - The maximum distance to search, default 16. + * @returns {Block} - The nearest block of the given type. + * @example + * let coalBlock = world.getNearestBlock(bot, 'coal_ore', 16); + **/ + let blocks = getNearestBlocks(bot, block_type, distance, 1); + if (blocks.length > 0) { + return blocks[0]; + } + return null; +} + + +export function getNearbyEntities(bot, maxDistance=16) { + let entities = []; + for (const entity of Object.values(bot.entities)) { + const distance = entity.position.distanceTo(bot.entity.position); + if (distance > maxDistance) continue; + entities.push({ entity: entity, distance: distance }); + } + entities.sort((a, b) => a.distance - b.distance); + let res = []; + for (let i = 0; i < entities.length; i++) { + res.push(entities[i].entity); + } + return res; +} + +export function getNearestEntityWhere(bot, predicate, maxDistance=16) { + return bot.nearestEntity(entity => predicate(entity) && bot.entity.position.distanceTo(entity.position) < maxDistance); +} + + +export function getNearbyPlayers(bot, maxDistance) { + if (maxDistance == null) maxDistance = 16; + let players = []; + for (const entity of Object.values(bot.entities)) { + const distance = entity.position.distanceTo(bot.entity.position); + if (distance > maxDistance) continue; + if (entity.type == 'player' && entity.username != bot.username) { + players.push({ entity: entity, distance: distance }); + } + } + players.sort((a, b) => a.distance - b.distance); + let res = []; + for (let i = 0; i < players.length; i++) { + res.push(players[i].entity); + } + return res; +} + +// Helper function to get villager profession from metadata +export function getVillagerProfession(entity) { + // Villager profession mapping based on metadata + const professions = { + 0: 'Unemployed', + 1: 'Armorer', + 2: 'Butcher', + 3: 'Cartographer', + 4: 'Cleric', + 5: 'Farmer', + 6: 'Fisherman', + 7: 'Fletcher', + 8: 'Leatherworker', + 9: 'Librarian', + 10: 'Mason', + 11: 'Nitwit', + 12: 'Shepherd', + 13: 'Toolsmith', + 14: 'Weaponsmith' + }; + + if (entity.metadata && entity.metadata[18]) { + // Check if metadata[18] is an object with villagerProfession property + if (typeof entity.metadata[18] === 'object' && entity.metadata[18].villagerProfession !== undefined) { + const professionId = entity.metadata[18].villagerProfession; + const level = entity.metadata[18].level || 1; + const professionName = professions[professionId] || 'Unknown'; + return `${professionName} L${level}`; + } + // Fallback for direct profession ID + else if (typeof entity.metadata[18] === 'number') { + const professionId = entity.metadata[18]; + return professions[professionId] || 'Unknown'; + } + } + + // If we can't determine profession but it's an adult villager + if (entity.metadata && entity.metadata[16] !== 1) { // Not a baby + return 'Adult'; + } + + return 'Unknown'; +} + + +export function getInventoryCounts(bot) { + /** + * Get an object representing the bot's inventory. + * @param {Bot} bot - The bot to get the inventory for. + * @returns {object} - An object with item names as keys and counts as values. + * @example + * let inventory = world.getInventoryCounts(bot); + * let oakLogCount = inventory['oak_log']; + * let hasWoodenPickaxe = inventory['wooden_pickaxe'] > 0; + **/ + let inventory = {}; + for (const slot of bot.inventory.slots) { + if (slot != null && slot.name) { + if (inventory[slot.name] == null) { + inventory[slot.name] = 0; + } + inventory[slot.name] += slot.count; + } + } + return inventory; +} + + +export function getCraftableItems(bot) { + /** + * Get a list of all items that can be crafted with the bot's current inventory. + * @param {Bot} bot - The bot to get the craftable items for. + * @returns {string[]} - A list of all items that can be crafted. + * @example + * let craftableItems = world.getCraftableItems(bot); + **/ + let table = getNearestBlock(bot, 'crafting_table'); + if (!table) { + for (const item of bot.inventory.items()) { + if (item != null && item.name === 'crafting_table') { + table = item; + break; + } + } + } + let res = []; + for (const item of mc.getAllItems()) { + let recipes = bot.recipesFor(item.id, null, 1, table); + if (recipes.length > 0) + res.push(item.name); + } + return res; +} + + +export function getPosition(bot) { + /** + * Get your position in the world (Note that y is vertical). + * @param {Bot} bot - The bot to get the position for. + * @returns {Vec3} - An object with x, y, and x attributes representing the position of the bot. + * @example + * let position = world.getPosition(bot); + * let x = position.x; + **/ + return bot.entity.position; +} + + +export function getNearbyEntityTypes(bot) { + /** + * Get a list of all nearby mob types. + * @param {Bot} bot - The bot to get nearby mobs for. + * @returns {string[]} - A list of all nearby mobs. + * @example + * let mobs = world.getNearbyEntityTypes(bot); + **/ + let mobs = getNearbyEntities(bot, 16); + let found = []; + for (let i = 0; i < mobs.length; i++) { + if (!found.includes(mobs[i].name)) { + found.push(mobs[i].name); + } + } + return found; +} + +export function isEntityType(name) { + /** + * Check if a given name is a valid entity type. + * @param {string} name - The name of the entity type to check. + * @returns {boolean} - True if the name is a valid entity type, false otherwise. + */ + return mc.getEntityId(name) !== null; +} + +export function getNearbyPlayerNames(bot) { + /** + * Get a list of all nearby player names. + * @param {Bot} bot - The bot to get nearby players for. + * @returns {string[]} - A list of all nearby players. + * @example + * let players = world.getNearbyPlayerNames(bot); + **/ + let players = getNearbyPlayers(bot, 64); + let found = []; + for (let i = 0; i < players.length; i++) { + if (!found.includes(players[i].username) && players[i].username != bot.username) { + found.push(players[i].username); + } + } + return found; +} + + +export function getNearbyBlockTypes(bot, distance=16) { + /** + * Get a list of all nearby block names. + * @param {Bot} bot - The bot to get nearby blocks for. + * @param {number} distance - The maximum distance to search, default 16. + * @returns {string[]} - A list of all nearby blocks. + * @example + * let blocks = world.getNearbyBlockTypes(bot); + **/ + let blocks = getNearestBlocks(bot, null, distance); + let found = []; + for (let i = 0; i < blocks.length; i++) { + if (!found.includes(blocks[i].name)) { + found.push(blocks[i].name); + } + } + return found; +} + +export async function isClearPath(bot, target) { + /** + * Check if there is a path to the target that requires no digging or placing blocks. + * @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'; +} + +export function shouldPlaceTorch(bot) { + if (!bot.modes.isOn('torch_placing') || bot.interrupt_code) return false; + const pos = getPosition(bot); + // TODO: check light level instead of nearby torches, block.light is broken + let nearest_torch = getNearestBlock(bot, 'torch', 6); + if (!nearest_torch) + nearest_torch = getNearestBlock(bot, 'wall_torch', 6); + if (!nearest_torch) { + const block = bot.blockAt(pos); + let has_torch = bot.inventory.findInventoryItem('torch'); + return has_torch && block?.name === 'air'; + } + return false; +} + +export function getBiomeName(bot) { + /** + * Get the name of the biome the bot is in. + * @param {Bot} bot - The bot to get the biome for. + * @returns {string} - The name of the biome. + * @example + * let biome = world.getBiomeName(bot); + **/ + const biomeId = bot.world.getBiome(bot.entity.position); + return mc.getAllBiomes()[biomeId].name; +} diff --git a/extensions/mindcraft-skills.ts b/extensions/mindcraft-skills.ts new file mode 100644 index 0000000..40cab29 --- /dev/null +++ b/extensions/mindcraft-skills.ts @@ -0,0 +1,406 @@ +// Mindcraft-backed Pi tools for pepa-pi-bot. +// +// Wraps battle-tested skill primitives from Mindcraft (MIT) — see +// extensions/lib/world.js, extensions/lib/skills.js, extensions/lib/mcdata.js, +// and extensions/lib/LICENSE-MINDCRAFT. +// +// This extension complements mineflayer-bridge.ts (auth, memory, chat, +// escalation, trust). It does NOT replace it; both extensions run side-by-side, +// registering disjoint Pi tools. Old broken mc_goto / mc_build_pyramid_5x5 +// in bridge.ts are deprecated in favour of the perception+action set below. + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +// Lazy access to the Mineflayer bot instance held by mineflayer-bridge.ts. +function getBot(): any { + const bot = (globalThis as any).__pepaPiBot; + if (!bot) throw new Error("Mineflayer bot is not connected (or bridge has not exposed __pepaPiBot yet)."); + return bot; +} + +// ---- parameter schemas ---------------------------------------------------- + +const EMPTY_PARAMS = { + type: "object", + properties: {}, + additionalProperties: false, +} as const; + +const SCAN_PARAMS = { + type: "object", + properties: { + radius: { type: "number", description: "Block radius (default 16, max 48)." }, + }, + additionalProperties: false, +} as const; + +const BLOCK_NAME_COUNT_PARAMS = { + type: "object", + properties: { + blockType: { type: "string", description: "Minecraft block name, e.g. 'oak_log', 'cobblestone'." }, + count: { type: "number", description: "How many to collect. Default 1." }, + }, + required: ["blockType"], + additionalProperties: false, +} as const; + +const PLACE_PARAMS = { + type: "object", + properties: { + blockType: { type: "string", description: "Block name in inventory to place." }, + x: { type: "number" }, + y: { type: "number" }, + z: { type: "number" }, + }, + required: ["blockType", "x", "y", "z"], + additionalProperties: false, +} as const; + +const GOTO_PARAMS = { + type: "object", + properties: { + x: { type: "number" }, + y: { type: "number" }, + z: { type: "number" }, + minDistance: { type: "number", description: "Stop when within this distance (default 2)." }, + }, + required: ["x", "y", "z"], + additionalProperties: false, +} as const; + +const GOTO_BLOCK_PARAMS = { + type: "object", + properties: { + blockType: { type: "string" }, + minDistance: { type: "number" }, + range: { type: "number", description: "Search radius. Default 64." }, + }, + required: ["blockType"], + additionalProperties: false, +} as const; + +const CONSUME_PARAMS = { + type: "object", + properties: { + itemName: { type: "string", description: "Food name. Empty = any food in inventory." }, + }, + additionalProperties: false, +} as const; + +const CRAFT_PARAMS = { + type: "object", + properties: { + itemName: { type: "string" }, + num: { type: "number" }, + }, + required: ["itemName"], + additionalProperties: false, +} as const; + +const EQUIP_PARAMS = { + type: "object", + properties: { + itemName: { type: "string" }, + }, + required: ["itemName"], + additionalProperties: false, +} as const; + +const DEFEND_PARAMS = { + type: "object", + properties: { + range: { type: "number", description: "Reaction range. Default 9." }, + }, + additionalProperties: false, +} as const; + +const AVOID_PARAMS = { + type: "object", + properties: { + distance: { type: "number", description: "Move this many blocks away. Default 16." }, + }, + additionalProperties: false, +} as const; + +const STAY_PARAMS = { + type: "object", + properties: { + seconds: { type: "number" }, + }, + additionalProperties: false, +} as const; + +// ---- helpers -------------------------------------------------------------- + +function clampRadius(r: number | undefined): number { + const v = typeof r === "number" && isFinite(r) ? r : 16; + return Math.max(1, Math.min(48, Math.floor(v))); +} + +function textResult(text: string, details?: Record) { + return { content: [{ type: "text", text }], details }; +} + +// ---- tool registration ---------------------------------------------------- + +export default async function mindcraftSkills(pi: ExtensionAPI) { + // Load vendored ESM Mindcraft libs at extension init. Pi awaits the default + // export, so registerTool calls below see fully-loaded modules. + const [skillsMod, worldMod] = await Promise.all([ + import("./lib/skills.js" as any), + import("./lib/world.js" as any), + ]); + const skills = skillsMod as Record any>; + const world = worldMod as Record any>; + + const safeCall = async (label: string, fn: () => T | Promise): Promise => { + try { + return await fn(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`${label}: ${msg}`); + } + }; + + // 1. mc_observe — one-shot perception snapshot. + pi.registerTool({ + name: "mc_observe", + label: "Observe Surroundings", + description: "Return a compact JSON summary of nearby blocks, entities, inventory, position, health, food, weather, time of day. Always call before deciding a non-trivial action in autonomous mode.", + promptSnippet: "Get a single JSON snapshot of everything around the bot.", + parameters: SCAN_PARAMS, + async execute(_id, params: { radius?: number }) { + const bot = getBot(); + const radius = clampRadius(params?.radius); + const blockTypes = world.getNearbyBlockTypes(bot, radius); + const entities = world.getNearbyEntities(bot, radius); + const players = world.getNearbyPlayerNames ? world.getNearbyPlayerNames(bot) : []; + const inv = world.getInventoryCounts(bot); + const pos = world.getPosition(bot); + const biome = (() => { + try { return world.getBiomeName(bot); } catch { return "unknown"; } + })(); + const summary = { + position: { x: Math.round(pos.x), y: Math.round(pos.y), z: Math.round(pos.z) }, + biome, + time: bot.time?.timeOfDay ?? null, + isDay: bot.time?.isDay ?? null, + weather: { rain: !!bot.isRaining, thunder: !!bot.thunderState }, + health: bot.health, + food: bot.food, + saturation: bot.foodSaturation, + inventory: inv, + nearbyBlocks: blockTypes, + nearbyEntityTypes: Array.from(new Set(entities.map((e: any) => e?.name ?? e?.type).filter(Boolean))), + nearbyPlayers: players, + entityCount: entities.length, + }; + return textResult(JSON.stringify(summary, null, 2), summary); + }, + }); + + // 2. mc_inventory + pi.registerTool({ + name: "mc_inventory", + label: "Inventory", + description: "List items in the bot's inventory as {name: count}.", + parameters: EMPTY_PARAMS, + async execute() { + const bot = getBot(); + const inv = world.getInventoryCounts(bot); + return textResult(JSON.stringify(inv), { inventory: inv }); + }, + }); + + // 3. mc_nearby_blocks + pi.registerTool({ + name: "mc_nearby_blocks", + label: "Nearby Block Types", + description: "List distinct nearby block types within radius (default 16). Useful for 'is there water/lava/log close?'.", + parameters: SCAN_PARAMS, + async execute(_id, params: { radius?: number }) { + const bot = getBot(); + const types = world.getNearbyBlockTypes(bot, clampRadius(params?.radius)); + return textResult(JSON.stringify(types), { blockTypes: types }); + }, + }); + + // 4. mc_nearby_entities + pi.registerTool({ + name: "mc_nearby_entities", + label: "Nearby Entities", + description: "List entities (players, mobs, items, etc.) within radius (default 16). Returns name/type and approximate distance.", + parameters: SCAN_PARAMS, + async execute(_id, params: { radius?: number }) { + const bot = getBot(); + const radius = clampRadius(params?.radius); + const entities = world.getNearbyEntities(bot, radius); + const me = bot.entity?.position; + const out = entities.map((e: any) => ({ + type: e.name ?? e.type ?? "unknown", + distance: me && e.position ? Math.round(me.distanceTo(e.position)) : null, + username: e.username ?? null, + position: e.position ? { x: Math.round(e.position.x), y: Math.round(e.position.y), z: Math.round(e.position.z) } : null, + })); + return textResult(JSON.stringify(out), { entities: out }); + }, + }); + + // 5. mc_collect_block + pi.registerTool({ + name: "mc_collect_block", + label: "Collect Block", + description: "Move to and break N blocks of a given type, picking them up. Handles path, tool equip, and dig. Throws if the block can't be found in range.", + parameters: BLOCK_NAME_COUNT_PARAMS, + executionMode: "sequential", + async execute(_id, params: { blockType: string; count?: number }) { + const bot = getBot(); + const count = Math.max(1, Math.min(64, Math.floor(params.count ?? 1))); + const ok = await safeCall("collectBlock", () => skills.collectBlock(bot, params.blockType, count)); + return textResult(ok ? `Collected ${count} of ${params.blockType}.` : `collectBlock returned false for ${params.blockType}.`, { ok, blockType: params.blockType, count }); + }, + }); + + // 6. mc_place_block + pi.registerTool({ + name: "mc_place_block", + label: "Place Block", + description: "Place one block from inventory at the given coordinates. Throws if you do not have the block or the target is not placeable.", + parameters: PLACE_PARAMS, + executionMode: "sequential", + async execute(_id, params: { blockType: string; x: number; y: number; z: number }) { + const bot = getBot(); + const ok = await safeCall("placeBlock", () => skills.placeBlock(bot, params.blockType, params.x, params.y, params.z)); + return textResult(ok ? `Placed ${params.blockType} at ${params.x},${params.y},${params.z}.` : `placeBlock returned false.`, { ok, ...params }); + }, + }); + + // 7. mc_go_to + pi.registerTool({ + name: "mc_go_to", + label: "Go To Coords", + description: "Walk/swim/path to the given coordinates. Will dig through soft obstacles (leaves) and jump as needed. Stop when within minDistance (default 2). Throws on noPath.", + parameters: GOTO_PARAMS, + executionMode: "sequential", + async execute(_id, params: { x: number; y: number; z: number; minDistance?: number }) { + const bot = getBot(); + const ok = await safeCall("goToPosition", () => skills.goToPosition(bot, params.x, params.y, params.z, params.minDistance ?? 2)); + return textResult(ok ? `Arrived near ${params.x},${params.y},${params.z}.` : `goToPosition returned false.`, { ok, ...params }); + }, + }); + + // 8. mc_go_to_block + pi.registerTool({ + name: "mc_go_to_block", + label: "Go To Nearest Block", + description: "Find the nearest block of a given type within `range` (default 64) and walk to it.", + parameters: GOTO_BLOCK_PARAMS, + executionMode: "sequential", + async execute(_id, params: { blockType: string; minDistance?: number; range?: number }) { + const bot = getBot(); + const ok = await safeCall("goToNearestBlock", () => + skills.goToNearestBlock(bot, params.blockType, params.minDistance ?? 2, params.range ?? 64), + ); + return textResult(ok ? `Arrived near nearest ${params.blockType}.` : `goToNearestBlock returned false for ${params.blockType}.`, { ok, ...params }); + }, + }); + + // 9. mc_craft + pi.registerTool({ + name: "mc_craft", + label: "Craft", + description: "Craft an item by name from inventory. Will use a nearby crafting table when required. Throws if recipe unknown or resources missing.", + parameters: CRAFT_PARAMS, + executionMode: "sequential", + async execute(_id, params: { itemName: string; num?: number }) { + const bot = getBot(); + const ok = await safeCall("craftRecipe", () => skills.craftRecipe(bot, params.itemName, Math.max(1, Math.floor(params.num ?? 1)))); + return textResult(ok ? `Crafted ${params.itemName}.` : `craftRecipe returned false.`, { ok, ...params }); + }, + }); + + // 10. mc_equip + pi.registerTool({ + name: "mc_equip", + label: "Equip Item", + description: "Equip an item by name (tool, armor, food).", + parameters: EQUIP_PARAMS, + executionMode: "sequential", + async execute(_id, params: { itemName: string }) { + const bot = getBot(); + const ok = await safeCall("equip", () => skills.equip(bot, params.itemName)); + return textResult(ok ? `Equipped ${params.itemName}.` : `equip returned false for ${params.itemName}.`, { ok, ...params }); + }, + }); + + // 11. mc_consume + pi.registerTool({ + name: "mc_consume", + label: "Consume Food", + description: "Eat food from inventory. If itemName empty, picks first food item.", + parameters: CONSUME_PARAMS, + executionMode: "sequential", + async execute(_id, params: { itemName?: string }) { + const bot = getBot(); + const ok = await safeCall("consume", () => skills.consume(bot, params.itemName ?? "")); + return textResult(ok ? `Ate ${params.itemName ?? "food"}.` : `consume returned false.`, { ok, ...params }); + }, + }); + + // 12. mc_defend_self + pi.registerTool({ + name: "mc_defend_self", + label: "Defend Self", + description: "Attack hostile mobs within range until clear. Uses best available weapon. Range default 9.", + parameters: DEFEND_PARAMS, + executionMode: "sequential", + async execute(_id, params: { range?: number }) { + const bot = getBot(); + const ok = await safeCall("defendSelf", () => skills.defendSelf(bot, params.range ?? 9)); + return textResult(ok ? `Defended against hostiles.` : `defendSelf returned false.`, { ok, ...params }); + }, + }); + + // 13. mc_avoid_enemies + pi.registerTool({ + name: "mc_avoid_enemies", + label: "Avoid Enemies", + description: "Move away from the nearest hostile entities by approx N blocks. Default 16.", + parameters: AVOID_PARAMS, + executionMode: "sequential", + async execute(_id, params: { distance?: number }) { + const bot = getBot(); + const ok = await safeCall("avoidEnemies", () => skills.avoidEnemies(bot, params.distance ?? 16)); + return textResult(ok ? `Avoided enemies.` : `avoidEnemies returned false.`, { ok, ...params }); + }, + }); + + // 14. mc_stay + pi.registerTool({ + name: "mc_stay", + label: "Stay In Place", + description: "Stand still for N seconds (default 30). Useful for waiting out night, regen, or letting world state change.", + parameters: STAY_PARAMS, + executionMode: "sequential", + async execute(_id, params: { seconds?: number }) { + const bot = getBot(); + const secs = Math.max(1, Math.min(600, Math.floor(params.seconds ?? 30))); + await safeCall("stay", () => skills.stay(bot, secs)); + return textResult(`Stood still for ${secs}s.`, { seconds: secs }); + }, + }); + + // 15. mc_pickup_nearby + pi.registerTool({ + name: "mc_pickup_nearby", + label: "Pickup Nearby Items", + description: "Walk over and pick up any dropped item entities within ~8 blocks.", + parameters: EMPTY_PARAMS, + executionMode: "sequential", + async execute() { + const bot = getBot(); + const ok = await safeCall("pickupNearbyItems", () => skills.pickupNearbyItems(bot)); + return textResult(ok ? `Picked up nearby items.` : `pickupNearbyItems returned false.`); + }, + }); +} diff --git a/extensions/mineflayer-bridge.ts b/extensions/mineflayer-bridge.ts index 288fa2d..47f8964 100644 --- a/extensions/mineflayer-bridge.ts +++ b/extensions/mineflayer-bridge.ts @@ -8,6 +8,15 @@ import type { Bot } from "mineflayer"; const require = createRequire(import.meta.url); const dotenv = require("dotenv") as typeof import("dotenv"); const mineflayer = require("mineflayer") as typeof import("mineflayer"); + +// mcdata is an ES module; load it lazily via dynamic import on first use to +// avoid the "Cannot require() ES Module ... not yet fully loaded" race when +// Pi loads multiple extensions in parallel. +let mcdataPromise: Promise<{ attachPluginsAndInit: (bot: any) => any }> | null = null; +function loadMcdata() { + if (!mcdataPromise) mcdataPromise = import("./lib/mcdata.js" as any) as Promise<{ attachPluginsAndInit: (bot: any) => any }>; + return mcdataPromise; +} const pathfinderModule = require("mineflayer-pathfinder") as { pathfinder: (bot: Bot) => void; Movements: new (bot: Bot) => any; @@ -90,6 +99,12 @@ interface BuildPyramidInput { dry_run?: boolean; } +interface DigInput { + x: number; + y: number; + z: number; +} + type MemoryAction = "set_current_task" | "clear_current_task" | "append_diary" | "register_location"; interface MemoryInput { @@ -228,6 +243,17 @@ const BUILD_PYRAMID_PARAMS = { additionalProperties: false, } as const; +const DIG_PARAMS = { + type: "object", + properties: { + x: { type: "number", description: "Block X coordinate to dig." }, + y: { type: "number", description: "Block Y coordinate to dig." }, + z: { type: "number", description: "Block Z coordinate to dig." }, + }, + required: ["x", "y", "z"], + additionalProperties: false, +} as const; + const MEMORY_PARAMS = { type: "object", properties: { @@ -1081,6 +1107,13 @@ export default function mineflayerBridge(pi: ExtensionAPI) { } bot = nextBot; + (globalThis as any).__pepaPiBot = nextBot; + // Attach Mindcraft-required plugins (pathfinder, pvp, collectblock, + // armorManager) and prime mc_version once login completes. Skill calls + // from mindcraft-skills.ts rely on this. Loaded lazily as ESM. + loadMcdata() + .then((m) => { try { m.attachPluginsAndInit(nextBot); } catch (e) { log("plugin-init-error", e); } }) + .catch((e) => log("mcdata-load-error", e)); nextBot.once("login", () => { setConnectionState("connected"); }); @@ -1122,7 +1155,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { lastDisconnectReason = `error: ${truncate(redact(stringifyUnknown(error), current), 200)}`; activeWorldTask = undefined; stopAuthTimer(); - if (bot === nextBot) bot = undefined; + if (bot === nextBot) { bot = undefined; (globalThis as any).__pepaPiBot = undefined; } setConnectionState("disconnected"); try { nextBot.end("connection error"); @@ -1135,7 +1168,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { nextBot.on("end", (reasonText) => { activeWorldTask = undefined; stopAuthTimer(); - if (bot === nextBot) bot = undefined; + if (bot === nextBot) { bot = undefined; (globalThis as any).__pepaPiBot = undefined; } const reasonString = stringifyUnknown(reasonText || lastDisconnectReason || "end"); lastDisconnectReason = reasonString; if (manualDisconnectRequested || shuttingDown) { @@ -1161,6 +1194,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { } const currentBot = bot as Bot & { quit?: (reason?: string) => void; end?: (reason?: string) => void }; bot = undefined; + (globalThis as any).__pepaPiBot = undefined; setConnectionState("disconnected"); if (typeof currentBot.quit === "function") { currentBot.quit(); @@ -1866,6 +1900,31 @@ export default function mineflayerBridge(pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "mc_dig", + label: "Minecraft Dig Block", + description: "Dig one block at exact coordinates using Mineflayer bot.dig(bot.blockAt(new Vec3(x,y,z))).", + promptSnippet: "Dig one block at exact Minecraft coordinates.", + parameters: DIG_PARAMS, + executionMode: "sequential", + async execute(_toolCallId, params: DigInput) { + const currentBot = activeBot(); + const pos = new Vec3( + Math.floor(finiteNumber(params.x, "x")), + Math.floor(finiteNumber(params.y, "y")), + Math.floor(finiteNumber(params.z, "z")), + ); + const block = currentBot.blockAt(pos); + if (!block) throw new Error(`No loaded block at ${pos.x},${pos.y},${pos.z}.`); + if (block.name === "air") throw new Error(`Block at ${pos.x},${pos.y},${pos.z} is air.`); + await currentBot.dig(block); + return { + content: [{ type: "text", text: `Dug ${block.name} at x=${pos.x}, y=${pos.y}, z=${pos.z}.` }], + details: { x: pos.x, y: pos.y, z: pos.z, block: block.name }, + }; + }, + }); + pi.registerTool({ name: "mc_goto", label: "Minecraft Guarded Go To", diff --git a/package-lock.json b/package-lock.json index c7fe736..1d31726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,16 @@ "license": "MIT", "dependencies": { "dotenv": "^16.4.5", + "minecraft-data": "^3.110.2", "mineflayer": "^4.37.1", "mineflayer-armor-manager": "^2.0.1", "mineflayer-auto-eat": "^5.0.3", "mineflayer-collectblock": "^1.6.0", "mineflayer-pathfinder": "^2.4.5", - "mineflayer-tool": "^1.2.0" + "mineflayer-pvp": "^1.3.2", + "mineflayer-tool": "^1.2.0", + "prismarine-item": "^1.18.0", + "vec3": "^0.2.0" }, "engines": { "node": ">=20" @@ -526,6 +530,23 @@ "vec3": "^0.1.7" } }, + "node_modules/mineflayer-pathfinder/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, + "node_modules/mineflayer-pvp": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mineflayer-pvp/-/mineflayer-pvp-1.3.2.tgz", + "integrity": "sha512-CI2T5w4ceiQdQRCFdrCs3OYmvO9G0cOx2qovUt6f27gknpVI90Ihe1qYhf+zBZJCg9uP1oJ1Pemlzw46Mm3wcA==", + "license": "MIT", + "dependencies": { + "mineflayer": "^4.0.0", + "mineflayer-pathfinder": "^2.0.0", + "mineflayer-utils": "^0.1.4" + } + }, "node_modules/mineflayer-tool": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mineflayer-tool/-/mineflayer-tool-1.2.0.tgz", @@ -537,6 +558,87 @@ "prismarine-nbt": "^2.0.0" } }, + "node_modules/mineflayer-utils": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/mineflayer-utils/-/mineflayer-utils-0.1.4.tgz", + "integrity": "sha512-8+0dbGAjA6FO62/W80v5k44AuSAcwJUMdpMAAGhjI9AdCDz+UuyTnNkPBncF4EcLY7yUUdtA7m/OXQZKFdWOlg==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.27", + "mineflayer": "^2.27.0", + "prismarine-entity": "^1.0.0", + "require-self": "^0.2.3", + "typescript": "^3.9.7" + } + }, + "node_modules/mineflayer-utils/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/mineflayer-utils/node_modules/minecraft-data": { + "version": "2.221.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-2.221.0.tgz", + "integrity": "sha512-0AhqzbIKb6WqPSF6qBevaPryeWOz545hLxt6q+gfJF8YIQX/YfkyX/nXWhl+pSIS2rTBcQ0RJkRCtTeRzQwHDA==", + "license": "MIT" + }, + "node_modules/mineflayer-utils/node_modules/mineflayer": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-2.41.0.tgz", + "integrity": "sha512-IFFy4NgF24FU2PkAwazJphl2F+3gpbpN578ex0sq1XfcBBRge3kCz1UC2KDMjKI+V/8vffOL+OEnug9jt3f7Vw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^2.70.0", + "minecraft-protocol": "^1.17.0", + "prismarine-biome": "^1.1.0", + "prismarine-block": "^1.6.0", + "prismarine-chat": "^1.0.0", + "prismarine-chunk": "^1.20.3", + "prismarine-entity": "^1.0.0", + "prismarine-item": "^1.5.0", + "prismarine-physics": "^1.0.4", + "prismarine-recipe": "^1.1.0", + "prismarine-windows": "^1.5.0", + "prismarine-world": "^3.2.0", + "protodef": "^1.8.0", + "typed-emitter": "^1.2.0", + "vec3": "^0.1.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/mineflayer-utils/node_modules/prismarine-entity": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-1.2.0.tgz", + "integrity": "sha512-4dQ9LYl6HDJQrwZHjSKU4D5VNyHRnfrjcw7eVLlbRPkuR50utW5mmfPi4ys9U7tHNmGWHC/cwjH9xzT75LUovQ==", + "license": "MIT", + "dependencies": { + "vec3": "^0.1.4" + } + }, + "node_modules/mineflayer-utils/node_modules/prismarine-windows": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-1.6.0.tgz", + "integrity": "sha512-026LG1yR76Xb62kM+W83IWT7Wy2yKplllbXNFBF2m0Lr4k4YpYKnpLb8tRft8MLOLRbYAt/KnxE/YKvRZul7kw==", + "license": "MIT", + "dependencies": { + "prismarine-item": "^1.4.0" + } + }, + "node_modules/mineflayer-utils/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, + "node_modules/mineflayer/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, "node_modules/mojangson": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz", @@ -676,6 +778,12 @@ "node": ">=14" } }, + "node_modules/prismarine-chunk/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, "node_modules/prismarine-entity": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz", @@ -688,6 +796,12 @@ "vec3": "^0.1.4" } }, + "node_modules/prismarine-entity/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, "node_modules/prismarine-item": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz", @@ -718,6 +832,12 @@ "vec3": "^0.1.7" } }, + "node_modules/prismarine-physics/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, "node_modules/prismarine-realms": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", @@ -780,6 +900,12 @@ "node": ">=8.0.0" } }, + "node_modules/prismarine-world/node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -859,6 +985,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/require-self": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/require-self/-/require-self-0.2.3.tgz", + "integrity": "sha512-keGBWkK0PWJGFAd6IznpjM5zZzySbsrvzq0ElXpZ4G8hzymV9I+/OnC916MF5mRxHRWSZKGIyCFJ73BZFz3xaA==", + "license": "MIT", + "bin": { + "require-self": "bin/require-self" + } + }, "node_modules/ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -948,6 +1083,19 @@ "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", "license": "MIT" }, + "node_modules/typescript": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", + "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/uint4": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz", @@ -989,9 +1137,9 @@ } }, "node_modules/vec3": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", - "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.2.0.tgz", + "integrity": "sha512-jOjU4zbCNWOKOGLHop1I0J2Ar4h5Zz76455LJhJKhiOJwI21QP6d5TnKklNZDKG+c5h71zWtPAbd9wh+Va57OQ==", "license": "BSD" }, "node_modules/webidl-conversions": { diff --git a/package.json b/package.json index 9170e7e..9ecf7c1 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,16 @@ }, "dependencies": { "dotenv": "^16.4.5", + "minecraft-data": "^3.110.2", "mineflayer": "^4.37.1", "mineflayer-armor-manager": "^2.0.1", "mineflayer-auto-eat": "^5.0.3", "mineflayer-collectblock": "^1.6.0", "mineflayer-pathfinder": "^2.4.5", - "mineflayer-tool": "^1.2.0" + "mineflayer-pvp": "^1.3.2", + "mineflayer-tool": "^1.2.0", + "prismarine-item": "^1.18.0", + "vec3": "^0.2.0" }, "repository": { "type": "git",