From b0dd132fbf3615d3a8322bf808cc8377e1f06e8e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 01:09:51 +0800 Subject: [PATCH] feat: skills --- services/minecraft/src/agents/actions.ts | 583 ++++++++++++++++++ services/minecraft/src/agents/openai.ts | 2 +- .../src/agents/{query.ts => queries.ts} | 0 services/minecraft/src/skills/base.ts | 22 + services/minecraft/src/skills/blocks.ts | 396 ++++++++++++ services/minecraft/src/skills/combat.ts | 133 ++++ services/minecraft/src/skills/crafting.ts | 268 ++++++++ services/minecraft/src/skills/index.ts | 50 ++ services/minecraft/src/skills/inventory.ts | 257 ++++++++ services/minecraft/src/skills/movement.ts | 208 +++++++ 10 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 services/minecraft/src/agents/actions.ts rename services/minecraft/src/agents/{query.ts => queries.ts} (100%) create mode 100644 services/minecraft/src/skills/base.ts create mode 100644 services/minecraft/src/skills/blocks.ts create mode 100644 services/minecraft/src/skills/combat.ts create mode 100644 services/minecraft/src/skills/crafting.ts create mode 100644 services/minecraft/src/skills/index.ts create mode 100644 services/minecraft/src/skills/inventory.ts create mode 100644 services/minecraft/src/skills/movement.ts diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts new file mode 100644 index 000000000..25e2997b8 --- /dev/null +++ b/services/minecraft/src/agents/actions.ts @@ -0,0 +1,583 @@ +import type { BotContext } from 'src/composables/bot' +import { z } from 'zod' +import * as skills from '../skills' + +function runAsAction(actionFn, resume = false, timeout = -1) { + let actionLabel = null // Will be set on first use + + const wrappedAction = async function (agent, ...args) { + // Set actionLabel only once, when the action is first created + if (!actionLabel) { + const actionObj = actionsList.find(a => a.perform === wrappedAction) + actionLabel = actionObj.name.substring(1) // Remove the ! prefix + } + + const actionFnWithAgent = async () => { + await actionFn(agent, ...args) + } + const code_return = await agent.actions.runAction(`action:${actionLabel}`, actionFnWithAgent, { timeout, resume }) + if (code_return.interrupted && !code_return.timedout) + return + return code_return.message + } + + return wrappedAction +} + +type ActionResult = string | Promise + +interface Action { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (ctx: BotContext) => () => ActionResult +} + +function getNewAction(): Action { + return { + name: '!newAction', + description: 'Perform new and unknown custom behaviors that are not available as a command.', + schema: z.object({ + prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), + }), + perform: (agent: BotContext) => async () => { + if (!settings.allow_insecure_coding) + return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' + return await agent.coder.generateCode(agent.history) + }, + } +} + +function getStopAction(): Action { + return { + name: '!stop', + description: 'Force stop all actions and commands that are currently executing.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + await agent.actions.stop() + agent.clearBotLogs() + agent.actions.cancelResume() + agent.bot.emit('idle') + let msg = 'Agent stopped.' + if (agent.self_prompter.on) + msg += ' Self-prompting still active.' + return msg + }, + } +} + +function getStfuAction(): Action { + return { + name: '!stfu', + description: 'Stop all chatting and self prompting, but continue current action.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.openChat('Shutting up.') + agent.shutUp() + }, + } +} + +function getRestartAction(): Action { + return { + name: '!restart', + description: 'Restart the agent process.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.cleanKill() + }, + } +} + +function getClearChatAction(): Action { + return { + name: '!clearChat', + description: 'Clear the chat history.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.history.clear() + return `${agent.name}'s chat history was cleared, starting new conversation from scratch.` + }, + } +} + +function getGoToPlayerAction(): Action { + return { + name: '!goToPlayer', + description: 'Go to the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to go to.'), + closeness: z.number().describe('How close to get to the player.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, closeness) => { + return await skills.goToPlayer(agent.bot, player_name, closeness) + }), + } +} + +function getFollowPlayerAction(): Action { + return { + name: '!followPlayer', + description: 'Endlessly follow the given player.', + schema: z.object({ + player_name: z.string().describe('name of the player to follow.'), + follow_dist: z.number().describe('The distance to follow from.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, follow_dist) => { + await skills.followPlayer(agent.bot, player_name, follow_dist) + }, true), + } +} + +function getGoToCoordinatesAction(): Action { + return { + name: '!goToCoordinates', + description: 'Go to the given x, y, z location.', + schema: z.object({ + x: z.number().describe('The x coordinate.'), + y: z.number().describe('The y coordinate.').min(-64).max(320), + z: z.number().describe('The z coordinate.'), + closeness: z.number().describe('How close to get to the location.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, x, y, z, closeness) => { + await skills.goToPosition(agent.bot, x, y, z, closeness) + }), + } +} + +function getSearchForBlockAction(): Action { + return { + name: '!searchForBlock', + description: 'Find and go to the nearest block of a given type in a given range.', + schema: z.object({ + type: z.string().describe('The block type to go to.'), + search_range: z.number().describe('The range to search for the block.').min(32).max(512), + }), + perform: (agent: BotContext) => runAsAction(async (agent, block_type, range) => { + await skills.goToNearestBlock(agent.bot, block_type, 4, range) + }), + } +} + +function getSearchForEntityAction(): Action { + return { + name: '!searchForEntity', + description: 'Find and go to the nearest entity of a given type in a given range.', + schema: z.object({ + type: z.string().describe('The type of entity to go to.'), + search_range: z.number().describe('The range to search for the entity.').min(32).max(512), + }), + perform: (agent: BotContext) => runAsAction(async (agent, entity_type, range) => { + await skills.goToNearestEntity(agent.bot, entity_type, 4, range) + }), + } +} + +function getMoveAwayAction(): Action { + return { + name: '!moveAway', + description: 'Move away from the current location in any direction by a given distance.', + schema: z.object({ + distance: z.number().describe('The distance to move away.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, distance) => { + await skills.moveAway(agent.bot, distance) + }), + } +} + +function getRememberHereAction(): Action { + return { + name: '!rememberHere', + description: 'Save the current location with a given name.', + schema: z.object({ + name: z.string().describe('The name to remember the location as.'), + }), + perform: (agent: BotContext) => async (name: string) => { + const pos = agent.bot.entity.position + agent.memory_bank.rememberPlace(name, pos.x, pos.y, pos.z) + return `Location saved as "${name}".` + }, + } +} + +function getGoToRememberedPlaceAction(): Action { + return { + name: '!goToRememberedPlace', + description: 'Go to a saved location.', + schema: z.object({ + name: z.string().describe('The name of the location to go to.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, name) => { + const pos = agent.memory_bank.recallPlace(name) + if (!pos) { + skills.log(agent.bot, `No location named "${name}" saved.`) + return + } + await skills.goToPosition(agent.bot, pos[0], pos[1], pos[2], 1) + }), + } +} + +function getGivePlayerAction(): Action { + return { + name: '!givePlayer', + description: 'Give the specified item to the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to give the item to.'), + item_name: z.string().describe('The name of the item to give.'), + num: z.number().int().describe('The number of items to give.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, item_name, num) => { + await skills.giveToPlayer(agent.bot, item_name, player_name, num) + }), + } +} + +function getConsumeAction(): Action { + return { + name: '!consume', + description: 'Eat/drink the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to consume.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { + await skills.consume(agent.bot, item_name) + }), + } +} + +function getEquipAction(): Action { + return { + name: '!equip', + description: 'Equip the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to equip.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { + await skills.equip(agent.bot, item_name) + }), + } +} + +function getPutInChestAction(): Action { + return { + name: '!putInChest', + description: 'Put the given item in the nearest chest.', + schema: z.object({ + item_name: z.string().describe('The name of the item to put in the chest.'), + num: z.number().int().describe('The number of items to put in the chest.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + await skills.putInChest(agent.bot, item_name, num) + }), + } +} + +function getTakeFromChestAction(): Action { + return { + name: '!takeFromChest', + description: 'Take the given items from the nearest chest.', + schema: z.object({ + item_name: z.string().describe('The name of the item to take.'), + num: z.number().int().describe('The number of items to take.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + await skills.takeFromChest(agent.bot, item_name, num) + }), + } +} + +function getViewChestAction(): Action { + return { + name: '!viewChest', + description: 'View the items/counts of the nearest chest.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.viewChest(agent.bot) + }), + } +} + +function getDiscardAction(): Action { + return { + name: '!discard', + description: 'Discard the given item from the inventory.', + schema: z.object({ + item_name: z.string().describe('The name of the item to discard.'), + num: z.number().int().describe('The number of items to discard.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + const start_loc = agent.bot.entity.position + await skills.moveAway(agent.bot, 5) + await skills.discard(agent.bot, item_name, num) + await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0) + }), + } +} + +function getCollectBlocksAction(): Action { + return { + name: '!collectBlocks', + description: 'Collect the nearest blocks of a given type.', + schema: z.object({ + type: z.string().describe('The block type to collect.'), + num: z.number().int().describe('The number of blocks to collect.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type, num) => { + await skills.collectBlock(agent.bot, type, num) + }, false, 10), // 10 minute timeout + } +} + +function getCraftRecipeAction(): Action { + return { + name: '!craftRecipe', + description: 'Craft the given recipe a given number of times.', + schema: z.object({ + recipe_name: z.string().describe('The name of the output item to craft.'), + num: z.number().int().describe('The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, recipe_name, num) => { + await skills.craftRecipe(agent.bot, recipe_name, num) + }), + } +} + +function getSmeltItemAction(): Action { + return { + name: '!smeltItem', + description: 'Smelt the given item the given number of times.', + schema: z.object({ + item_name: z.string().describe('The name of the input item to smelt.'), + num: z.number().int().describe('The number of times to smelt the item.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + const success = await skills.smeltItem(agent.bot, item_name, num) + if (success) { + setTimeout(() => { + agent.cleanKill('Safely restarting to update inventory.') + }, 500) + } + }), + } +} + +function getClearFurnaceAction(): Action { + return { + name: '!clearFurnace', + description: 'Take all items out of the nearest furnace.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.clearNearestFurnace(agent.bot) + }), + } +} + +function getPlaceHereAction(): Action { + return { + name: '!placeHere', + description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.', + schema: z.object({ + type: z.string().describe('The block type to place.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + const pos = agent.bot.entity.position + await skills.placeBlock(agent.bot, type, pos.x, pos.y, pos.z) + }), + } +} + +function getAttackAction(): Action { + return { + name: '!attack', + description: 'Attack and kill the nearest entity of a given type.', + schema: z.object({ + type: z.string().describe('The type of entity to attack.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + await skills.attackNearest(agent.bot, type, true) + }), + } +} + +function getAttackPlayerAction(): Action { + return { + name: '!attackPlayer', + description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', + schema: z.object({ + player_name: z.string().describe('The name of the player to attack.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name) => { + const player = agent.bot.players[player_name]?.entity + if (!player) { + skills.log(agent.bot, `Could not find player ${player_name}.`) + return false + } + await skills.attackEntity(agent.bot, player, true) + }), + } +} + +function getGoToBedAction(): Action { + return { + name: '!goToBed', + description: 'Go to the nearest bed and sleep.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.goToBed(agent.bot) + }), + } +} + +function getActivateAction(): Action { + return { + name: '!activate', + description: 'Activate the nearest object of a given type.', + schema: z.object({ + type: z.string().describe('The type of object to activate.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + await skills.activateNearestBlock(agent.bot, type) + }), + } +} + +function getStayAction(): Action { + return { + name: '!stay', + description: 'Stay in the current location no matter what. Pauses all modes.', + schema: z.object({ + type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, seconds) => { + await skills.stay(agent.bot, seconds) + }), + } +} + +function getSetModeAction(): Action { + return { + name: '!setMode', + description: 'Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.', + schema: z.object({ + mode_name: z.string().describe('The name of the mode to enable.'), + on: z.boolean().describe('Whether to enable or disable the mode.'), + }), + perform: (agent: BotContext) => async (mode_name: string, on: boolean) => { + const modes = agent.bot.modes + if (!modes.exists(mode_name)) + return `Mode ${mode_name} does not exist.${modes.getDocs()}` + if (modes.isOn(mode_name) === on) + return `Mode ${mode_name} is already ${on ? 'on' : 'off'}.` + modes.setOn(mode_name, on) + return `Mode ${mode_name} is now ${on ? 'on' : 'off'}.` + }, + } +} + +function getGoalAction(): Action { + return { + name: '!goal', + description: 'Set a goal prompt to endlessly work towards with continuous self-prompting.', + schema: z.object({ + selfPrompt: z.string().describe('The goal prompt.'), + }), + perform: (agent: BotContext) => async (prompt: string) => { + if (convoManager.inConversation()) { + agent.self_prompter.setPrompt(prompt) + convoManager.scheduleSelfPrompter() + } + else { + agent.self_prompter.start(prompt) + } + }, + } +} + +function getEndGoalAction(): Action { + return { + name: '!endGoal', + description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.self_prompter.stop() + convoManager.cancelSelfPrompter() + return 'Self-prompting stopped.' + }, + } +} + +function getStartConversationAction(): Action { + return { + name: '!startConversation', + description: 'Start a conversation with a player. Use for bots only.', + schema: z.object({ + player_name: z.string().describe('The name of the player to send the message to.'), + message: z.string().describe('The message to send.'), + }), + perform: (agent: BotContext) => async (player_name: string, message: string) => { + if (!convoManager.isOtherAgent(player_name)) + return `${player_name} is not a bot, cannot start conversation.` + if (convoManager.inConversation() && !convoManager.inConversation(player_name)) + convoManager.forceEndCurrentConversation() + else if (convoManager.inConversation(player_name)) + agent.history.add('system', `You are already in conversation with ${player_name}. Don't use this command to talk to them.`) + convoManager.startConversation(player_name, message) + }, + } +} + +function getEndConversationAction(): Action { + return { + name: '!endConversation', + description: 'End the conversation with the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to end the conversation with.'), + }), + perform: (agent: BotContext) => async (player_name: string) => { + if (!convoManager.inConversation(player_name)) + return `Not in conversation with ${player_name}.` + convoManager.endConversation(player_name) + return `Converstaion with ${player_name} ended.` + }, + } +} + +export const actionsList = [ + getNewAction(), + getStopAction(), + getStfuAction(), + getRestartAction(), + getClearChatAction(), + getGoToPlayerAction(), + getFollowPlayerAction(), + getGoToCoordinatesAction(), + getSearchForBlockAction(), + getSearchForEntityAction(), + getMoveAwayAction(), + getRememberHereAction(), + getGoToRememberedPlaceAction(), + getGivePlayerAction(), + getConsumeAction(), + getEquipAction(), + getPutInChestAction(), + getTakeFromChestAction(), + getViewChestAction(), + getDiscardAction(), + getCollectBlocksAction(), + getCraftRecipeAction(), + getSmeltItemAction(), + getClearFurnaceAction(), + getPlaceHereAction(), + getAttackAction(), + getAttackPlayerAction(), + getGoToBedAction(), + getActivateAction(), + getStayAction(), + getSetModeAction(), + getGoalAction(), + getEndGoalAction(), + getStartConversationAction(), + getEndConversationAction(), +] diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 991e99fd9..6989b7999 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -3,7 +3,7 @@ import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { queryList } from './query' +import { queryList } from './queries' const agents = new Set>() diff --git a/services/minecraft/src/agents/query.ts b/services/minecraft/src/agents/queries.ts similarity index 100% rename from services/minecraft/src/agents/query.ts rename to services/minecraft/src/agents/queries.ts diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts new file mode 100644 index 000000000..ed7ae041c --- /dev/null +++ b/services/minecraft/src/skills/base.ts @@ -0,0 +1,22 @@ +import type { Bot } from 'mineflayer' + +/** + * Log a message to the bot's output + */ +export function log(bot: Bot, message: string): void { + bot.chat(`${message}`) +} + +/** + * Type definition for a position in the world + */ +export interface Position { + x: number + y: number + z: number +} + +/** + * Type definition for a block face direction + */ +export type BlockFace = 'top' | 'bottom' | 'north' | 'south' | 'east' | 'west' | 'side' diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts new file mode 100644 index 000000000..a96e97e51 --- /dev/null +++ b/services/minecraft/src/skills/blocks.ts @@ -0,0 +1,396 @@ +import type { Bot } from 'mineflayer' +import type { BlockFace } from './base' +import Vec3 from 'vec3' +import * as world from '../composables/world' +import * as mc from '../utils/mcdata' +import { log } from './base' +import { goToPosition } from './movement' + +/** + * Place a torch if needed + */ +async function autoLight(bot: Bot): Promise { + if (world.shouldPlaceTorch(bot)) { + try { + const pos = world.getPosition(bot) + return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true) + } + catch { + return false + } + } + return false +} + +/** + * Break a block at the specified position + */ +export async function breakBlockAt( + bot: Bot, + x: number, + y: number, + z: number, +): Promise { + if (x == null || y == null || z == null) { + throw new Error('Invalid position to break block at.') + } + + const block = bot.blockAt(new Vec3(x, y, z)) + if (block.name === 'air' || block.name === 'water' || block.name === 'lava') { + return false + } + + if (bot.modes.isOn('cheat')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + log(bot, `Used /setblock to break block at ${x}, ${y}, ${z}.`) + return true + } + + if (bot.entity.position.distanceTo(block.position) > 4.5) { + const pos = block.position + const movements = new bot.pathfinder.Movements(bot) + movements.allowParkour = false + movements.allowSprinting = false + bot.pathfinder.setMovements(movements) + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + + if (bot.game.gameMode !== 'creative') { + await bot.tool.equipForBlock(block) + const itemId = bot.heldItem?.type + 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)}.`) + return true +} + +/** + * Place a block at the specified position + */ +export async function placeBlock( + bot: Bot, + blockType: string, + x: number, + y: number, + z: number, + placeOn: BlockFace = 'bottom', + dontCheat = false, +): Promise { + if (!mc.getBlockId(blockType)) { + log(bot, `Invalid block type: ${blockType}.`) + return false + } + + const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + + if (bot.modes.isOn('cheat') && !dontCheat) { + // Invert the facing direction + const face = placeOn === 'north' + ? 'south' + : placeOn === 'south' + ? 'north' + : placeOn === 'east' + ? 'west' + : placeOn === 'west' + ? 'east' + : placeOn + + let blockState = blockType + if (blockType.includes('torch') && placeOn !== 'bottom') { + blockState = blockType.replace('torch', 'wall_torch') + if (placeOn !== 'side' && placeOn !== 'top') { + blockState += `[facing=${face}]` + } + } + + if (blockType.includes('button') || blockType === 'lever') { + if (placeOn === 'top') { + blockState += '[face=ceiling]' + } + else if (placeOn === 'bottom') { + blockState += '[face=floor]' + } + else { + blockState += `[facing=${face}]` + } + } + + if (blockType === 'ladder' || blockType === 'repeater' || blockType === 'comparator') { + blockState += `[facing=${face}]` + } + + if (blockType.includes('stairs')) { + blockState += `[facing=${face}]` + } + + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} ${blockState}`) + + if (blockType.includes('door')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y + 1)} ${Math.floor(z)} ${blockState}[half=upper]`) + } + + if (blockType.includes('bed')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z - 1)} ${blockState}[part=head]`) + } + + log(bot, `Used /setblock to place ${blockType} at ${targetDest}.`) + return true + } + + let itemName = blockType + if (itemName === 'redstone_wire') { + itemName = 'redstone' + } + + let block = bot.inventory.items().find(item => item.name === itemName) + if (!block && bot.game.gameMode === 'creative') { + await bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) + block = bot.inventory.items().find(item => item.name === itemName) + } + + if (!block) { + log(bot, `Don't have any ${blockType} to place.`) + return false + } + + const targetBlock = bot.blockAt(targetDest) + if (targetBlock.name === blockType) { + log(bot, `${blockType} already at ${targetBlock.position}.`) + return false + } + + const emptyBlocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern'] + if (!emptyBlocks.includes(targetBlock.name)) { + log(bot, `${blockType} 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)) + } + + const dirMap = { + top: new Vec3(0, 1, 0), + bottom: new Vec3(0, -1, 0), + north: new Vec3(0, 0, -1), + south: new Vec3(0, 0, 1), + east: new Vec3(1, 0, 0), + west: new Vec3(-1, 0, 0), + } + + const dirs = [] + if (placeOn === 'side') { + dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) + } + else if (dirMap[placeOn]) { + dirs.push(dirMap[placeOn]) + } + else { + dirs.push(dirMap.bottom) + log(bot, `Unknown placeOn value "${placeOn}". Defaulting to bottom.`) + } + dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) + + let buildOffBlock = null + let faceVec = null + + for (const d of dirs) { + const block = bot.blockAt(targetDest.plus(d)) + if (!emptyBlocks.includes(block.name)) { + buildOffBlock = block + faceVec = new Vec3(-d.x, -d.y, -d.z) + break + } + } + + if (!buildOffBlock) { + log(bot, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`) + return false + } + + const pos = bot.entity.position + const posAbove = pos.plus(new Vec3(0, 1, 0)) + const dontMoveFor = [ + 'torch', + 'redstone_torch', + 'redstone_wire', + 'lever', + 'button', + 'rail', + 'detector_rail', + 'powered_rail', + 'activator_rail', + 'tripwire_hook', + 'tripwire', + 'water_bucket', + ] + + if (!dontMoveFor.includes(blockType) + && (pos.distanceTo(targetBlock.position) < 1 + || posAbove.distanceTo(targetBlock.position) < 1)) { + const goal = bot.pathfinder.goals.GoalNear(targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + bot.pathfinder.setMovements(new bot.pathfinder.Movements(bot)) + await bot.pathfinder.goto(invertedGoal) + } + + if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + const pos = targetBlock.position + const movements = new bot.pathfinder.Movements(bot) + bot.pathfinder.setMovements(movements) + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + + await bot.equip(block, 'hand') + await bot.lookAt(buildOffBlock.position) + + try { + await bot.placeBlock(buildOffBlock, faceVec) + log(bot, `Placed ${blockType} at ${targetDest}.`) + await new Promise(resolve => setTimeout(resolve, 200)) + return true + } + catch { + log(bot, `Failed to place ${blockType} at ${targetDest}.`) + return false + } +} + +/** + * Use a door at the specified position + */ +export async function useDoor(bot: Bot, doorPos: Vec3 | null = null): Promise { + if (!doorPos) { + const doorTypes = [ + 'oak_door', + 'spruce_door', + 'birch_door', + 'jungle_door', + 'acacia_door', + 'dark_oak_door', + 'mangrove_door', + 'cherry_door', + 'bamboo_door', + 'crimson_door', + 'warped_door', + ] + + for (const doorType of doorTypes) { + const block = world.getNearestBlock(bot, doorType, 16) + if (block) { + doorPos = block.position + break + } + } + } + + if (!doorPos) { + log(bot, 'Could not find a door to use.') + return false + } + + await goToPosition(bot, doorPos.x, doorPos.y, doorPos.z, 1) + while (bot.pathfinder.isMoving()) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + const doorBlock = bot.blockAt(doorPos) + await bot.lookAt(doorPos) + + if (!doorBlock._properties.open) { + await bot.activateBlock(doorBlock) + } + + bot.setControlState('forward', true) + await new Promise(resolve => setTimeout(resolve, 600)) + bot.setControlState('forward', false) + await bot.activateBlock(doorBlock) + + log(bot, `Used door at ${doorPos}.`) + return true +} + +/** + * Till and sow a block at the specified position + */ +export async function tillAndSow( + bot: Bot, + x: number, + y: number, + z: number, + seedType: string | null = null, +): Promise { + x = Math.round(x) + y = Math.round(y) + z = Math.round(z) + + const block = bot.blockAt(new Vec3(x, y, z)) + 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 + } + + const above = bot.blockAt(new Vec3(x, y + 1, z)) + if (above.name !== 'air') { + log(bot, `Cannot till, there is ${above.name} above the block.`) + return false + } + + if (bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(bot, block.position.x, block.position.y, block.position.z, 4) + } + + if (block.name !== 'farmland') { + const hoe = bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + log(bot, 'Cannot till, no hoes.') + return false + } + await bot.equip(hoe, 'hand') + 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' // Fix common mistake + } + + const seeds = bot.inventory.items().find(item => item.name === seedType) + if (!seeds) { + log(bot, `No ${seedType} to plant.`) + return false + } + + await bot.equip(seeds, 'hand') + await bot.placeBlock(block, new Vec3(0, -1, 0)) + log(bot, `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + } + + return true +} + +/** + * Activate the nearest block of a specific type + */ +export async function activateNearestBlock(bot: Bot, type: string): Promise { + const 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) { + await goToPosition(bot, block.position.x, block.position.y, block.position.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 +} diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts new file mode 100644 index 000000000..8afd98aca --- /dev/null +++ b/services/minecraft/src/skills/combat.ts @@ -0,0 +1,133 @@ +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' +import * as mc from '../../../utils/mcdata.js' +import * as world from '../world.js' +import { log } from './base.js' + +/** + * Equip the item with highest attack damage + */ +async function equipHighestAttack(bot: Bot): Promise { + const weapons = bot.inventory.items().filter(item => + item.name.includes('sword') + || (item.name.includes('axe') && !item.name.includes('pickaxe')), + ) + + if (weapons.length === 0) { + const tools = bot.inventory.items().filter(item => + item.name.includes('pickaxe') + || item.name.includes('shovel'), + ) + if (tools.length === 0) + return + + tools.sort((a, b) => b.attackDamage - a.attackDamage) + const tool = tools[0] + if (tool) + await bot.equip(tool, 'hand') + return + } + + weapons.sort((a, b) => b.attackDamage - a.attackDamage) + const weapon = weapons[0] + if (weapon) + await bot.equip(weapon, 'hand') +} + +/** + * Attack the nearest mob of the given type + */ +export async function attackNearest(bot: Bot, mobType: string, kill = true): Promise { + bot.modes.pause('cowardice') + if (mobType === 'drowned' || mobType === 'cod' || mobType === 'salmon' + || mobType === 'tropical_fish' || mobType === 'squid') { + bot.modes.pause('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 +} + +/** + * Attack a specific entity + */ +export async function attackEntity(bot: Bot, entity: Entity, kill = true): Promise { + const pos = entity.position + await equipHighestAttack(bot) + + if (!kill) { + if (bot.entity.position.distanceTo(pos) > 5) { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + 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}.`) + return true + } + return true +} + +/** + * Defend against nearby hostile mobs + */ +export async function defendSelf(bot: Bot, range = 9): Promise { + 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 { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(enemy, 3.5), true) + } + catch { /* might error if entity dies, ignore */ } + } + + if (bot.entity.position.distanceTo(enemy.position) <= 2) { + try { + const inverted_goal = bot.pathfinder.goals.GoalInvert( + bot.pathfinder.goals.GoalFollow(enemy, 2), + ) + await bot.pathfinder.goto(inverted_goal, true) + } + catch { /* 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 +} diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts new file mode 100644 index 000000000..b364117e9 --- /dev/null +++ b/services/minecraft/src/skills/crafting.ts @@ -0,0 +1,268 @@ +import type { Bot } from 'mineflayer' +import * as world from '../composables/world' +import * as mc from '../utils/mcdata' +import { log } from './base' +import { collectBlock } from './blocks' +import { goToPosition } from './movement' + +/** + * Craft items from a recipe + */ +export async function craftRecipe(bot: Bot, itemName: string, num = 1): Promise { + 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 = 32 + + if (!recipes || recipes.length === 0) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, true) + if (!recipes || recipes.length === 0) { + log(bot, `You do not have the resources to craft a ${itemName}.`) + return false + } + + // Look for crafting table + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange) + if (!craftingTable) { + // Try to place crafting table + const hasTable = world.getInventoryCounts(bot).crafting_table > 0 + if (hasTable) { + const 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]) + .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 goToPosition(bot, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) + } + + const recipe = recipes[0] + // 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 < num) { + log(bot, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + } + else { + log(bot, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + } + + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1) + } + + // Equip any armor the bot may have crafted + bot.armorManager.equipAll() + + return true +} + +/** + * Smelt items in a furnace + */ +export async function smeltItem(bot: Bot, itemName: string, num = 1): Promise { + if (!mc.isSmeltable(itemName)) { + log(bot, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) + return false + } + + let placedFurnace = false + const furnaceRange = 32 + let furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange) + + if (!furnaceBlock) { + // Try to place furnace + const hasFurnace = world.getInventoryCounts(bot).furnace > 0 + if (hasFurnace) { + const 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 goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + } + + bot.modes.pause('unstuck') + await bot.lookAt(furnaceBlock.position) + + const furnace = await bot.openFurnace(furnaceBlock) + + // Check if the furnace is already smelting something + const inputItem = furnace.inputItem() + if (inputItem && inputItem.type !== mc.getItemId(itemName) && inputItem.count > 0) { + log(bot, `The furnace is currently smelting ${mc.getItemName(inputItem.type)}.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + // Check if the bot has enough items to smelt + const invCounts = world.getInventoryCounts(bot) + if (!invCounts[itemName] || invCounts[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()) { + const 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 putFuel = Math.ceil(num / mc.getFuelSmeltOutput(fuel.name)) + + if (fuel.count < putFuel) { + log(bot, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + await furnace.putFuel(fuel.type, null, putFuel) + log(bot, `Added ${putFuel} ${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 collectedLast = true + let smeltedItem = null + await new Promise(resolve => setTimeout(resolve, 200)) + + while (total < num) { + await new Promise(resolve => setTimeout(resolve, 10000)) + let collected = false + + if (furnace.outputItem()) { + smeltedItem = await furnace.takeOutput() + if (smeltedItem) { + total += smeltedItem.count + collected = true + } + } + + if (!collected && !collectedLast) { + break // If nothing was collected this time or last time + } + + collectedLast = collected + if (bot.interrupt_code) { + break + } + } + + 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(smeltedItem.type)}.`) + return false + } + + log(bot, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smeltedItem.type)}.`) + return true +} + +/** + * Clear the nearest furnace + */ +export async function clearNearestFurnace(bot: Bot): Promise { + const 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 goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + } + + const furnace = await bot.openFurnace(furnaceBlock) + + // Take the items out of the furnace + let smeltedItem, inputItem, fuelItem + + if (furnace.outputItem()) { + smeltedItem = await furnace.takeOutput() + } + + if (furnace.inputItem()) { + inputItem = await furnace.takeInput() + } + + if (furnace.fuelItem()) { + fuelItem = await furnace.takeFuel() + } + + const smeltedName = smeltedItem ? `${smeltedItem.count} ${smeltedItem.name}` : '0 smelted items' + const inputName = inputItem ? `${inputItem.count} ${inputItem.name}` : '0 input items' + const fuelName = fuelItem ? `${fuelItem.count} ${fuelItem.name}` : '0 fuel items' + + log(bot, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) + return true +} diff --git a/services/minecraft/src/skills/index.ts b/services/minecraft/src/skills/index.ts new file mode 100644 index 000000000..ed2a3589e --- /dev/null +++ b/services/minecraft/src/skills/index.ts @@ -0,0 +1,50 @@ +// Base utilities +export { log } from './base' +export type { BlockFace, Position } from './base' + +// Block interaction functions +export { + activateNearestBlock, + breakBlockAt, + placeBlock, + tillAndSow, + useDoor, +} from './blocks.js' + +// Combat related functions +export { + attackEntity, + attackNearest, + defendSelf, +} from './combat.js' + +// Crafting and smelting functions +export { + clearNearestFurnace, + craftRecipe, + smeltItem, +} from './crafting.js' + +// Inventory management functions +export { + consume, + discard, + equip, + giveToPlayer, + pickupNearbyItems, + putInChest, + takeFromChest, + viewChest, +} from './inventory.js' + +// Movement related functions +export { + followPlayer, + goToNearestBlock, + goToNearestEntity, + goToPlayer, + goToPosition, + moveAway, + moveAwayFromEntity, + stay, +} from './movement.js' diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts new file mode 100644 index 000000000..aef535afe --- /dev/null +++ b/services/minecraft/src/skills/inventory.ts @@ -0,0 +1,257 @@ +import type { Bot } from 'mineflayer' +import * as world from '../composables/world' +import { log } from './base' +import { goToPosition } from './movement' + +/** + * Pick up nearby items + */ +export async function pickupNearbyItems(bot: Bot): Promise { + const distance = 8 + const getNearestItem = (bot: Bot) => + bot.nearestEntity(entity => + entity.name === 'item' + && bot.entity.position.distanceTo(entity.position) < distance, + ) + + let nearestItem = getNearestItem(bot) + let pickedUp = 0 + + while (nearestItem) { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(nearestItem, 0.8), true) + await new Promise(resolve => setTimeout(resolve, 200)) + + const prev = nearestItem + nearestItem = getNearestItem(bot) + if (prev === nearestItem) { + break + } + pickedUp++ + } + + log(bot, `Picked up ${pickedUp} items.`) + return true +} + +/** + * Equip an item + */ +export async function equip(bot: Bot, itemName: string): Promise { + const item = bot.inventory.slots.find(slot => slot && slot.name === itemName) + if (!item) { + 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 +} + +/** + * Discard items + */ +export async function discard(bot: Bot, itemName: string, num = -1): Promise { + let discarded = 0 + + while (true) { + const item = bot.inventory.items().find(item => item.name === itemName) + if (!item) { + break + } + + const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) + await bot.toss(item.type, null, toDiscard) + discarded += toDiscard + + 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 +} + +/** + * Put items in a chest + */ +export async function putInChest(bot: Bot, itemName: string, num = -1): Promise { + const chest = world.getNearestBlock(bot, 'chest', 32) + if (!chest) { + log(bot, 'Could not find a chest nearby.') + return false + } + + const item = bot.inventory.items().find(item => item.name === itemName) + if (!item) { + log(bot, `You do not have any ${itemName} to put in the chest.`) + return false + } + + const toPut = 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, toPut) + await chestContainer.close() + + log(bot, `Successfully put ${toPut} ${itemName} in the chest.`) + return true +} + +/** + * Take items from a chest + */ +export async function takeFromChest(bot: Bot, itemName: string, num = -1): Promise { + const 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) + + const item = chestContainer.containerItems().find(item => item.name === itemName) + if (!item) { + log(bot, `Could not find any ${itemName} in the chest.`) + await chestContainer.close() + return false + } + + const toTake = num === -1 ? item.count : Math.min(num, item.count) + await chestContainer.withdraw(item.type, null, toTake) + await chestContainer.close() + + log(bot, `Successfully took ${toTake} ${itemName} from the chest.`) + return true +} + +/** + * View contents of a chest + */ +export async function viewChest(bot: Bot): Promise { + const 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) + const items = chestContainer.containerItems() + + if (items.length === 0) { + log(bot, 'The chest is empty.') + } + else { + log(bot, 'The chest contains:') + for (const item of items) { + log(bot, `${item.count} ${item.name}`) + } + } + + await chestContainer.close() + return true +} + +/** + * Consume (eat/drink) an item + */ +export async function consume(bot: Bot, itemName = ''): Promise { + let item + let name + + if (itemName) { + item = bot.inventory.items().find(item => item.name === 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 +} + +/** + * Give items to a player + */ +export async function giveToPlayer( + bot: Bot, + itemType: string, + username: string, + num = 1, +): Promise { + const player = bot.players[username]?.entity + if (!player) { + log(bot, `Could not find ${username}.`) + return false + } + + await goToPosition(bot, player.position.x, player.position.y, player.position.z, 3) + + if (bot.entity.position.y < player.position.y - 1) { + await goToPosition(bot, player.position.x, player.position.y, player.position.z, 1) + } + + if (bot.entity.position.distanceTo(player.position) < 2) { + const goal = bot.pathfinder.goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + await bot.pathfinder.goto(invertedGoal) + } + + await bot.lookAt(player.position) + + if (await discard(bot, itemType, num)) { + let given = false + bot.once('playerCollect', (collector, collected) => { + if (collector.username === username) { + log(bot, `${username} received ${itemType}.`) + given = true + } + }) + + const 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 +} diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts new file mode 100644 index 000000000..ac2778243 --- /dev/null +++ b/services/minecraft/src/skills/movement.ts @@ -0,0 +1,208 @@ +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' +import * as world from '../composables/world' +import { log } from './base' + +/** + * Navigate to a specific position + */ +export async function goToPosition( + bot: Bot, + x: number, + y: number, + z: number, + minDistance = 2, +): Promise { + 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 + } + + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(x, y, z, minDistance)) + log(bot, `You have reached ${x}, ${y}, ${z}.`) + return true +} + +/** + * Navigate to the nearest block of a specific type + */ +export async function goToNearestBlock( + bot: Bot, + blockType: string, + minDistance = 2, + range = 64, +): Promise { + const MAX_RANGE = 512 + if (range > MAX_RANGE) { + log(bot, `Maximum search range capped at ${MAX_RANGE}.`) + range = MAX_RANGE + } + + const 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}.`) + await goToPosition(bot, block.position.x, block.position.y, block.position.z, minDistance) + return true +} + +/** + * Navigate to the nearest entity of a specific type + */ +export async function goToNearestEntity( + bot: Bot, + entityType: string, + minDistance = 2, + range = 64, +): Promise { + const entity = world.getNearestEntityWhere( + bot, + entity => entity.name === entityType, + range, + ) + + if (!entity) { + log(bot, `Could not find any ${entityType} in ${range} blocks.`) + return false + } + + const 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, + minDistance, + ) + return true +} + +/** + * Navigate to a specific player + */ +export async function goToPlayer(bot: Bot, username: string, distance = 3): Promise { + 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') + + const player = bot.players[username]?.entity + if (!player) { + log(bot, `Could not find ${username}.`) + return false + } + + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(player, distance), true) + log(bot, `You have reached ${username}.`) + return true +} + +/** + * Follow a player continuously + */ +export async function followPlayer(bot: Bot, username: string, distance = 4): Promise { + const player = bot.players[username]?.entity + if (!player) + return false + + bot.pathfinder.setGoal(bot.pathfinder.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)) + + if (bot.modes.isOn('cheat') + && bot.entity.position.distanceTo(player.position) > 100 + && player.isOnGround) { + await goToPlayer(bot, username) + } + + if (bot.modes.isOn('unstuck')) { + const isNearby = bot.entity.position.distanceTo(player.position) <= distance + 1 + if (isNearby) { + bot.modes.pause('unstuck') + } + else { + bot.modes.unpause('unstuck') + } + } + } + return true +} + +/** + * Move away from current position + */ +export async function moveAway(bot: Bot, distance: number): Promise { + const pos = bot.entity.position + const goal = bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, distance) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + + if (bot.modes.isOn('cheat')) { + const move = new bot.pathfinder.Movements(bot) + const path = await bot.pathfinder.getPathTo(move, invertedGoal, 10000) + const lastMove = path.path[path.path.length - 1] + + if (lastMove) { + const x = Math.floor(lastMove.x) + const y = Math.floor(lastMove.y) + const z = Math.floor(lastMove.z) + bot.chat(`/tp @s ${x} ${y} ${z}`) + return true + } + } + + await bot.pathfinder.goto(invertedGoal) + const newPos = bot.entity.position + log(bot, `Moved away from nearest entity to ${newPos}.`) + return true +} + +/** + * Move away from a specific entity + */ +export async function moveAwayFromEntity( + bot: Bot, + entity: Entity, + distance = 16, +): Promise { + const goal = bot.pathfinder.goals.GoalFollow(entity, distance) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + await bot.pathfinder.goto(invertedGoal) + return true +} + +/** + * Stay in current position + */ +export async function stay(bot: Bot, seconds = 30): Promise { + 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') + + const 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 +}