diff --git a/services/minecraft/.env b/services/minecraft/.env new file mode 100644 index 00000000..0380bee0 --- /dev/null +++ b/services/minecraft/.env @@ -0,0 +1,10 @@ +OPENAI_API_BASEURL='' +OPENAI_API_KEY='' +OPENAI_MODEL='deepseek-chat' +OPENAI_REASONING_MODEL='deepseek-reasoner' + +BOT_USERNAME='' +BOT_HOSTNAME='' +BOT_PORT='' +BOT_PASSWORD='' +BOT_VERSION='' diff --git a/services/minecraft/README.md b/services/minecraft/README.md new file mode 100644 index 00000000..ae59c10d --- /dev/null +++ b/services/minecraft/README.md @@ -0,0 +1,114 @@ +# ⛏️ Minecraft agent player for [アイリ (Airi)](https://airi.moeru.ai) + +> [!NOTE] +> +> This project is part of the [Project アイリ (Airi)](https://github.com/moeru-ai/airi), we aim to build a LLM-driven VTuber like [Neuro-sama](https://www.youtube.com/@Neurosama) (subscribe if you didn't!) if you are interested in, please do give it a try on [live demo](https://airi.moeru.ai). + +An intelligent Minecraft bot powered by LLM. AIRI can understand natural language commands, interact with the world, and assist players in various tasks. + +## 🎥 Preview + +![demo](./docs/preview.png) + +## ✨ Features + +- 🗣️ Natural language understanding +- 🏃‍♂️ Advanced pathfinding and navigation +- 🛠️ Block breaking and placing +- 🎯 Combat and PvP capabilities +- 🔄 Auto-reconnect on disconnection +- 📦 Inventory management +- 🤝 Player following and interaction +- 🌍 World exploration and mapping + +## 🚀 Getting Started + +### 📋 Prerequisites + +- 📦 Node.js 22+ +- 🔧 pnpm +- 🎮 A Minecraft server (1.20+) + +### 🔨 Installation + +1. Clone the repository: + +```bash +git clone https://github.com/moeru-ai/airi-minecraft.git +cd airi-mc +``` + +2. Install dependencies: + +```bash +pnpm install +``` + +3. Create a `.env.local` file with your configuration: + +```env +OPENAI_API_KEY=your_openai_api_key +OPENAI_API_BASEURL=your_openai_api_baseurl + +BOT_USERNAME=your_bot_username +BOT_HOSTNAME=localhost +BOT_PORT=25565 +BOT_PASSWORD=optional_password +BOT_VERSION=1.20 +``` + +4. Start the bot: + +```bash +pnpm dev +``` + +## 🎮 Usage + +Once the bot is connected, you can interact with it using chat commands in Minecraft. All commands start with `#`. + +### Basic Commands + +- `#help` - Show available commands +- `#follow` - Make the bot follow you +- `#stop` - Stop the current action +- `#come` - Make the bot come to your location + +### Natural Language Commands + +You can also give the bot natural language commands, and it will try to understand and execute them. For example: + +- "Build a house" +- "Find some diamonds" +- "Help me fight these zombies" +- "Collect wood from nearby trees" + +## 🛠️ Development + +### Project Structure + +``` +src/ +├── agents/ # AI agent implementations +├── composables/# Reusable composable functions +├── libs/ # Core library code +├── mineflayer/ # Mineflayer plugin implementations +├── prompts/ # AI prompt templates +├── skills/ # Bot skills and actions +└── utils/ # Utility functions +``` + +### Commands + +- `pnpm dev` - Start the bot in development mode +- `pnpm lint` - Run ESLint +- `pnpm typecheck` - Run TypeScript type checking +- `pnpm test` - Run tests + +## 🙏 Acknowledgements + +- https://github.com/kolbytn/mindcraft + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/services/minecraft/docs/preview.png b/services/minecraft/docs/preview.png new file mode 100644 index 00000000..f122c55e Binary files /dev/null and b/services/minecraft/docs/preview.png differ diff --git a/services/minecraft/package.json b/services/minecraft/package.json new file mode 100644 index 00000000..823097f9 --- /dev/null +++ b/services/minecraft/package.json @@ -0,0 +1,60 @@ +{ + "name": "@proj-airi/minecraft-bot", + "type": "module", + "version": "1.0.0", + "packageManager": "pnpm@9.15.5", + "description": "An intelligent Minecraft bot powered by LLM. AIRI can understand natural language commands, interact with the world, and assist players in various tasks.", + "main": "src/main.ts", + "scripts": { + "dev": "dotenvx run -f .env -f .env.local --overload --debug --ignore=MISSING_ENV_FILE -- tsx src/main.ts", + "start": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE -- tsx src/main.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit", + "test": "vitest", + "postinstall": "npx simple-git-hooks" + }, + "dependencies": { + "@dotenvx/dotenvx": "^1.34.0", + "@guiiai/logg": "^1.0.7", + "@proj-airi/server-sdk": "^0.1.4", + "@typeschema/zod": "^0.14.0", + "awilix": "^12.0.4", + "dotenv": "^16.4.7", + "es-toolkit": "^1.32.0", + "eventemitter3": "^5.0.1", + "minecraft-data": "^3.83.1", + "mineflayer": "^4.26.0", + "mineflayer-armor-manager": "^2.0.1", + "mineflayer-auto-eat": "^5.0.0", + "mineflayer-collectblock": "^1.6.0", + "mineflayer-pathfinder": "^2.4.5", + "mineflayer-pvp": "^1.3.2", + "mineflayer-tool": "^1.2.0", + "neuri": "^0.0.21", + "prismarine-block": "^1.21.0", + "prismarine-entity": "^2.5.0", + "prismarine-item": "^1.16.0", + "prismarine-recipe": "^1.3.1", + "prismarine-viewer": "^1.30.0", + "prismarine-windows": "^2.9.0", + "vec3": "^0.1.10", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.1" + }, + "devDependencies": { + "@antfu/eslint-config": "^4.1.1", + "eslint": "^9.19.0", + "lint-staged": "^15.4.3", + "simple-git-hooks": "^2.11.1", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.4" + }, + "simple-git-hooks": { + "pre-commit": "pnpm lint-staged" + }, + "lint-staged": { + "*": "eslint --fix" + } +} diff --git a/services/minecraft/src/agents/action/adapter.test.ts b/services/minecraft/src/agents/action/adapter.test.ts new file mode 100644 index 00000000..828a0c54 --- /dev/null +++ b/services/minecraft/src/agents/action/adapter.test.ts @@ -0,0 +1,40 @@ +import { messages, system, user } from 'neuri/openai' +import { beforeAll, describe, expect, it } from 'vitest' + +import { initBot, useBot } from '../../composables/bot' +import { config, initEnv } from '../../composables/config' +import { createNeuriAgent } from '../../composables/neuri' +import { generateSystemBasicPrompt } from '../../libs/llm-agent/prompt' +import { initLogger } from '../../utils/logger' + +describe('openAI agent', { timeout: 0 }, () => { + beforeAll(() => { + initLogger() + initEnv() + initBot({ botConfig: config.bot }) + }) + + it('should initialize the agent', async () => { + const { bot } = useBot() + const agent = await createNeuriAgent(bot) + + await new Promise((resolve) => { + bot.bot.once('spawn', async () => { + const text = await agent.handle( + messages( + system(generateSystemBasicPrompt('airi')), + user('Hello, who are you?'), + ), + async (c) => { + const completion = await c.reroute('query', c.messages, { model: config.openai.model }) + return await completion?.firstContent() + }, + ) + + expect(text?.toLowerCase()).toContain('airi') + + resolve() + }) + }) + }) +}) diff --git a/services/minecraft/src/agents/action/adapter.ts b/services/minecraft/src/agents/action/adapter.ts new file mode 100644 index 00000000..5156d6b6 --- /dev/null +++ b/services/minecraft/src/agents/action/adapter.ts @@ -0,0 +1,84 @@ +import type { Agent } from 'neuri' +import type { Message } from 'neuri/openai' +import type { Mineflayer } from '../../libs/mineflayer' +import type { PlanStep } from '../planning/adapter' + +import { agent } from 'neuri' +import { system, user } from 'neuri/openai' + +import { BaseLLMHandler } from '../../libs/llm-agent/handler' +import { useLogger } from '../../utils/logger' +import { actionsList } from './tools' + +export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { + const logger = useLogger() + logger.log('Initializing action agent') + let actionAgent = agent('action') + + Object.values(actionsList).forEach((action) => { + actionAgent = actionAgent.tool( + action.name, + action.schema, + async ({ parameters }) => { + logger.withFields({ name: action.name, parameters }).log('Calling action') + mineflayer.memory.actions.push(action) + const fn = action.perform(mineflayer) + return await fn(...Object.values(parameters)) + }, + { description: action.description }, + ) + }) + + return actionAgent.build() +} + +export class ActionLLMHandler extends BaseLLMHandler { + public async executeStep(step: PlanStep): Promise { + const systemPrompt = this.generateActionSystemPrompt() + const userPrompt = this.generateActionUserPrompt(step) + const messages = [system(systemPrompt), user(userPrompt)] + + const result = await this.handleAction(messages) + return result + } + + private generateActionSystemPrompt(): string { + return `You are a Minecraft bot action executor. Your task is to execute a given step using available tools. +You have access to various tools that can help you accomplish tasks. +When using a tool: +1. Choose the most appropriate tool for the task +2. Determine the correct parameters based on the context +3. Handle any errors or unexpected situations + +Remember to: +- Be precise with tool parameters +- Consider the current state of the bot +- Handle failures gracefully` + } + + private generateActionUserPrompt(step: PlanStep): string { + return `Execute this step: ${step.description} + +Suggested tool: ${step.tool} +Params: ${JSON.stringify(step.params)} + +Please use the appropriate tool with the correct parameters to accomplish this step. +If the suggested tool is not appropriate, you may choose a different one.` + } + + public async handleAction(messages: Message[]): Promise { + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Processing action...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'action', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to process action') + } + + return result + } +} diff --git a/services/minecraft/src/agents/action/index.ts b/services/minecraft/src/agents/action/index.ts new file mode 100644 index 00000000..6565a466 --- /dev/null +++ b/services/minecraft/src/agents/action/index.ts @@ -0,0 +1,118 @@ +import type { Mineflayer } from '../../libs/mineflayer' +import type { Action } from '../../libs/mineflayer/action' +import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent' +import type { PlanStep } from '../planning/adapter' + +import { useBot } from '../../composables/bot' +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { actionsList } from './tools' + +interface ActionState { + executing: boolean + label: string + startTime: number +} + +/** + * ActionAgentImpl implements the ActionAgent interface to handle action execution + * Manages action lifecycle, state tracking and error handling + */ +export class ActionAgentImpl extends AbstractAgent implements ActionAgent { + public readonly type = 'action' as const + private actions: Map + private mineflayer: Mineflayer + private currentActionState: ActionState + + constructor(config: AgentConfig) { + super(config) + this.actions = new Map() + this.mineflayer = useBot().bot + this.currentActionState = { + executing: false, + label: '', + startTime: 0, + } + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing action agent') + actionsList.forEach(action => this.actions.set(action.name, action)) + + // Set up event listeners + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + } + + protected async destroyAgent(): Promise { + this.actions.clear() + this.removeAllListeners() + } + + public async performAction(step: PlanStep): Promise { + if (!this.initialized) { + throw new Error('Action agent not initialized') + } + + const action = this.actions.get(step.tool) + if (!action) { + throw new Error(`Unknown action: ${step.tool}`) + } + + this.logger.withFields({ + action: step.tool, + description: step.description, + params: step.params, + }).log('Performing action') + + // Update action state + this.updateActionState(true, step.description) + + try { + // Execute action with provided parameters + const result = await action.perform(this.mineflayer)(...Object.values(step.params)) + return this.formatActionOutput({ + message: result, + timedout: false, + interrupted: false, + }) + } + catch (error) { + this.logger.withError(error).error('Action failed') + throw error + } + finally { + this.updateActionState(false) + } + } + + public getAvailableActions(): Action[] { + return Array.from(this.actions.values()) + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system' && message.includes('interrupt') && this.currentActionState.executing) { + // Handle interruption + this.logger.log('Received interrupt request') + // Additional interrupt handling logic here + } + } + + private updateActionState(executing: boolean, label = ''): void { + this.currentActionState = { + executing, + label, + startTime: executing ? Date.now() : this.currentActionState.startTime, + } + } + + private formatActionOutput(result: { message: string | null, timedout: boolean, interrupted: boolean }): string { + if (result.timedout) { + return 'Action timed out' + } + if (result.interrupted) { + return 'Action was interrupted' + } + return result.message || 'Action completed successfully' + } +} diff --git a/services/minecraft/src/agents/action/tools.test.ts b/services/minecraft/src/agents/action/tools.test.ts new file mode 100644 index 00000000..14af19f8 --- /dev/null +++ b/services/minecraft/src/agents/action/tools.test.ts @@ -0,0 +1,61 @@ +import { messages, system, user } from 'neuri/openai' +import { beforeAll, describe, expect, it } from 'vitest' + +import { initBot, useBot } from '../../composables/bot' +import { config, initEnv } from '../../composables/config' +import { createNeuriAgent } from '../../composables/neuri' +import { generateActionAgentPrompt } from '../../libs/llm-agent/prompt' +import { sleep } from '../../utils/helper' +import { initLogger } from '../../utils/logger' + +describe('actions agent', { timeout: 0 }, () => { + beforeAll(() => { + initLogger() + initEnv() + initBot({ botConfig: config.bot }) + }) + + it('should choose right query command', async () => { + const { bot } = useBot() + const agent = await createNeuriAgent(bot) + + await new Promise((resolve) => { + bot.bot.once('spawn', async () => { + const text = await agent.handle(messages( + system(generateActionAgentPrompt(bot)), + user('What\'s your status?'), + ), async (c) => { + const completion = await c.reroute('query', c.messages, { model: config.openai.model }) + return await completion?.firstContent() + }) + + expect(text?.toLowerCase()).toContain('position') + + resolve() + }) + }) + }) + + it('should choose right action command', async () => { + const { bot } = useBot() + const agent = await createNeuriAgent(bot) + + await new Promise((resolve) => { + bot.bot.on('spawn', async () => { + const text = await agent.handle(messages( + system(generateActionAgentPrompt(bot)), + user('goToPlayer: luoling8192'), + ), async (c) => { + const completion = await c.reroute('action', c.messages, { model: config.openai.model }) + + return await completion?.firstContent() + }) + + expect(text).toContain('goToPlayer') + + await sleep(10000) + resolve() + }) + }) + }) +}) diff --git a/services/minecraft/src/agents/action/tools.ts b/services/minecraft/src/agents/action/tools.ts new file mode 100644 index 00000000..b9960c11 --- /dev/null +++ b/services/minecraft/src/agents/action/tools.ts @@ -0,0 +1,540 @@ +import type { Action } from '../../libs/mineflayer' + +import { z } from 'zod' + +import * as skills from '../../skills' +import { collectBlock } from '../../skills/actions/collect-block' +import { discard, equip, putInChest, takeFromChest, viewChest } from '../../skills/actions/inventory' +import { activateNearestBlock, placeBlock } from '../../skills/actions/world-interactions' +import * as world from '../../skills/world' +import { useLogger } from '../../utils/logger' + +// Utils +const pad = (str: string): string => `\n${str}\n` + +function formatInventoryItem(item: string, count: number): string { + return count > 0 ? `\n- ${item}: ${count}` : '' +} + +function formatWearingItem(slot: string, item: string | undefined): string { + return item ? `\n${slot}: ${item}` : '' +} + +export const actionsList: Action[] = [ + { + name: 'stats', + description: 'Get your bot\'s location, health, hunger, and time of day.', + schema: z.object({}), + perform: mineflayer => (): string => { + const status = mineflayer.status.toOneLiner() + return status + }, + }, + { + name: 'inventory', + description: 'Get your bot\'s inventory.', + schema: z.object({}), + perform: mineflayer => (): string => { + const inventory = world.getInventoryCounts(mineflayer) + const items = Object.entries(inventory) + .map(([item, count]) => formatInventoryItem(item, count)) + .join('') + + const wearing = [ + formatWearingItem('Head', mineflayer.bot.inventory.slots[5]?.name), + formatWearingItem('Torso', mineflayer.bot.inventory.slots[6]?.name), + formatWearingItem('Legs', mineflayer.bot.inventory.slots[7]?.name), + formatWearingItem('Feet', mineflayer.bot.inventory.slots[8]?.name), + ].filter(Boolean).join('') + + return pad(`INVENTORY${items || ': Nothing'} + ${mineflayer.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} + WEARING: ${wearing || 'Nothing'}`) + }, + }, + { + name: 'nearbyBlocks', + description: 'Get the blocks near the bot.', + schema: z.object({}), + perform: mineflayer => (): string => { + const blocks = world.getNearbyBlockTypes(mineflayer) + useLogger().withFields({ blocks }).log('nearbyBlocks') + return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) + }, + }, + { + name: 'craftable', + description: 'Get the craftable items with the bot\'s inventory.', + schema: z.object({}), + perform: mineflayer => (): string => { + const craftable = world.getCraftableItems(mineflayer) + return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) + }, + }, + { + name: 'entities', + description: 'Get the nearby players and entities.', + schema: z.object({}), + perform: mineflayer => (): string => { + const players = world.getNearbyPlayerNames(mineflayer) + const entities = world.getNearbyEntityTypes(mineflayer) + .filter((e: string) => e !== 'player' && e !== 'item') + + const result = [ + ...players.map((p: string) => `- Human player: ${p}`), + ...entities.map((e: string) => `- entities: ${e}`), + ] + + return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) + }, + }, + // 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: (mineflayer: BotContext) => async (prompt: string) => { + // if (!settings.allow_insecure_coding) + // return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' + // return await ctx.coder.generateCode(mineflayer.history) + // }, + // } + // }, + + // todo: must 'stop now' can be used to stop the agent + { + name: 'stop', + description: 'Force stop all actions and commands that are currently executing.', + schema: z.object({}), + perform: mineflayer => async () => { + // await ctx.actions.stop() + // ctx.clearBotLogs() + // ctx.actions.cancelResume() + // ctx.bot.emit('idle') + + mineflayer.emit('interrupt') + + const msg = 'Agent stopped.' + // if (mineflayer.self_prompter.on) + // msg += ' Self-prompting still active.' + return msg + }, + }, + + // getStfuAction(): Action { + // return { + // name: 'stfu', + // description: 'Stop all chatting and self prompting, but continue current action.', + // schema: z.object({}), + // perform: (mineflayer: BotContext) => async () => { + // ctx.openChat('Shutting up.') + // ctx.shutUp() + // return 'Shutting up.' + // }, + // } + // }, + + // getRestartAction(): Action { + // return { + // name: 'restart', + // description: 'Restart the agent process.', + // schema: z.object({}), + // perform: (mineflayer: BotContext) => async () => { + // ctx.cleanKill() + // return 'Restarting agent...' + // }, + // } + // }, + + // getClearChatAction(): Action { + // return { + // name: 'clearChat', + // description: 'Clear the chat history.', + // schema: z.object({}), + // perform: (mineflayer: BotContext) => async () => { + // ctx.history.clear() + // return `${ctx.name}'s chat history was cleared, starting new conversation from scratch.` + // }, + // } + // }, + { + 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: mineflayer => async (player_name: string, closeness: number) => { + await skills.goToPlayer(mineflayer, player_name, closeness) + return 'Moving to player...' + }, + }, + + { + 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: mineflayer => async (player_name: string, follow_dist: number) => { + await skills.followPlayer(mineflayer, player_name, follow_dist) + return 'Following player...' + }, + }, + + { + 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: mineflayer => async (x: number, y: number, z: number, closeness: number) => { + await skills.goToPosition(mineflayer, x, y, z, closeness) + return 'Moving to coordinates...' + }, + }, + + { + 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: mineflayer => async (block_type: string, range: number) => { + await skills.goToNearestBlock(mineflayer, block_type, 4, range) + return 'Searching for block...' + }, + }, + + { + 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: mineflayer => async (entity_type: string, range: number) => { + await skills.goToNearestEntity(mineflayer, entity_type, 4, range) + return 'Searching for entity...' + }, + }, + + { + 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: mineflayer => async (distance: number) => { + await skills.moveAway(mineflayer, distance) + return 'Moving away...' + }, + }, + + { + 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: mineflayer => async (player_name: string, item_name: string, num: number) => { + await skills.giveToPlayer(mineflayer, item_name, player_name, num) + return 'Giving items to player...' + }, + }, + + { + name: 'consume', + description: 'Eat/drink the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to consume.'), + }), + perform: mineflayer => async (item_name: string) => { + await skills.consume(mineflayer, item_name) + return 'Consuming item...' + }, + }, + + { + name: 'equip', + description: 'Equip the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to equip.'), + }), + perform: mineflayer => async (item_name: string) => { + await equip(mineflayer, item_name) + return 'Equipping item...' + }, + }, + + { + 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: mineflayer => async (item_name: string, num: number) => { + await putInChest(mineflayer, item_name, num) + return 'Putting items in chest...' + }, + }, + + { + 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: mineflayer => async (item_name: string, num: number) => { + await takeFromChest(mineflayer, item_name, num) + return 'Taking items from chest...' + }, + }, + + { + name: 'viewChest', + description: 'View the items/counts of the nearest chest.', + schema: z.object({}), + perform: mineflayer => async () => { + await viewChest(mineflayer) + return 'Viewing chest contents...' + }, + }, + + { + 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: mineflayer => async (item_name: string, num: number) => { + await discard(mineflayer, item_name, num) + return 'Discarding items...' + }, + }, + + { + 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: mineflayer => async (type: string, num: number) => { + await collectBlock(mineflayer, type, num) + return 'Collecting blocks...' + }, + }, + + { + 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: mineflayer => async (recipe_name: string, num: number) => { + await skills.craftRecipe(mineflayer, recipe_name, num) + return 'Crafting items...' + }, + }, + + { + 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: mineflayer => async (item_name: string, num: number) => { + await skills.smeltItem(mineflayer, item_name, num) + return 'Smelting items...' + }, + }, + + { + name: 'clearFurnace', + description: 'Take all items out of the nearest furnace.', + schema: z.object({}), + perform: mineflayer => async () => { + await skills.clearNearestFurnace(mineflayer) + return 'Clearing furnace...' + }, + }, + + { + 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: mineflayer => async (type: string) => { + const pos = mineflayer.bot.entity.position + await placeBlock(mineflayer, type, pos.x, pos.y, pos.z) + return 'Placing block...' + }, + }, + + { + 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: mineflayer => async (type: string) => { + await skills.attackNearest(mineflayer, type, true) + return 'Attacking entity...' + }, + }, + + { + 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: mineflayer => async (player_name: string) => { + const player = mineflayer.bot.players[player_name]?.entity + if (!player) { + skills.log(mineflayer, `Could not find player ${player_name}.`) + return 'Player not found' + } + await skills.attackEntity(mineflayer, player, true) + return 'Attacking player...' + }, + }, + + { + name: 'goToBed', + description: 'Go to the nearest bed and sleep.', + schema: z.object({}), + perform: mineflayer => async () => { + await skills.goToBed(mineflayer) + return 'Going to bed...' + }, + }, + + { + 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: mineflayer => async (type: string) => { + await activateNearestBlock(mineflayer, type) + return 'Activating block...' + }, + }, + + { + 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: mineflayer => async (seconds: number) => { + await skills.stay(mineflayer, seconds) + return 'Staying in place...' + }, + }, + // 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: (mineflayer: BotContext) => async (mode_name: string, on: boolean) => { + // const modes = ctx.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'}.` + // }, + // } + // }, + + // 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: (mineflayer: BotContext) => async (prompt: string) => { + // if (convoManager.inConversation()) { + // ctx.self_prompter.setPrompt(prompt) + // convoManager.scheduleSelfPrompter() + // } + // else { + // ctx.self_prompter.start(prompt) + // } + // return 'Goal set...' + // }, + // } + // }, + + // 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: (mineflayer: BotContext) => async () => { + // ctx.self_prompter.stop() + // convoManager.cancelSelfPrompter() + // return 'Self-prompting stopped.' + // }, + // } + // }, + + // 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: (mineflayer: 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)) + // ctx.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) + // }, + // } + // }, + + // 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: (mineflayer: 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.` + // }, + // } + // }, +] diff --git a/services/minecraft/src/agents/chat/adapter.ts b/services/minecraft/src/agents/chat/adapter.ts new file mode 100644 index 00000000..bc03b45a --- /dev/null +++ b/services/minecraft/src/agents/chat/adapter.ts @@ -0,0 +1,67 @@ +import type { ChatHistory } from './types' + +import { system, user } from 'neuri/openai' + +import { BaseLLMHandler } from '../../libs/llm-agent/handler' + +export function generateChatAgentPrompt(): string { + return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals. + +Guidelines: +1. Be friendly and helpful +2. Keep responses concise but informative +3. Use game-appropriate language +4. Acknowledge player's emotions and intentions +5. Ask for clarification when needed +6. Remember context from previous messages +7. Be proactive in suggesting helpful actions + +You can: +- Answer questions about the game +- Help with tasks and crafting +- Give directions and suggestions +- Engage in casual conversation +- Coordinate with other bots + +Remember that you're operating in a Minecraft world and should maintain that context in your responses.` +} + +export class ChatLLMHandler extends BaseLLMHandler { + public async generateResponse( + message: string, + history: ChatHistory[], + ): Promise { + const systemPrompt = generateChatAgentPrompt() + const chatHistory = this.formatChatHistory(history, this.config.maxContextLength ?? 10) + const messages = [ + system(systemPrompt), + ...chatHistory, + user(message), + ] + + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Generating response...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'chat', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to generate response') + } + + return result + } + + private formatChatHistory( + history: ChatHistory[], + maxLength: number, + ): Array<{ role: 'user' | 'assistant', content: string }> { + const recentHistory = history.slice(-maxLength) + return recentHistory.map(entry => ({ + role: entry.sender === 'bot' ? 'assistant' : 'user', + content: entry.message, + })) + } +} diff --git a/services/minecraft/src/agents/chat/index.ts b/services/minecraft/src/agents/chat/index.ts new file mode 100644 index 00000000..5def0e02 --- /dev/null +++ b/services/minecraft/src/agents/chat/index.ts @@ -0,0 +1,168 @@ +import type { ChatAgent } from '../../libs/mineflayer/base-agent' +import type { ChatAgentConfig, ChatContext } from './types' + +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { generateChatResponse } from './llm' + +export class ChatAgentImpl extends AbstractAgent implements ChatAgent { + public readonly type = 'chat' as const + private activeChats: Map + private maxHistoryLength: number + private idleTimeout: number + private llmConfig: ChatAgentConfig['llm'] + + constructor(config: ChatAgentConfig) { + super(config) + this.activeChats = new Map() + this.maxHistoryLength = config.maxHistoryLength ?? 50 + this.idleTimeout = config.idleTimeout ?? 5 * 60 * 1000 // 5 minutes + this.llmConfig = config.llm + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing chat agent') + + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + + setInterval(() => { + this.checkIdleChats() + }, 60 * 1000) + } + + protected async destroyAgent(): Promise { + this.activeChats.clear() + this.removeAllListeners() + } + + public async processMessage(message: string, sender: string): Promise { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withFields({ sender, message }).log('Processing message') + + try { + // Get or create chat context + const context = this.getOrCreateContext(sender) + + // Add message to history + this.addToHistory(context, sender, message) + + // Update last activity time + context.lastUpdate = Date.now() + + // Generate response using LLM + const response = await this.generateResponse(message, context) + + // Add response to history + this.addToHistory(context, this.id, response) + + return response + } + catch (error) { + this.logger.withError(error).error('Failed to process message') + throw error + } + } + + public startConversation(player: string): void { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withField('player', player).log('Starting conversation') + + const context = this.getOrCreateContext(player) + context.startTime = Date.now() + context.lastUpdate = Date.now() + } + + public endConversation(player: string): void { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withField('player', player).log('Ending conversation') + + if (this.activeChats.has(player)) { + const context = this.activeChats.get(player)! + // Archive chat history if needed + this.archiveChat(context) + this.activeChats.delete(player) + } + } + + private getOrCreateContext(player: string): ChatContext { + let context = this.activeChats.get(player) + if (!context) { + context = { + player, + startTime: Date.now(), + lastUpdate: Date.now(), + history: [], + } + this.activeChats.set(player, context) + } + return context + } + + private addToHistory(context: ChatContext, sender: string, message: string): void { + context.history.push({ + sender, + message, + timestamp: Date.now(), + }) + + // Trim history if too long + if (context.history.length > this.maxHistoryLength) { + context.history = context.history.slice(-this.maxHistoryLength) + } + } + + private async generateResponse(message: string, context: ChatContext): Promise { + return await generateChatResponse(message, context.history, { + agent: this.llmConfig.agent, + model: this.llmConfig.model, + maxContextLength: this.maxHistoryLength, + }) + } + + private checkIdleChats(): void { + const now = Date.now() + for (const [player, context] of this.activeChats.entries()) { + if (now - context.lastUpdate > this.idleTimeout) { + this.logger.withField('player', player).log('Ending idle conversation') + this.endConversation(player) + } + } + } + + private async archiveChat(context: ChatContext): Promise { + // Archive chat history to persistent storage if needed + this.logger.withFields({ + player: context.player, + messageCount: context.history.length, + duration: Date.now() - context.startTime, + }).log('Archiving chat history') + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system') { + if (message.includes('interrupt')) { + // Handle system interrupt + for (const player of this.activeChats.keys()) { + this.endConversation(player) + } + } + } + else { + // Handle messages from other agents + const context = this.activeChats.get(sender) + if (context) { + await this.processMessage(message, sender) + } + } + } +} diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts new file mode 100644 index 00000000..924fd089 --- /dev/null +++ b/services/minecraft/src/agents/chat/llm.ts @@ -0,0 +1,85 @@ +import type { Agent, Neuri } from 'neuri' +import type { ChatHistory } from './types' + +import { agent } from 'neuri' +import { system, user } from 'neuri/openai' + +import { config as appConfig } from '../../composables/config' +import { toRetriable } from '../../utils/helper' +import { useLogger } from '../../utils/logger' +import { generateChatAgentPrompt } from './adapter' + +interface LLMChatConfig { + agent: Neuri + model?: string + retryLimit?: number + delayInterval?: number + maxContextLength?: number +} + +export async function createChatNeuriAgent(): Promise { + return agent('chat').build() +} + +export async function generateChatResponse( + message: string, + history: ChatHistory[], + config: LLMChatConfig, +): Promise { + const systemPrompt = generateChatAgentPrompt() + const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10) + const userPrompt = message + const logger = useLogger() + + const messages = [ + system(systemPrompt), + ...chatHistory, + user(userPrompt), + ] + + const content = await config.agent.handleStateless(messages, async (c) => { + logger.log('Generating response...') + + const handleCompletion = async (c: any): Promise => { + const completion = await c.reroute('chat', c.messages, { + model: config.model ?? appConfig.openai.model, + }) + + if (!completion || 'error' in completion) { + logger.withFields(c).error('Completion failed') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion.firstContent() + logger.withFields({ usage: completion.usage, content }).log('Response generated') + return content + } + + const retriableHandler = toRetriable( + config.retryLimit ?? 3, + config.delayInterval ?? 1000, + handleCompletion, + ) + + return await retriableHandler(c) + }) + + if (!content) { + throw new Error('Failed to generate response') + } + + return content +} + +function formatChatHistory( + history: ChatHistory[], + maxLength: number, +): Array<{ role: 'user' | 'assistant', content: string }> { + // Take the most recent messages up to maxLength + const recentHistory = history.slice(-maxLength) + + return recentHistory.map(entry => ({ + role: entry.sender === 'bot' ? 'assistant' : 'user', + content: entry.message, + })) +} diff --git a/services/minecraft/src/agents/chat/types.ts b/services/minecraft/src/agents/chat/types.ts new file mode 100644 index 00000000..32410bed --- /dev/null +++ b/services/minecraft/src/agents/chat/types.ts @@ -0,0 +1,25 @@ +import type { Neuri } from 'neuri' + +export interface ChatHistory { + sender: string + message: string + timestamp: number +} + +export interface ChatContext { + player: string + startTime: number + lastUpdate: number + history: ChatHistory[] +} + +export interface ChatAgentConfig { + id: string + type: 'chat' + llm: { + agent: Neuri + model?: string + } + maxHistoryLength?: number + idleTimeout?: number +} diff --git a/services/minecraft/src/agents/memory/index.ts b/services/minecraft/src/agents/memory/index.ts new file mode 100644 index 00000000..c284ea2a --- /dev/null +++ b/services/minecraft/src/agents/memory/index.ts @@ -0,0 +1,107 @@ +import type { Message } from 'neuri/openai' +import type { Action } from '../../libs/mineflayer' +import type { AgentConfig, MemoryAgent } from '../../libs/mineflayer/base-agent' + +import { Memory } from '../../libs/mineflayer/memory' +import { type Logger, useLogger } from '../../utils/logger' + +export class MemoryAgentImpl implements MemoryAgent { + public readonly type = 'memory' as const + public readonly id: string + private memory: Map + private initialized: boolean + private memoryInstance: Memory + private logger: Logger + + constructor(config: AgentConfig) { + this.id = config.id + this.memory = new Map() + this.initialized = false + this.memoryInstance = new Memory() + this.logger = useLogger() + } + + async init(): Promise { + if (this.initialized) { + return + } + + this.logger.log('Initializing memory agent') + this.initialized = true + } + + async destroy(): Promise { + this.memory.clear() + this.initialized = false + } + + remember(key: string, value: unknown): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.logger.withFields({ key, value }).log('Storing memory') + this.memory.set(key, value) + } + + recall(key: string): T | undefined { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + const value = this.memory.get(key) as T | undefined + this.logger.withFields({ key, value }).log('Recalling memory') + return value + } + + forget(key: string): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.logger.withFields({ key }).log('Forgetting memory') + this.memory.delete(key) + } + + getMemorySnapshot(): Record { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return Object.fromEntries(this.memory.entries()) + } + + addChatMessage(message: Message): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.memoryInstance.chatHistory.push(message) + this.logger.withFields({ message }).log('Adding chat message to memory') + } + + addAction(action: Action): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.memoryInstance.actions.push(action) + this.logger.withFields({ action }).log('Adding action to memory') + } + + getChatHistory(): Message[] { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return this.memoryInstance.chatHistory + } + + getActions(): Action[] { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return this.memoryInstance.actions + } +} diff --git a/services/minecraft/src/agents/planning/adapter.ts b/services/minecraft/src/agents/planning/adapter.ts new file mode 100644 index 00000000..fd964a64 --- /dev/null +++ b/services/minecraft/src/agents/planning/adapter.ts @@ -0,0 +1,146 @@ +import type { Agent } from 'neuri' +import type { Action } from '../../libs/mineflayer/action' + +import { agent } from 'neuri' +import { system, user } from 'neuri/openai' + +import { BaseLLMHandler } from '../../libs/llm-agent/handler' + +export async function createPlanningNeuriAgent(): Promise { + return agent('planning').build() +} + +export interface PlanStep { + description: string + tool: string + params: Record +} + +export class PlanningLLMHandler extends BaseLLMHandler { + public async generatePlan( + goal: string, + availableActions: Action[], + sender: string, + feedback?: string, + ): Promise { + const systemPrompt = this.generatePlanningAgentSystemPrompt(availableActions) + const userPrompt = this.generatePlanningAgentUserPrompt(goal, sender, feedback) + const messages = [system(systemPrompt), user(userPrompt)] + + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Generating plan...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'planning', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to generate plan') + } + + return this.parsePlanContent(result) + } + + private parsePlanContent(content: string): PlanStep[] { + // Split content into steps (numbered list) + const steps = content.split(/\d+\./).filter(step => step.trim().length > 0) + + return steps.map((step) => { + const lines = step.trim().split('\n') + const description = lines[0].trim() + + // Extract tool name and parameters + let tool = '' + const params: Record = {} + + for (const line of lines) { + const trimmed = line.trim() + + // Extract tool name + if (trimmed.startsWith('Tool:')) { + tool = trimmed.split(':')[1].trim() + continue + } + + // Extract parameters + if (trimmed === 'Params:') { + let i = lines.indexOf(line) + 1 + while (i < lines.length) { + const paramLine = lines[i].trim() + if (paramLine === '') + break + + const paramMatch = paramLine.match(/(\w+):\s*(.+)/) + if (paramMatch) { + const [, key, value] = paramMatch + // Try to parse numbers and booleans + if (value === 'true') + params[key] = true + else if (value === 'false') + params[key] = false + else if (/^\d+$/.test(value)) + params[key] = Number.parseInt(value) + else if (/^\d*\.\d+$/.test(value)) + params[key] = Number.parseFloat(value) + else params[key] = value.trim() + } + i++ + } + } + } + + return { + description, + tool, + params, + } + }) + } + + private generatePlanningAgentSystemPrompt(availableActions: Action[]): string { + const actionsList = availableActions + .map((action) => { + const params = Object.keys(action.schema.shape) + .map(name => ` - ${name}`) + .join('\n') + return `- ${action.name}: ${action.description}\n Parameters:\n${params}` + }) + .join('\n\n') + + return `You are a Minecraft bot planner. Break down goals into simple action steps. + +Available tools: +${actionsList} + +Format each step as: +1. Action description (short, direct command) +2. Tool name +3. Required parameters + +Example: +1. Follow player + Tool: followPlayer + Params: + player: luoling8192 + follow_dist: 3 + +Keep steps: +- Short and direct +- Action-focused +- Parameters precise +- Generate all steps at once` + } + + private generatePlanningAgentUserPrompt(goal: string, sender: string, feedback?: string): string { + let prompt = `${sender}: ${goal} + +Generate minimal steps with exact parameters. +Use the sender's name (${sender}) for player-related parameters.` + + if (feedback) { + prompt += `\n\nPrevious attempt failed: ${feedback}` + } + return prompt + } +} diff --git a/services/minecraft/src/agents/planning/index.ts b/services/minecraft/src/agents/planning/index.ts new file mode 100644 index 00000000..274fd2f4 --- /dev/null +++ b/services/minecraft/src/agents/planning/index.ts @@ -0,0 +1,650 @@ +import type { Neuri } from 'neuri' +import type { Action } from '../../libs/mineflayer/action' +import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from '../../libs/mineflayer/base-agent' +import type { PlanStep } from './adapter' + +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { ActionAgentImpl } from '../action' +import { PlanningLLMHandler } from './adapter' + +interface PlanContext { + goal: string + currentStep: number + startTime: number + lastUpdate: number + retryCount: number + isGenerating: boolean + pendingSteps: PlanStep[] +} + +export interface PlanningAgentConfig extends AgentConfig { + llm: { + agent: Neuri + model?: string + } +} + +export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { + public readonly type = 'planning' as const + private currentPlan: Plan | null = null + private context: PlanContext | null = null + private actionAgent: ActionAgent | null = null + private memoryAgent: MemoryAgent | null = null + private llmConfig: PlanningAgentConfig['llm'] + private llmHandler: PlanningLLMHandler + + constructor(config: PlanningAgentConfig) { + super(config) + this.llmConfig = config.llm + this.llmHandler = new PlanningLLMHandler({ + agent: this.llmConfig.agent, + model: this.llmConfig.model, + }) + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing planning agent') + + // Create action agent directly + this.actionAgent = new ActionAgentImpl({ + id: 'action', + type: 'action', + }) + await this.actionAgent.init() + + // Set event listener + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + + this.on('interrupt', () => { + this.handleInterrupt() + }) + } + + protected async destroyAgent(): Promise { + this.currentPlan = null + this.context = null + this.actionAgent = null + this.memoryAgent = null + this.removeAllListeners() + } + + public async createPlan(goal: string): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + this.logger.withField('goal', goal).log('Creating plan') + + try { + // Check memory for existing plan + const cachedPlan = await this.loadCachedPlan(goal) + if (cachedPlan) { + this.logger.log('Using cached plan') + return cachedPlan + } + + // Get available actions from action agent + const availableActions = this.actionAgent?.getAvailableActions() ?? [] + + // Check if the goal requires actions + const requirements = this.parseGoalRequirements(goal) + const requiresAction = this.doesGoalRequireAction(requirements) + + // If no actions needed, return empty plan + if (!requiresAction) { + this.logger.log('Goal does not require actions') + return { + goal, + steps: [], + status: 'completed', + requiresAction: false, + } + } + + // Create plan steps based on available actions and goal + const steps = await this.generatePlanSteps(goal, availableActions, 'system') + + // Create new plan + const plan: Plan = { + goal, + steps, + status: 'pending', + requiresAction: true, + } + + // Cache the plan + await this.cachePlan(plan) + + this.currentPlan = plan + this.context = { + goal, + currentStep: 0, + startTime: Date.now(), + lastUpdate: Date.now(), + retryCount: 0, + isGenerating: false, + pendingSteps: [], + } + + return plan + } + catch (error) { + this.logger.withError(error).error('Failed to create plan') + throw error + } + } + + public async executePlan(plan: Plan): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + if (!plan.requiresAction) { + this.logger.log('Plan does not require actions, skipping execution') + return + } + + if (!this.actionAgent) { + throw new Error('Action agent not available') + } + + this.logger.withField('plan', plan).log('Executing plan') + + try { + plan.status = 'in_progress' + this.currentPlan = plan + + // Execute each step + for (const step of plan.steps) { + try { + this.logger.withField('step', step).log('Executing step') + await this.actionAgent.performAction(step) + } + catch (stepError) { + this.logger.withError(stepError).error('Failed to execute step') + + // Attempt to adjust plan and retry + if (this.context && this.context.retryCount < 3) { + this.context.retryCount++ + // Adjust plan and restart + const adjustedPlan = await this.adjustPlan( + plan, + stepError instanceof Error ? stepError.message : 'Unknown error', + 'system', + ) + await this.executePlan(adjustedPlan) + return + } + + throw stepError + } + } + + plan.status = 'completed' + } + catch (error) { + plan.status = 'failed' + throw error + } + finally { + this.context = null + } + } + + // private async generateStepsStream( + // goal: string, + // availableActions: Action[], + // sender: string, + // ): Promise { + // if (!this.context) { + // return + // } + + // try { + // // Generate all steps at once + // const steps = await this.llmHandler.generatePlan(goal, availableActions, sender) + // if (!this.context.isGenerating) { + // return + // } + + // // Add all steps to pending queue + // this.context.pendingSteps.push(...steps) + // this.logger.withField('steps', steps).log('Generated steps') + // } + // catch (error) { + // this.logger.withError(error).error('Failed to generate steps') + // throw error + // } + // finally { + // this.context.isGenerating = false + // } + // } + + // private async executeStepsStream(): Promise { + // if (!this.context || !this.actionAgent) { + // return + // } + + // try { + // while (this.context.isGenerating || this.context.pendingSteps.length > 0) { + // // Wait for steps to be available + // if (this.context.pendingSteps.length === 0) { + // await new Promise(resolve => setTimeout(resolve, 100)) + // continue + // } + + // // Execute next step + // const step = this.context.pendingSteps.shift() + // if (!step) { + // continue + // } + + // try { + // this.logger.withField('step', step).log('Executing step') + // await this.actionAgent.performAction(step) + // this.context.lastUpdate = Date.now() + // this.context.currentStep++ + // } + // catch (stepError) { + // this.logger.withError(stepError).error('Failed to execute step') + + // // Attempt to adjust plan and retry + // if (this.context.retryCount < 3) { + // this.context.retryCount++ + // // Stop current generation + // this.context.isGenerating = false + // this.context.pendingSteps = [] + // // Adjust plan and restart + // const adjustedPlan = await this.adjustPlan( + // this.currentPlan!, + // stepError instanceof Error ? stepError.message : 'Unknown error', + // 'system', + // ) + // await this.executePlan(adjustedPlan) + // return + // } + + // throw stepError + // } + // } + // } + // catch (error) { + // this.logger.withError(error).error('Failed to execute steps') + // throw error + // } + // } + + // private async *createStepGenerator( + // goal: string, + // availableActions: Action[], + // ): AsyncGenerator { + // // Use LLM to generate plan in chunks + // this.logger.log('Generating plan using LLM') + // const chunkSize = 3 // Generate 3 steps at a time + // let currentChunk = 1 + + // while (true) { + // const steps = await this.llmHandler.generatePlan( + // goal, + // availableActions, + // `Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`, + // ) + + // if (steps.length === 0) { + // break + // } + + // yield steps + // currentChunk++ + + // // Check if we've generated enough steps or if the goal is achieved + // if (steps.length < chunkSize || await this.isGoalAchieved(goal)) { + // break + // } + // } + // } + + // private async isGoalAchieved(goal: string): Promise { + // if (!this.context || !this.actionAgent) { + // return false + // } + + // const requirements = this.parseGoalRequirements(goal) + + // // Check inventory for required items + // if (requirements.needsItems && requirements.items) { + // const inventorySteps = this.generateGatheringSteps(requirements.items) + // if (inventorySteps.length > 0) { + // this.context.pendingSteps.push(...inventorySteps) + // return false + // } + // } + + // // Check location requirements + // if (requirements.needsMovement && requirements.location) { + // const movementSteps = this.generateMovementSteps(requirements.location) + // if (movementSteps.length > 0) { + // this.context.pendingSteps.push(...movementSteps) + // return false + // } + // } + + // // Check interaction requirements + // if (requirements.needsInteraction && requirements.target) { + // const interactionSteps = this.generateInteractionSteps(requirements.target) + // if (interactionSteps.length > 0) { + // this.context.pendingSteps.push(...interactionSteps) + // return false + // } + // } + + // return true + // } + + public async adjustPlan(plan: Plan, feedback: string, sender: string): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + this.logger.withFields({ plan, feedback }).log('Adjusting plan') + + try { + // If there's a current context, use it to adjust the plan + if (this.context) { + const currentStep = this.context.currentStep + const availableActions = this.actionAgent?.getAvailableActions() ?? [] + + // Generate recovery steps based on feedback + const recoverySteps = this.generateRecoverySteps(feedback) + + // Generate new steps from the current point + const newSteps = await this.generatePlanSteps(plan.goal, availableActions, sender, feedback) + + // Create adjusted plan + const adjustedPlan: Plan = { + goal: plan.goal, + steps: [ + ...plan.steps.slice(0, currentStep), + ...recoverySteps, + ...newSteps, + ], + status: 'pending', + requiresAction: true, + } + + return adjustedPlan + } + + // If no context, create a new plan + return this.createPlan(plan.goal) + } + catch (error) { + this.logger.withError(error).error('Failed to adjust plan') + throw error + } + } + + // private generateGatheringSteps(items: string[]): PlanStep[] { + // const steps: PlanStep[] = [] + + // for (const item of items) { + // steps.push( + // { + // description: `Search for ${item} in the surrounding area`, + // tool: 'searchForBlock', + // params: { + // blockType: item, + // range: 64, + // }, + // }, + // { + // description: `Collect ${item} from the found location`, + // tool: 'collectBlocks', + // params: { + // blockType: item, + // count: 1, + // }, + // }, + // ) + // } + + // return steps + // } + + // private generateMovementSteps(location: { x?: number, y?: number, z?: number }): PlanStep[] { + // if (location.x !== undefined && location.y !== undefined && location.z !== undefined) { + // return [{ + // description: `Move to coordinates (${location.x}, ${location.y}, ${location.z})`, + // tool: 'goToCoordinates', + // params: { + // x: location.x, + // y: location.y, + // z: location.z, + // }, + // }] + // } + // return [] + // } + + // private generateInteractionSteps(target: string): PlanStep[] { + // return [{ + // description: `Interact with ${target}`, + // tool: 'activate', + // params: { + // target, + // }, + // }] + // } + + private generateRecoverySteps(feedback: string): PlanStep[] { + const steps: PlanStep[] = [] + + if (feedback.includes('not found')) { + steps.push({ + description: 'Search in a wider area', + tool: 'searchForBlock', + params: { + blockType: 'oak_log', + range: 64, + }, + }) + } + + if (feedback.includes('inventory full')) { + steps.push({ + description: 'Clear inventory space', + tool: 'discard', + params: { + blockType: 'oak_log', + count: 1, + }, + }) + } + + if (feedback.includes('blocked') || feedback.includes('cannot reach')) { + steps.push({ + description: 'Move away from obstacles', + tool: 'moveAway', + params: { + range: 64, + }, + }) + } + + if (feedback.includes('too far')) { + steps.push({ + description: 'Move closer to target', + tool: 'moveAway', + params: { + range: 64, + }, + }) + } + + if (feedback.includes('need tool')) { + steps.push( + { + description: 'Craft a wooden pickaxe', + tool: 'craftRecipe', + params: { + recipe: 'oak_pickaxe', + }, + }, + { + description: 'Equip the wooden pickaxe', + tool: 'equip', + params: { + item: 'oak_pickaxe', + }, + }, + ) + } + + return steps + } + + private async loadCachedPlan(goal: string): Promise { + if (!this.memoryAgent) + return null + + const cachedPlan = this.memoryAgent.recall(`plan:${goal}`) + if (cachedPlan && this.isPlanValid(cachedPlan)) { + return cachedPlan + } + return null + } + + private async cachePlan(plan: Plan): Promise { + if (!this.memoryAgent) + return + + this.memoryAgent.remember(`plan:${plan.goal}`, plan) + } + + private isPlanValid(_plan: Plan): boolean { + // Add validation logic here + return true + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system') { + if (message.includes('interrupt')) { + this.handleInterrupt() + } + } + else { + // Process message and potentially adjust plan + this.logger.withFields({ sender, message }).log('Processing agent message') + + // If there's a current plan, try to adjust it based on the message + if (this.currentPlan) { + await this.adjustPlan(this.currentPlan, message, sender) + } + } + } + + private handleInterrupt(): void { + if (this.currentPlan) { + this.currentPlan.status = 'failed' + this.context = null + } + } + + private doesGoalRequireAction(requirements: ReturnType): boolean { + // Check if any requirement indicates need for action + return requirements.needsItems + || requirements.needsMovement + || requirements.needsInteraction + || requirements.needsCrafting + || requirements.needsCombat + } + + private async generatePlanSteps( + goal: string, + availableActions: Action[], + sender: string, + feedback?: string, + ): Promise { + // Generate all steps at once + this.logger.log('Generating plan using LLM') + return await this.llmHandler.generatePlan(goal, availableActions, sender, feedback) + } + + private parseGoalRequirements(goal: string): { + needsItems: boolean + items?: string[] + needsMovement: boolean + location?: { x?: number, y?: number, z?: number } + needsInteraction: boolean + target?: string + needsCrafting: boolean + needsCombat: boolean + } { + const requirements = { + needsItems: false, + items: [] as string[], + needsMovement: false, + location: undefined as { x?: number, y?: number, z?: number } | undefined, + needsInteraction: false, + target: undefined as string | undefined, + needsCrafting: false, + needsCombat: false, + } + + const goalLower = goal.toLowerCase() + + // Extract items from goal + const itemMatches = goalLower.match(/(collect|get|find|craft|make|build|use|equip) (\w+)/g) + if (itemMatches) { + requirements.needsItems = true + requirements.items = itemMatches.map(match => match.split(' ')[1]) + } + + // Extract location from goal + const locationMatches = goalLower.match(/(go to|move to|at) (\d+)[, ]+(\d+)[, ]+(\d+)/g) + if (locationMatches) { + requirements.needsMovement = true + const [x, y, z] = locationMatches[0].split(/[, ]+/).slice(-3).map(Number) + requirements.location = { x, y, z } + } + + // Extract target from goal + const targetMatches = goalLower.match(/(interact with|use|open|activate) (\w+)/g) + if (targetMatches) { + requirements.needsInteraction = true + requirements.target = targetMatches[0].split(' ').pop() + } + + // Check for item-related actions + if (goalLower.includes('collect') || goalLower.includes('get') || goalLower.includes('find')) { + requirements.needsItems = true + requirements.needsMovement = true + } + + // Check for movement-related actions + if (goalLower.includes('go to') || goalLower.includes('move to') || goalLower.includes('follow')) { + requirements.needsMovement = true + } + + // Check for interaction-related actions + if (goalLower.includes('interact') || goalLower.includes('use') || goalLower.includes('open')) { + requirements.needsInteraction = true + } + + // Check for crafting-related actions + if (goalLower.includes('craft') || goalLower.includes('make') || goalLower.includes('build')) { + requirements.needsCrafting = true + requirements.needsItems = true + } + + // Check for combat-related actions + if (goalLower.includes('attack') || goalLower.includes('fight') || goalLower.includes('kill')) { + requirements.needsCombat = true + requirements.needsMovement = true + } + + return requirements + } +} diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts new file mode 100644 index 00000000..2e319acf --- /dev/null +++ b/services/minecraft/src/composables/bot.ts @@ -0,0 +1,33 @@ +import type { MineflayerOptions } from '../libs/mineflayer' + +import { Mineflayer } from '../libs/mineflayer' + +// Singleton instance of the Mineflayer bot +let botInstance: Mineflayer | null = null + +/** + * Initialize a new Mineflayer bot instance. + * Follows singleton pattern to ensure only one bot exists at a time. + */ +export async function initBot(options: MineflayerOptions): Promise<{ bot: Mineflayer }> { + if (botInstance) { + throw new Error('Bot already initialized') + } + + botInstance = await Mineflayer.asyncBuild(options) + return { bot: botInstance } +} + +/** + * Get the current bot instance. + * Throws if bot is not initialized. + */ +export function useBot(): { bot: Mineflayer } { + if (!botInstance) { + throw new Error('Bot not initialized') + } + + return { + bot: botInstance, + } +} diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts new file mode 100644 index 00000000..e391eaf1 --- /dev/null +++ b/services/minecraft/src/composables/config.ts @@ -0,0 +1,87 @@ +import type { BotOptions } from 'mineflayer' + +import { env } from 'node:process' + +import { useLogger } from '../utils/logger' + +const logger = useLogger() + +// Configuration interfaces +interface OpenAIConfig { + apiKey: string + baseUrl: string + model: string + reasoningModel: string +} + +interface AiriConfig { + wsBaseUrl: string + clientName: string +} + +interface Config { + openai: OpenAIConfig + bot: BotOptions + airi: AiriConfig +} + +// Helper functions for type-safe environment variable parsing +function getEnvVar(key: string, defaultValue: string): string { + return env[key] || defaultValue +} + +function getEnvNumber(key: string, defaultValue: number): number { + return Number.parseInt(env[key] || String(defaultValue)) +} + +// Default configurations +const defaultConfig: Config = { + openai: { + apiKey: '', + baseUrl: '', + model: '', + reasoningModel: '', + }, + bot: { + username: 'airi-bot', + host: 'localhost', + port: 25565, + password: '', + version: '1.20', + }, + airi: { + wsBaseUrl: 'ws://localhost:6121/ws', + clientName: 'minecraft-bot', + }, +} + +// Create a singleton config instance +export const config: Config = { ...defaultConfig } + +// Initialize environment configuration +export function initEnv(): void { + logger.log('Initializing environment variables') + + // Update config with environment variables + config.openai = { + apiKey: getEnvVar('OPENAI_API_KEY', defaultConfig.openai.apiKey), + baseUrl: getEnvVar('OPENAI_API_BASEURL', defaultConfig.openai.baseUrl), + model: getEnvVar('OPENAI_MODEL', defaultConfig.openai.model), + reasoningModel: getEnvVar('OPENAI_REASONING_MODEL', defaultConfig.openai.reasoningModel), + } + + config.bot = { + username: getEnvVar('BOT_USERNAME', defaultConfig.bot.username as string), + host: getEnvVar('BOT_HOSTNAME', defaultConfig.bot.host as string), + port: getEnvNumber('BOT_PORT', defaultConfig.bot.port as number), + password: getEnvVar('BOT_PASSWORD', defaultConfig.bot.password as string), + version: getEnvVar('BOT_VERSION', defaultConfig.bot.version as string), + } + + config.airi = { + wsBaseUrl: getEnvVar('AIRI_WS_BASEURL', defaultConfig.airi.wsBaseUrl), + clientName: getEnvVar('AIRI_CLIENT_NAME', defaultConfig.airi.clientName), + } + + logger.withFields({ config }).log('Environment variables initialized') +} diff --git a/services/minecraft/src/composables/neuri.ts b/services/minecraft/src/composables/neuri.ts new file mode 100644 index 00000000..bc52e435 --- /dev/null +++ b/services/minecraft/src/composables/neuri.ts @@ -0,0 +1,40 @@ +import type { Agent, Neuri } from 'neuri' +import type { Mineflayer } from '../libs/mineflayer' + +import { neuri } from 'neuri' + +import { createActionNeuriAgent } from '../agents/action/adapter' +import { createChatNeuriAgent } from '../agents/chat/llm' +import { createPlanningNeuriAgent } from '../agents/planning/adapter' +import { useLogger } from '../utils/logger' +import { config } from './config' + +let neuriAgent: Neuri | undefined +const agents = new Set>() + +export async function createNeuriAgent(mineflayer: Mineflayer): Promise { + useLogger().log('Initializing neuri agent') + let n = neuri() + + agents.add(createPlanningNeuriAgent()) + agents.add(createActionNeuriAgent(mineflayer)) + agents.add(createChatNeuriAgent()) + + agents.forEach(agent => n = n.agent(agent)) + + neuriAgent = await n.build({ + provider: { + apiKey: config.openai.apiKey, + baseURL: config.openai.baseUrl, + }, + }) + + return neuriAgent +} + +export function useNeuriAgent(): Neuri { + if (!neuriAgent) { + throw new Error('Agent not initialized') + } + return neuriAgent +} diff --git a/services/minecraft/src/libs/llm-agent/chat.ts b/services/minecraft/src/libs/llm-agent/chat.ts new file mode 100644 index 00000000..117a2315 --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/chat.ts @@ -0,0 +1,53 @@ +import type { Neuri, NeuriContext } from 'neuri' +import type { Logger } from '../../utils/logger' +import type { MineflayerWithAgents } from './types' + +import { system, user } from 'neuri/openai' + +import { toRetriable } from '../../utils/helper' +import { handleLLMCompletion } from './completion' +import { generateStatusPrompt } from './prompt' + +export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { + logger.withFields({ username, message }).log('Chat message received') + bot.memory.chatHistory.push(user(`${username}: ${message}`)) + + logger.log('thinking...') + + try { + // Create and execute plan + const plan = await bot.planning.createPlan(message) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // Generate response + // TODO: use chat agent and conversion manager + const statusPrompt = await generateStatusPrompt(bot) + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('handling response...') + return toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + { onError: err => logger.withError(err).log('error occurred') }, + )(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} diff --git a/services/minecraft/src/libs/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts new file mode 100644 index 00000000..433adaaa --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/completion.ts @@ -0,0 +1,28 @@ +import type { NeuriContext } from 'neuri' +import type { ChatCompletion } from 'neuri/openai' +import type { Logger } from '../../utils/logger' +import type { MineflayerWithAgents } from './types' + +import { assistant } from 'neuri/openai' + +import { config } from '../../composables/config' + +export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise { + logger.log('rerouting...') + + const completion = await context.reroute('action', context.messages, { + model: config.openai.model, + }) as ChatCompletion | { error: { message: string } } & ChatCompletion + + if (!completion || 'error' in completion) { + logger.withFields({ completion }).error('Completion') + logger.withFields({ messages: context.messages }).log('messages') + return completion?.error?.message ?? 'Unknown error' + } + + const content = await completion.firstContent() + logger.withFields({ usage: completion.usage, content }).log('output') + + bot.memory.chatHistory.push(assistant(content)) + return content +} diff --git a/services/minecraft/src/libs/llm-agent/container.ts b/services/minecraft/src/libs/llm-agent/container.ts new file mode 100644 index 00000000..da49b0b0 --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/container.ts @@ -0,0 +1,70 @@ +import type { Neuri } from 'neuri' +import type { Logger } from '../../utils/logger' + +import { useLogg } from '@guiiai/logg' +import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' + +import { ActionAgentImpl } from '../../agents/action' +import { ChatAgentImpl } from '../../agents/chat' +import { PlanningAgentImpl } from '../../agents/planning' + +export interface ContainerServices { + logger: Logger + actionAgent: ActionAgentImpl + planningAgent: PlanningAgentImpl + chatAgent: ChatAgentImpl + neuri: Neuri +} + +export function createAgentContainer(options: { + neuri: Neuri + model?: string +}) { + const container = createContainer({ + injectionMode: InjectionMode.PROXY, + strict: true, + }) + + // Register services + container.register({ + // Create independent logger for each agent + logger: asFunction(() => useLogg('agent').useGlobalConfig()).singleton(), + + // Register neuri client + neuri: asFunction(() => options.neuri).singleton(), + + // Register agents + actionAgent: asClass(ActionAgentImpl) + .singleton() + .inject(() => ({ + id: 'action', + type: 'action' as const, + })), + + planningAgent: asClass(PlanningAgentImpl) + .singleton() + .inject(() => ({ + id: 'planning', + type: 'planning' as const, + llm: { + agent: options.neuri, + model: options.model, + }, + })), + + chatAgent: asClass(ChatAgentImpl) + .singleton() + .inject(() => ({ + id: 'chat', + type: 'chat' as const, + llm: { + agent: options.neuri, + model: options.model, + }, + maxHistoryLength: 50, + idleTimeout: 5 * 60 * 1000, // 5 minutes + })), + }) + + return container +} diff --git a/services/minecraft/src/libs/llm-agent/handler.ts b/services/minecraft/src/libs/llm-agent/handler.ts new file mode 100644 index 00000000..d6e48e24 --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/handler.ts @@ -0,0 +1,46 @@ +import type { NeuriContext } from 'neuri' +import type { ChatCompletion, Message } from 'neuri/openai' +import type { LLMConfig, LLMResponse } from './types' + +import { config } from '../../composables/config' +import { toRetriable } from '../../utils/helper' +import { type Logger, useLogger } from '../../utils/logger' + +export abstract class BaseLLMHandler { + protected logger: Logger + + constructor(protected config: LLMConfig) { + this.logger = useLogger() + } + + protected async handleCompletion( + context: NeuriContext, + route: string, + messages: Message[], + ): Promise { + const completion = await context.reroute(route, messages, { + model: this.config.model ?? config.openai.model, + }) as ChatCompletion | ChatCompletion & { error: { message: string } } + + if (!completion || 'error' in completion) { + this.logger.withFields(context).error('Completion failed') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion.firstContent() + this.logger.withFields({ usage: completion.usage, content }).log('Generated content') + + return { + content, + usage: completion.usage, + } + } + + protected createRetryHandler(handler: (context: NeuriContext) => Promise) { + return toRetriable( + this.config.retryLimit ?? 3, + this.config.delayInterval ?? 1000, + handler, + ) + } +} diff --git a/services/minecraft/src/libs/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts new file mode 100644 index 00000000..fc2ef25d --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -0,0 +1,61 @@ +import type { MineflayerPlugin } from '../mineflayer' +import type { LLMAgentOptions, MineflayerWithAgents } from './types' + +import { system } from 'neuri/openai' + +import { config } from '../../composables/config' +import { useLogger } from '../../utils/logger' +import { ChatMessageHandler } from '../mineflayer' +import { handleChatMessage } from './chat' +import { createAgentContainer } from './container' +import { generateActionAgentPrompt } from './prompt' +import { handleVoiceInput } from './voice' + +export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { + return { + async created(bot) { + const logger = useLogger() + + // Create container and get required services + const container = createAgentContainer({ + neuri: options.agent, + model: config.openai.model, + }) + + const actionAgent = container.resolve('actionAgent') + const planningAgent = container.resolve('planningAgent') + const chatAgent = container.resolve('chatAgent') + + // Initialize agents + await actionAgent.init() + await planningAgent.init() + await chatAgent.init() + + // Type conversion + const botWithAgents = bot as unknown as MineflayerWithAgents + botWithAgents.action = actionAgent + botWithAgents.planning = planningAgent + botWithAgents.chat = chatAgent + + // Initialize system prompt + bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) + + // Set message handling + const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => + handleChatMessage(username, message, botWithAgents, options.agent, logger)) + + options.airiClient.onEvent('input:text:voice', event => + handleVoiceInput(event, botWithAgents, options.agent, logger)) + + bot.bot.on('chat', onChat) + }, + + async beforeCleanup(bot) { + const botWithAgents = bot as unknown as MineflayerWithAgents + await botWithAgents.action?.destroy() + await botWithAgents.planning?.destroy() + await botWithAgents.chat?.destroy() + bot.bot.removeAllListeners('chat') + }, + } +} diff --git a/services/minecraft/src/libs/llm-agent/prompt.ts b/services/minecraft/src/libs/llm-agent/prompt.ts new file mode 100644 index 00000000..61591b7a --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/prompt.ts @@ -0,0 +1,50 @@ +import type { Mineflayer } from '../mineflayer' + +import { listInventory } from '../../skills/actions/inventory' + +export async function generateStatusPrompt(mineflayer: Mineflayer): Promise { + // Get inventory items + const inventory = await listInventory(mineflayer) + + // Format inventory string + const inventoryStr = inventory.length === 0 + ? '[Empty]' + : inventory.map(item => `${item.name} x ${item.count}`).join(', ') + + // Get currently held item + const itemInHand = inventory.length === 0 + ? '[Empty]' + : `${inventory[0].name} x ${inventory[0].count}` // TODO: mock + + // Build status message + return [ + 'I will give you the following information:', + mineflayer.status.toOneLiner(), + '', + 'Inventory:', + inventoryStr, + '', + 'Item in hand:', + itemInHand, + ].join('\n') +} + +export function generateSystemBasicPrompt(botName: string): string { + // ${ctx.prompt.selfPrompt} + return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, +mine, build, and interact with the world by using commands.` +} + +export function generateActionAgentPrompt(mineflayer: Mineflayer): string { + return `${generateSystemBasicPrompt(mineflayer.username)} + +Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in +your responses, don't apologize constantly, don't give instructions or make lists unless +asked, and don't refuse requests. + +Do not use any emojis. Just call the function given you if needed. + +- If I command you 'stop', then call the 'stop' function. +- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. +` +} diff --git a/services/minecraft/src/libs/llm-agent/types.ts b/services/minecraft/src/libs/llm-agent/types.ts new file mode 100644 index 00000000..ee8367df --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/types.ts @@ -0,0 +1,28 @@ +import type { Client } from '@proj-airi/server-sdk' +import type { Neuri } from 'neuri' +import type { Mineflayer } from '../mineflayer' +import type { ActionAgent, ChatAgent, PlanningAgent } from '../mineflayer/base-agent' + +export interface LLMConfig { + agent: Neuri + model?: string + retryLimit?: number + delayInterval?: number + maxContextLength?: number +} + +export interface LLMResponse { + content: string + usage?: any +} + +export interface MineflayerWithAgents extends Mineflayer { + planning: PlanningAgent + action: ActionAgent + chat: ChatAgent +} + +export interface LLMAgentOptions { + agent: Neuri + airiClient: Client +} diff --git a/services/minecraft/src/libs/llm-agent/voice.ts b/services/minecraft/src/libs/llm-agent/voice.ts new file mode 100644 index 00000000..d7a52bca --- /dev/null +++ b/services/minecraft/src/libs/llm-agent/voice.ts @@ -0,0 +1,58 @@ +import type { Neuri, NeuriContext } from 'neuri' +import type { Logger } from '../../utils/logger' +import type { MineflayerWithAgents } from './types' + +import { system, user } from 'neuri/openai' + +import { toRetriable } from '../../utils/helper' +import { handleLLMCompletion } from './completion' +import { generateStatusPrompt } from './prompt' + +export async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { + logger + .withFields({ + user: event.data.discord?.guildMember, + message: event.data.transcription, + }) + .log('Chat message received') + + const statusPrompt = await generateStatusPrompt(bot) + bot.memory.chatHistory.push(system(statusPrompt)) + bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) + + try { + // Create and execute plan + const plan = await bot.planning.createPlan(event.data.transcription) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // Generate response + const retryHandler = toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + ) + + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('thinking...') + return retryHandler(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} diff --git a/services/minecraft/src/libs/mineflayer/action.ts b/services/minecraft/src/libs/mineflayer/action.ts new file mode 100644 index 00000000..5cf278e5 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/action.ts @@ -0,0 +1,11 @@ +import type { z } from 'zod' +import type { Mineflayer } from './core' + +type ActionResult = string | Promise + +export interface Action { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (mineflayer: Mineflayer) => (...args: any[]) => ActionResult +} diff --git a/services/minecraft/src/libs/mineflayer/base-agent.ts b/services/minecraft/src/libs/mineflayer/base-agent.ts new file mode 100644 index 00000000..d56130b6 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/base-agent.ts @@ -0,0 +1,128 @@ +import type { PlanStep } from '../../agents/planning/adapter' +import type { Logger } from '../../utils/logger' +import type { Action } from './action' + +import { useLogg } from '@guiiai/logg' +import EventEmitter3 from 'eventemitter3' + +export type AgentType = 'action' | 'memory' | 'planning' | 'chat' + +export interface AgentConfig { + id: string + type: AgentType +} + +export interface BaseAgent { + readonly id: string + readonly type: AgentType + init: () => Promise + destroy: () => Promise +} + +export interface ActionAgent extends BaseAgent { + type: 'action' + performAction: (step: PlanStep) => Promise + getAvailableActions: () => Action[] +} + +export interface MemoryAgent extends BaseAgent { + type: 'memory' + remember: (key: string, value: unknown) => void + recall: (key: string) => T | undefined + forget: (key: string) => void + getMemorySnapshot: () => Record +} + +export interface Plan { + goal: string + steps: PlanStep[] + status: 'pending' | 'in_progress' | 'completed' | 'failed' + requiresAction: boolean +} + +export interface PlanningAgent extends BaseAgent { + type: 'planning' + createPlan: (goal: string) => Promise + executePlan: (plan: Plan) => Promise + adjustPlan: (plan: Plan, feedback: string, sender: string) => Promise +} + +export interface ChatAgent extends BaseAgent { + type: 'chat' + processMessage: (message: string, sender: string) => Promise + startConversation: (player: string) => void + endConversation: (player: string) => void +} + +export abstract class AbstractAgent extends EventEmitter3 implements BaseAgent { + public readonly id: string + public readonly type: AgentConfig['type'] + public readonly name: string + + protected initialized: boolean + protected logger: Logger + // protected actionManager: ReturnType + // protected conversationStore: ReturnType + + constructor(config: AgentConfig) { + super() + this.id = config.id // TODO: use uuid, is it needed? + this.type = config.type + this.name = `${this.type}-agent` + this.initialized = false + this.logger = useLogg(this.name).useGlobalConfig() + + // Initialize managers + // this.actionManager = useActionManager(this) + // this.conversationStore = useConversationStore({ + // agent: this, + // chatBotMessages: true, + // }) + } + + public async init(): Promise { + if (this.initialized) { + return + } + + await this.initializeAgent() + this.initialized = true + } + + public async destroy(): Promise { + if (!this.initialized) { + return + } + + this.logger.log('Destroying agent') + await this.destroyAgent() + this.initialized = false + } + + // Agent interface implementation + // public isIdle(): boolean { + // return !this.actionManager.executing + // } + + public handleMessage(sender: string, message: string): void { + this.logger.withFields({ sender, message }).log('Received message') + this.emit('message', { sender, message }) + } + + public openChat(message: string): void { + this.logger.withField('message', message).log('Opening chat') + this.emit('chat', message) + } + + // public clearBotLogs(): void { + // // Implement if needed + // } + + public requestInterrupt(): void { + this.emit('interrupt') + } + + // Methods to be implemented by specific agents + protected abstract initializeAgent(): Promise + protected abstract destroyAgent(): Promise +} diff --git a/services/minecraft/src/libs/mineflayer/command.ts b/services/minecraft/src/libs/mineflayer/command.ts new file mode 100644 index 00000000..7d1a3c95 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/command.ts @@ -0,0 +1,13 @@ +export interface CommandContext { + sender: string + isCommand: boolean + command: string + args: string[] +} + +export function parseCommand(sender: string, message: string): CommandContext { + const isCommand = message.startsWith('#') + const command = message.split(' ')[0] + const args = message.split(' ').slice(1) + return { sender, isCommand, command, args } +} diff --git a/services/minecraft/src/libs/mineflayer/components.ts b/services/minecraft/src/libs/mineflayer/components.ts new file mode 100644 index 00000000..67fa5789 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/components.ts @@ -0,0 +1,30 @@ +import type { Logg } from '@guiiai/logg' +import type { Handler } from './types' + +import { useLogger } from '../../utils/logger' + +export class Components { + private components: Map = new Map() + private logger: Logg + + constructor() { + this.logger = useLogger() + } + + register(componentName: string, component: Handler) { + this.components.set(componentName, component) + } + + get(componentName: string) { + return this.components.get(componentName) + } + + list() { + return Array.from(this.components.keys()) + } + + cleanup() { + this.logger.log('Cleaning up components') + this.components.clear() + } +} diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts new file mode 100644 index 00000000..f0c14c21 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -0,0 +1,346 @@ +import type { Logg } from '@guiiai/logg' +import type { Bot, BotOptions } from 'mineflayer' +import type { MineflayerPlugin } from './plugin' +import type { TickEvents, TickEventsHandler } from './ticker' +import type { EventHandlers, EventsHandler } from './types' + +import { useLogg } from '@guiiai/logg' +import EventEmitter from 'eventemitter3' +import mineflayer from 'mineflayer' + +import { parseCommand } from './command' +import { Components } from './components' +import { Health } from './health' +import { Memory } from './memory' +import { ChatMessageHandler } from './message' +import { Status } from './status' +import { Ticker } from './ticker' + +export interface MineflayerOptions { + botConfig: BotOptions + plugins?: Array +} + +export class Mineflayer extends EventEmitter { + public bot: Bot + public username: string + public health: Health = new Health() + public ready: boolean = false + public components: Components = new Components() + public status: Status = new Status() + public memory: Memory = new Memory() + + public isCreative: boolean = false + public allowCheats: boolean = false + + private options: MineflayerOptions + private logger: Logg + private commands: Map> = new Map() + private ticker: Ticker = new Ticker() + + constructor(options: MineflayerOptions) { + super() + this.options = options + this.bot = mineflayer.createBot(options.botConfig) + this.username = options.botConfig.username + this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() + + this.on('interrupt', () => { + this.logger.log('Interrupted') + this.bot.chat('Interrupted') + }) + } + + public static async asyncBuild(options: MineflayerOptions) { + const mineflayer = new Mineflayer(options) + + mineflayer.bot.on('messagestr', async (message, _, jsonMsg) => { + // jsonMsg.translate: + // - death.attack.player + // message: + // - was slain by + // - drowned + if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(mineflayer.username)) { + const deathPos = mineflayer.bot.entity.position + + // mineflayer.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) + let deathPosStr: string | undefined + if (deathPos) { + deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` + } + + const dimension = mineflayer.bot.game.dimension + await mineflayer.handleMessage('system', `You died at position ${deathPosStr || 'unknown'} in the ${dimension} dimension with the final message: '${message}'. Your place of death has been saved as 'last_death_position' if you want to return. Previous actions were stopped and you have re-spawned.`) + } + }) + + mineflayer.bot.once('resourcePack', () => { + mineflayer.bot.acceptResourcePack() + }) + + mineflayer.bot.on('time', () => { + if (mineflayer.bot.time.timeOfDay === 0) + mineflayer.emit('time:sunrise', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 6000) + mineflayer.emit('time:noon', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 12000) + mineflayer.emit('time:sunset', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 18000) + mineflayer.emit('time:midnight', { time: mineflayer.bot.time.timeOfDay }) + }) + + mineflayer.bot.on('health', () => { + mineflayer.logger.withFields({ + health: mineflayer.health.value, + lastDamageTime: mineflayer.health.lastDamageTime, + lastDamageTaken: mineflayer.health.lastDamageTaken, + previousHealth: mineflayer.bot.health, + }).log('Health updated') + + if (mineflayer.bot.health < mineflayer.health.value) { + mineflayer.health.lastDamageTime = Date.now() + mineflayer.health.lastDamageTaken = mineflayer.health.value - mineflayer.bot.health + } + + mineflayer.health.value = mineflayer.bot.health + }) + + mineflayer.bot.once('spawn', () => { + mineflayer.ready = true + mineflayer.logger.log('Bot ready') + }) + + mineflayer.bot.on('death', () => { + mineflayer.logger.error('Bot died') + }) + + mineflayer.bot.on('kicked', (reason: string) => { + mineflayer.logger.withFields({ reason }).error('Bot was kicked') + }) + + mineflayer.bot.on('end', (reason) => { + mineflayer.logger.withFields({ reason }).log('Bot ended') + }) + + mineflayer.bot.on('error', (err: Error) => { + mineflayer.logger.errorWithError('Bot error:', err) + }) + + mineflayer.bot.on('spawn', () => { + mineflayer.bot.on('chat', mineflayer.handleCommand()) + }) + + mineflayer.bot.on('spawn', async () => { + for (const plugin of options?.plugins || []) { + if (plugin.spawned) { + await plugin.spawned(mineflayer) + } + } + }) + + for (const plugin of options?.plugins || []) { + if (plugin.created) { + await plugin.created(mineflayer) + } + } + + // Load Plugins + for (const plugin of options?.plugins || []) { + if (plugin.loadPlugin) { + mineflayer.bot.loadPlugin(await plugin.loadPlugin(mineflayer, mineflayer.bot, options.botConfig)) + } + } + + mineflayer.ticker.on('tick', () => { + mineflayer.status.update(mineflayer) + mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' + mineflayer.allowCheats = false + }) + + return mineflayer + } + + public async loadPlugin(plugin: MineflayerPlugin) { + if (plugin.created) + await plugin.created(this) + + if (plugin.loadPlugin) { + this.bot.loadPlugin(await plugin.loadPlugin(this, this.bot, this.options.botConfig)) + } + + if (plugin.spawned) + this.bot.once('spawn', () => plugin.spawned?.(this)) + } + + public onCommand(commandName: string, cb: EventsHandler<'command'>) { + this.commands.set(commandName, cb) + } + + public onTick(event: TickEvents, cb: TickEventsHandler) { + this.ticker.on(event, cb) + } + + public async stop() { + for (const plugin of this.options?.plugins || []) { + if (plugin.beforeCleanup) { + await plugin.beforeCleanup(this) + } + } + this.components.cleanup() + this.bot.removeListener('chat', this.handleCommand()) + this.bot.quit() + this.removeAllListeners() + } + + private handleCommand() { + return new ChatMessageHandler(this.username).handleChat((sender, message) => { + const { isCommand, command, args } = parseCommand(sender, message) + + if (!isCommand) + return + + // Remove the # prefix from command + const cleanCommand = command.slice(1) + this.logger.withFields({ sender, command: cleanCommand, args }).log('Command received') + + const handler = this.commands.get(cleanCommand) + if (handler) { + handler({ time: this.bot.time.timeOfDay, command: { sender, isCommand, command: cleanCommand, args } }) + return + } + + // Built-in commands + switch (cleanCommand) { + case 'help': { + const commandList = Array.from(this.commands.keys()).concat(['help']) + this.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) + break + } + default: + this.bot.chat(`Unknown command: ${cleanCommand}`) + } + }) + } + + private async handleMessage(_source: string, _message: string, _maxResponses: number = Infinity) { + // if (!source || !message) { + // console.warn('Received empty message from', source); + // return false; + // } + + // let used_command = false; + // if (maxResponses === null) { + // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; + // } + // if (maxResponses === -1) { + // maxResponses = Infinity; + // } + + // const self_prompt = source === 'system' || source === ctx.botName; + // const from_other_bot = convoManager.isOtherAgent(source); + + // if (!self_prompt && !from_other_bot) { // from user, check for forced commands + // const user_command_name = containsCommand(message); + // if (user_command_name) { + // if (!commandExists(user_command_name)) { + // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); + // return false; + // } + // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); + // if (user_command_name === '!newAction') { + // // all user-initiated commands are ignored by the bot except for this one + // // add the preceding message to the history to give context for newAction + // this.history.add(source, message); + // } + // let execute_res = await executeCommand(this, message); + // if (execute_res) + // this.routeResponse(source, execute_res); + // return true; + // } + // } + + // if (from_other_bot) + // this.last_sender = source; + + // // Now translate the message + // message = await handleEnglishTranslation(message); + // console.log('received message from', source, ':', message); + + // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); + + // let behavior_log = this.bot.modes.flushBehaviorLog(); + // if (behavior_log.trim().length > 0) { + // const MAX_LOG = 500; + // if (behavior_log.length > MAX_LOG) { + // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); + // } + // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); + // await this.history.add('system', behavior_log); + // } + + // // Handle other user messages + // await this.history.add(source, message); + // this.history.save(); + + // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting + // maxResponses = 1; // force only respond to this message, then let self-prompting take over + // for (let i=0; i 0) + // chat_message = `${pre_message} ${chat_message}`; + // this.routeResponse(source, chat_message); + // } + + // let execute_res = await executeCommand(this, res); + + // console.log('Agent executed:', command_name, 'and got:', execute_res); + // used_command = true; + + // if (execute_res) + // this.history.add('system', execute_res); + // else + // break; + // } + // else { // conversation response + // this.history.add(this.name, res); + // this.routeResponse(source, res); + // break; + // } + + // this.history.save(); + // } + + // return used_command; + } +} diff --git a/services/minecraft/src/libs/mineflayer/health.ts b/services/minecraft/src/libs/mineflayer/health.ts new file mode 100644 index 00000000..fb85b3b1 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/health.ts @@ -0,0 +1,9 @@ +export class Health { + public value: number + public lastDamageTime?: number + public lastDamageTaken?: number + + constructor() { + this.value = 20 + } +} diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts new file mode 100644 index 00000000..1bea7708 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -0,0 +1,11 @@ +export * from './action' +export * from './command' +export * from './components' +export * from './core' +export * from './health' +export * from './memory' +export * from './message' +export * from './plugin' +export * from './status' +export * from './ticker' +export * from './types' diff --git a/services/minecraft/src/libs/mineflayer/memory.ts b/services/minecraft/src/libs/mineflayer/memory.ts new file mode 100644 index 00000000..25fb9633 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/memory.ts @@ -0,0 +1,12 @@ +import type { Message } from 'neuri/openai' +import type { Action } from './action' + +export class Memory { + public chatHistory: Message[] + public actions: Action[] + + constructor() { + this.chatHistory = [] + this.actions = [] + } +} diff --git a/services/minecraft/src/libs/mineflayer/message.ts b/services/minecraft/src/libs/mineflayer/message.ts new file mode 100644 index 00000000..5ab42fb7 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/message.ts @@ -0,0 +1,45 @@ +import type { Entity } from 'prismarine-entity' + +// Represents the context of a chat message in the Minecraft world +interface ChatMessage { + readonly sender: { + username: string + entity: Entity | null + } + readonly content: string +} + +// Handles chat message validation and processing +export class ChatMessageHandler { + constructor(private readonly botUsername: string) {} + + // Creates a new chat message context with validation + createMessageContext(entity: Entity | null, username: string, content: string): ChatMessage { + return { + sender: { + username, + entity, + }, + content, + } + } + + // Checks if a message is from the bot itself + isBotMessage(username: string): boolean { + return username === this.botUsername + } + + // Checks if a message is a command + isCommand(content: string): boolean { + return content.startsWith('#') + } + + // Processes chat messages, filtering out bot's own messages + handleChat(callback: (username: string, message: string) => void): (username: string, message: string) => void { + return (username: string, message: string) => { + if (!this.isBotMessage(username)) { + callback(username, message) + } + } + } +} diff --git a/services/minecraft/src/libs/mineflayer/plugin.ts b/services/minecraft/src/libs/mineflayer/plugin.ts new file mode 100644 index 00000000..a65f52ee --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/plugin.ts @@ -0,0 +1,15 @@ +import type { Bot, BotOptions, Plugin } from 'mineflayer' +import type { Mineflayer } from '.' + +export interface MineflayerPlugin { + created?: (mineflayer: Mineflayer) => void | Promise + loadPlugin?: (mineflayer: Mineflayer, bot: Bot, options: BotOptions) => Plugin + spawned?: (mineflayer: Mineflayer) => void | Promise + beforeCleanup?: (mineflayer: Mineflayer) => void | Promise +} + +export function wrapPlugin(plugin: Plugin): MineflayerPlugin { + return { + loadPlugin: () => (plugin), + } +} diff --git a/services/minecraft/src/libs/mineflayer/status.ts b/services/minecraft/src/libs/mineflayer/status.ts new file mode 100644 index 00000000..d39aadcf --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/status.ts @@ -0,0 +1,46 @@ +import type { Mineflayer } from './core' +import type { OneLinerable } from './types' + +export class Status implements OneLinerable { + public position: string + public health: string + public weather: string + public timeOfDay: string + + constructor() { + this.position = '' + this.health = '' + this.weather = '' + this.timeOfDay = '' + } + + public update(mineflayer: Mineflayer) { + if (!mineflayer.ready) + return + + Object.assign(this, Status.from(mineflayer)) + } + + static from(mineflayer: Mineflayer): Status { + if (!mineflayer.ready) + return new Status() + + const pos = mineflayer.bot.entity.position + const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' + const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 + ? 'Morning' + : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + + const status = new Status() + status.position = `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}` + status.health = `${Math.round(mineflayer.bot.health)} / 20` + status.weather = weather + status.timeOfDay = timeOfDay + + return status + } + + public toOneLiner(): string { + return Object.entries(this).map(([key, value]) => `${key}: ${value}`).join('\n') + } +} diff --git a/services/minecraft/src/libs/mineflayer/ticker.ts b/services/minecraft/src/libs/mineflayer/ticker.ts new file mode 100644 index 00000000..87f3f884 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/ticker.ts @@ -0,0 +1,57 @@ +import EventEmitter from 'eventemitter3' + +export interface TickContext { + delta: number + nextTick: () => Promise +} + +export interface TickEventHandlers { + tick: (ctx: TickContext) => void +} + +export type TickEvents = keyof TickEventHandlers +export type TickEventsHandler = TickEventHandlers[K] + +// This update loop ensures that each update() is called one at a time, even if it takes longer than the interval +export class Ticker extends EventEmitter { + constructor(options?: { interval?: number }) { + super() + const { interval = 300 } = options ?? { interval: 300 } + + let last = Date.now() + + setTimeout(async () => { + while (true) { + const start = Date.now() + const nextTickPromise = new Promise((resolve) => { + // Schedule nextTick resolution for after all callbacks complete + setImmediate(resolve) + }) + + // Run all callbacks without awaiting them + const callbackPromises = this.listeners('tick').map(cb => cb({ + delta: start - last, + nextTick: () => nextTickPromise, + })) + + // Wait for all callbacks to complete or timeout + await Promise.race([ + Promise.all(callbackPromises), + new Promise(resolve => + setTimeout(resolve, interval), + ), + ]) + + const remaining = interval - (Date.now() - start) + if (remaining > 0) + await new Promise(resolve => setTimeout(resolve, remaining)) + + last = start + } + }, interval) + } + + on(event: K, cb: TickEventsHandler) { + return super.on(event, cb) + } +} diff --git a/services/minecraft/src/libs/mineflayer/types.ts b/services/minecraft/src/libs/mineflayer/types.ts new file mode 100644 index 00000000..7f8a3f61 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/types.ts @@ -0,0 +1,23 @@ +import type { CommandContext } from './command' + +export interface Context { + time: number + command?: CommandContext +} + +export interface EventHandlers { + 'interrupt': () => void + 'command': (ctx: Context) => void | Promise + 'time:sunrise': (ctx: Context) => void + 'time:noon': (ctx: Context) => void + 'time:sunset': (ctx: Context) => void + 'time:midnight': (ctx: Context) => void +} + +export type Events = keyof EventHandlers +export type EventsHandler = EventHandlers[K] +export type Handler = (ctx: Context) => void | Promise + +export interface OneLinerable { + toOneLiner: () => string +} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts new file mode 100644 index 00000000..89457b4f --- /dev/null +++ b/services/minecraft/src/main.ts @@ -0,0 +1,52 @@ +import process, { exit } from 'node:process' +import { Client } from '@proj-airi/server-sdk' +import MineflayerArmorManager from 'mineflayer-armor-manager' +import { loader as MineflayerAutoEat } from 'mineflayer-auto-eat' +import { plugin as MineflayerCollectBlock } from 'mineflayer-collectblock' +import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder' +import { plugin as MineflayerPVP } from 'mineflayer-pvp' +import { plugin as MineflayerTool } from 'mineflayer-tool' + +import { initBot } from './composables/bot' +import { config, initEnv } from './composables/config' +import { createNeuriAgent } from './composables/neuri' +import { LLMAgent } from './libs/llm-agent' +import { wrapPlugin } from './libs/mineflayer' +import { initLogger, useLogger } from './utils/logger' + +async function main() { + initLogger() // todo: save logs to file + initEnv() + + const { bot } = await initBot({ + botConfig: config.bot, + plugins: [ + wrapPlugin(MineflayerArmorManager), + wrapPlugin(MineflayerAutoEat), + wrapPlugin(MineflayerCollectBlock), + wrapPlugin(MineflayerPathfinder), + wrapPlugin(MineflayerPVP), + wrapPlugin(MineflayerTool), + ], + }) + + // Connect airi server + const airiClient = new Client({ + name: config.airi.clientName, + url: config.airi.wsBaseUrl, + }) + + // Dynamically load LLMAgent after the bot is initialized + const agent = await createNeuriAgent(bot) + await bot.loadPlugin(LLMAgent({ agent, airiClient })) + + process.on('SIGINT', () => { + bot.stop() + exit(0) + }) +} + +main().catch((err: Error) => { + useLogger().errorWithError('Fatal error', err) + exit(1) +}) diff --git a/services/minecraft/src/plugins/echo.ts b/services/minecraft/src/plugins/echo.ts new file mode 100644 index 00000000..76b8b402 --- /dev/null +++ b/services/minecraft/src/plugins/echo.ts @@ -0,0 +1,23 @@ +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { ChatMessageHandler } from '../libs/mineflayer/message' +import { useLogger } from '../utils/logger' + +export function Echo(): MineflayerPlugin { + const logger = useLogger() + + return { + spawned(mineflayer) { + const onChatHandler = new ChatMessageHandler(mineflayer.username).handleChat((username, message) => { + logger.withFields({ username, message }).log('Chat message received') + mineflayer.bot.chat(message) + }) + + this.beforeCleanup = () => { + mineflayer.bot.removeListener('chat', onChatHandler) + } + + mineflayer.bot.on('chat', onChatHandler) + }, + } +} diff --git a/services/minecraft/src/plugins/follow.ts b/services/minecraft/src/plugins/follow.ts new file mode 100644 index 00000000..8420a7dd --- /dev/null +++ b/services/minecraft/src/plugins/follow.ts @@ -0,0 +1,67 @@ +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import pathfinderModel from 'mineflayer-pathfinder' + +import { useLogger } from '../utils/logger' + +export function FollowCommand(options?: { rangeGoal: number }): MineflayerPlugin { + const logger = useLogger() + const { goals, Movements } = pathfinderModel + + return { + created(bot) { + const state = { + following: undefined as string | undefined, + movements: new Movements(bot.bot), + } + + function startFollow(username: string): void { + state.following = username + logger.withFields({ username }).log('Starting to follow player') + followPlayer() + } + + function stopFollow(): void { + state.following = undefined + logger.log('Stopping follow') + bot.bot.pathfinder.stop() + } + + function followPlayer(): void { + if (!state.following) + return + + const target = bot.bot.players[state.following]?.entity + if (!target) { + bot.bot.chat('I lost sight of you!') + state.following = undefined + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + bot.bot.pathfinder.setMovements(state.movements) + bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) + } + + bot.onCommand('follow', (ctx) => { + const username = ctx.command!.sender + if (!username) { + bot.bot.chat('Please specify a player name!') + return + } + + startFollow(username) + }) + + bot.onCommand('stop', () => { + stopFollow() + }) + + bot.onTick('tick', () => { + if (state.following) + followPlayer() + }) + }, + } +} diff --git a/services/minecraft/src/plugins/pathfinder.ts b/services/minecraft/src/plugins/pathfinder.ts new file mode 100644 index 00000000..725d32c6 --- /dev/null +++ b/services/minecraft/src/plugins/pathfinder.ts @@ -0,0 +1,41 @@ +import type { Context } from '../libs/mineflayer' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import pathfinderModel from 'mineflayer-pathfinder' + +import { useLogger } from '../utils/logger' + +const { goals, Movements } = pathfinderModel + +export function PathFinder(options?: { rangeGoal: number }): MineflayerPlugin { + return { + created(bot) { + const logger = useLogger() + + let defaultMove: any + + const handleCome = (commandCtx: Context) => { + const username = commandCtx.command!.sender + if (!username) { + bot.bot.chat('Please specify a player name!') + return + } + + logger.withFields({ username }).log('Come command received') + const target = bot.bot.players[username]?.entity + if (!target) { + bot.bot.chat('I don\'t see that player!') + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + bot.bot.pathfinder.setMovements(defaultMove) + bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) + } + + defaultMove = new Movements(bot.bot) + bot.onCommand('come', handleCome) + }, + } +} diff --git a/services/minecraft/src/plugins/status.ts b/services/minecraft/src/plugins/status.ts new file mode 100644 index 00000000..4b9cfc99 --- /dev/null +++ b/services/minecraft/src/plugins/status.ts @@ -0,0 +1,18 @@ +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { useLogger } from '../utils/logger' + +export function Status(): MineflayerPlugin { + return { + created(bot) { + const logger = useLogger() + logger.log('Loading status component') + + bot.onCommand('status', () => { + logger.log('Status command received') + const status = bot.status.toOneLiner() + bot.bot.chat(status) + }) + }, + } +} diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts new file mode 100644 index 00000000..c7ff182e --- /dev/null +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -0,0 +1,176 @@ +import type { Block } from 'prismarine-block' +import type { Mineflayer } from '../../libs/mineflayer' + +import pathfinder from 'mineflayer-pathfinder' + +import { useLogger } from '../../utils/logger' +import { breakBlockAt } from '../blocks' +import { getNearestBlocks } from '../world' +import { ensurePickaxe } from './ensure' +import { pickupNearbyItems } from './world-interactions' + +const logger = useLogger() + +function isMessagable(err: unknown): err is { message: string } { + return (err instanceof Error || (typeof err === 'object' && !!err && 'message' in err && typeof err.message === 'string')) +} + +export async function collectBlock( + mineflayer: Mineflayer, + blockType: string, + num = 1, + range = 16, +): Promise { + if (num < 1) { + logger.log(`Invalid number of blocks to collect: ${num}.`) + return false + } + + const blockTypes = [blockType] + + // Add block variants + if ( + [ + 'coal', + 'diamond', + 'emerald', + 'iron', + 'gold', + 'lapis_lazuli', + 'redstone', + 'copper', + ].includes(blockType) + ) { + blockTypes.push(`${blockType}_ore`, `deepslate_${blockType}_ore`) + } + if (blockType.endsWith('ore')) { + blockTypes.push(`deepslate_${blockType}`) + } + if (blockType === 'dirt') { + blockTypes.push('grass_block') + } + + let collected = 0 + + while (collected < num) { + const blocks = getNearestBlocks(mineflayer, blockTypes, range) + + if (blocks.length === 0) { + if (collected === 0) + logger.log(`No ${blockType} nearby to collect.`) + else logger.log(`No more ${blockType} nearby to collect.`) + break + } + + const block = blocks[0] + + try { + // Equip appropriate tool + if (mineflayer.bot.game.gameMode !== 'creative') { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null + if (!block.canHarvest(itemId)) { + logger.log(`Don't have right tools to harvest ${block.name}.`) + if (block.name.includes('ore') || block.name.includes('stone')) { + await ensurePickaxe(mineflayer) + } + throw new Error('Don\'t have right tools to harvest block.') + } + } + + // Implement vein mining + const veinBlocks = findVeinBlocks(mineflayer, block, 100, range, 1) + + for (const veinBlock of veinBlocks) { + if (collected >= num) + break + + // Move to the block using pathfinder + const goal = new pathfinder.goals.GoalGetToBlock( + veinBlock.position.x, + veinBlock.position.y, + veinBlock.position.z, + ) + await mineflayer.bot.pathfinder.goto(goal) + + // Break the block and collect drops + await mineAndCollect(mineflayer, veinBlock) + + collected++ + + // Check if inventory is full + if (mineflayer.bot.inventory.emptySlotCount() === 0) { + logger.log('Inventory is full, cannot collect more items.') + break + } + } + } + catch (err) { + logger.log(`Failed to collect ${blockType}: ${err}.`) + if (isMessagable(err) && err.message.includes('Digging aborted')) { + break + } + + continue + } + } + + logger.log(`Collected ${collected} ${blockType}(s).`) + return collected > 0 +} + +// Helper function to mine a block and collect drops +async function mineAndCollect(mineflayer: Mineflayer, block: Block): Promise { + // Break the block + await breakBlockAt(mineflayer, block.position.x, block.position.y, block.position.z) + // Use your existing function to pick up nearby items + await pickupNearbyItems(mineflayer, 5) +} + +// Function to find connected blocks (vein mining) +function findVeinBlocks( + mineflayer: Mineflayer, + startBlock: Block, + maxBlocks = 100, + maxDistance = 16, + floodRadius = 1, +): Block[] { + const veinBlocks: Block[] = [] + const visited = new Set() + const queue: Block[] = [startBlock] + + while (queue.length > 0 && veinBlocks.length < maxBlocks) { + const block = queue.shift() + if (!block) + continue + const key = block.position.toString() + + if (visited.has(key)) + continue + visited.add(key) + + if (block.name !== startBlock.name) + continue + if (block.position.distanceTo(startBlock.position) > maxDistance) + continue + + veinBlocks.push(block) + + // Check neighboring blocks within floodRadius + for (let dx = -floodRadius; dx <= floodRadius; dx++) { + for (let dy = -floodRadius; dy <= floodRadius; dy++) { + for (let dz = -floodRadius; dz <= floodRadius; dz++) { + if (dx === 0 && dy === 0 && dz === 0) + continue // Skip the current block + const neighborPos = block.position.offset(dx, dy, dz) + const neighborBlock = mineflayer.bot.blockAt(neighborPos) + if (neighborBlock && !visited.has(neighborPos.toString())) { + queue.push(neighborBlock) + } + } + } + } + } + + return veinBlocks +} diff --git a/services/minecraft/src/skills/actions/ensure.ts b/services/minecraft/src/skills/actions/ensure.ts new file mode 100644 index 00000000..82637221 --- /dev/null +++ b/services/minecraft/src/skills/actions/ensure.ts @@ -0,0 +1,500 @@ +import type { Mineflayer } from '../../libs/mineflayer' + +import { useLogger } from '../../utils/logger' +import { getItemId } from '../../utils/mcdata' +import { craftRecipe } from '../crafting' +import { moveAway } from '../movement' +import { collectBlock } from './collect-block' +import { gatherWood } from './gather-wood' +import { getItemCount } from './inventory' + +// Constants for crafting and gathering +const PLANKS_PER_LOG = 4 +const STICKS_PER_PLANK = 2 +const logger = useLogger() + +// Helper function to ensure a crafting table +export async function ensureCraftingTable(mineflayer: Mineflayer): Promise { + logger.log('Bot: Checking for a crafting table...') + + let hasCraftingTable = getItemCount(mineflayer, 'crafting_table') > 0 + + if (hasCraftingTable) { + logger.log('Bot: Crafting table is available.') + return true + } + + while (!hasCraftingTable) { + const planksEnsured = await ensurePlanks(mineflayer, 4) + if (!planksEnsured) { + logger.error('Bot: Failed to ensure planks.') + continue + } + + // Craft crafting table + hasCraftingTable = await craftRecipe(mineflayer, 'crafting_table', 1) + if (hasCraftingTable) { + mineflayer.bot.chat('I have made a crafting table.') + logger.log('Bot: Crafting table crafted.') + } + else { + logger.error('Bot: Failed to craft crafting table.') + } + } + + return hasCraftingTable +} + +// Helper function to ensure a specific amount of planks +export async function ensurePlanks(mineflayer: Mineflayer, neededAmount: number): Promise { + logger.log('Bot: Checking for planks...') + + let planksCount = getItemCount(mineflayer, 'planks') + + if (neededAmount < planksCount) { + logger.log('Bot: Have enough planks.') + return true + } + + while (neededAmount > planksCount) { + const logsNeeded = Math.ceil((neededAmount - planksCount) / PLANKS_PER_LOG) + + // Get all available log types in inventory + const availableLogs = mineflayer.bot.inventory + .items() + .filter(item => item.name.includes('log')) + + // If no logs available, gather more wood + if (availableLogs.length === 0) { + await gatherWood(mineflayer, logsNeeded, 80) + logger.error('Bot: Not enough logs for planks.') + continue + } + + // Iterate over each log type to craft planks + for (const log of availableLogs) { + const logType = log.name.replace('_log', '') // Get log type without "_log" suffix + const logsToCraft = Math.min(log.count, logsNeeded) + + logger.log( + `Trying to make ${logsToCraft * PLANKS_PER_LOG} ${logType}_planks`, + ) + logger.log(`NeededAmount: ${neededAmount}, while I have ${planksCount}`) + + const crafted = await craftRecipe( + mineflayer, + `${logType}_planks`, + logsToCraft * PLANKS_PER_LOG, + ) + if (crafted) { + planksCount = getItemCount(mineflayer, 'planks') + mineflayer.bot.chat( + `I have crafted ${logsToCraft * PLANKS_PER_LOG} ${logType} planks.`, + ) + logger.log(`Bot: ${logType} planks crafted.`) + } + else { + logger.error(`Bot: Failed to craft ${logType} planks.`) + return false + } + + // Check if we have enough planks after crafting + if (planksCount >= neededAmount) + break + } + } + + return planksCount >= neededAmount +}; + +// Helper function to ensure a specific amount of sticks +export async function ensureSticks(mineflayer: Mineflayer, neededAmount: number): Promise { + logger.log('Bot: Checking for sticks...') + + let sticksCount = getItemCount(mineflayer, 'stick') + + if (neededAmount <= sticksCount) { + logger.log('Bot: Have enough sticks.') + return true + } + + while (neededAmount >= sticksCount) { + const planksCount = getItemCount(mineflayer, 'planks') + const planksNeeded = Math.max( + Math.ceil((neededAmount - sticksCount) / STICKS_PER_PLANK), + 4, + ) + + if (planksCount >= planksNeeded) { + try { + const sticksId = getItemId('stick') + const recipe = await mineflayer.bot.recipesFor(sticksId, null, 1, null)[0] + await mineflayer.bot.craft(recipe, neededAmount - sticksCount) + sticksCount = getItemCount(mineflayer, 'stick') + mineflayer.bot.chat(`I have made ${Math.abs(neededAmount - sticksCount)} sticks.`) + logger.log(`Bot: Sticks crafted.`) + } + catch (err) { + logger.withError(err).error('Bot: Failed to craft sticks.') + return false + } + } + else { + await ensurePlanks(mineflayer, planksNeeded) + logger.error('Bot: Not enough planks for sticks.') + } + } + + return sticksCount >= neededAmount +} + +// Ensure a specific number of chests +export async function ensureChests(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} chest(s)...`) + + // Count the number of chests the bot already has + let chestCount = getItemCount(mineflayer, 'chest') + + if (chestCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more chest(s).`) + return true + } + + while (chestCount < quantity) { + const planksEnsured = await ensurePlanks(mineflayer, 8 * quantity) // 8 planks per chest + if (!planksEnsured) { + logger.error('Bot: Failed to ensure planks for chest(s).') + continue + } + + // Craft the chest(s) + const crafted = await craftRecipe(mineflayer, 'chest', quantity - chestCount) + if (crafted) { + chestCount = getItemCount(mineflayer, 'chest') + mineflayer.bot.chat(`I have crafted ${quantity} chest(s).`) + logger.log(`Bot: ${quantity} chest(s) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft chest(s).') + } + } + return chestCount >= quantity +} + +// Ensure a specific number of furnaces +export async function ensureFurnaces(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} furnace(s)...`) + + // Count the number of furnaces the bot already has + let furnaceCount = getItemCount(mineflayer, 'furnace') + + if (furnaceCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more furnace(s).`) + return true + } + + while (furnaceCount < quantity) { + const stoneEnsured = await ensureCobblestone(mineflayer, 8 * (quantity - furnaceCount)) // 8 stone blocks per furnace + if (!stoneEnsured) { + logger.error('Bot: Failed to ensure stone for furnace(s).') + continue + } + + // Craft the furnace(s) + const crafted = await craftRecipe(mineflayer, 'furnace', quantity - furnaceCount) + if (crafted) { + furnaceCount = getItemCount(mineflayer, 'furnace') + mineflayer.bot.chat(`I have crafted ${quantity} furnace(s).`) + logger.log(`Bot: ${quantity} furnace(s) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft furnace(s).') + } + } + return furnaceCount >= quantity +} + +// Ensure a specific number of torches +export async function ensureTorches(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} torch(es)...`) + + // Count the number of torches the bot already has + let torchCount = getItemCount(mineflayer, 'torch') + + if (torchCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more torch(es).`) + return true + } + + while (torchCount < quantity) { + const sticksEnsured = await ensureSticks(mineflayer, quantity - torchCount) // 1 stick per 4 torches + const coalEnsured = await ensureCoal( + mineflayer, + Math.ceil((quantity - torchCount) / 4), + ) // 1 coal per 4 torches + + if (!sticksEnsured || !coalEnsured) { + logger.error('Bot: Failed to ensure sticks or coal for torch(es).') + continue + } + + // Craft the torch(es) + const crafted = await craftRecipe(mineflayer, 'torch', quantity - torchCount) + if (crafted) { + torchCount = getItemCount(mineflayer, 'torch') + mineflayer.bot.chat(`I have crafted ${quantity} torch(es).`) + logger.log(`Bot: ${quantity} torch(es) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft torch(es).') + } + } + return torchCount >= quantity +} + +// Ensure a campfire +// Todo: rework +export async function ensureCampfire(mineflayer: Mineflayer): Promise { + logger.log('Bot: Checking for a campfire...') + + const hasCampfire = getItemCount(mineflayer, 'campfire') > 0 + + if (hasCampfire) { + logger.log('Bot: Campfire is already available.') + return true + } + + const logsEnsured = await ensurePlanks(mineflayer, 3) // Need 3 logs for a campfire + const sticksEnsured = await ensureSticks(mineflayer, 3) // Need 3 sticks for a campfire + const coalEnsured = await ensureCoal(mineflayer, 1) // Need 1 coal or charcoal for a campfire + + if (!logsEnsured || !sticksEnsured || !coalEnsured) { + logger.error('Bot: Failed to ensure resources for campfire.') + } + + const crafted = await craftRecipe(mineflayer, 'campfire', 1) + if (crafted) { + mineflayer.bot.chat('I have crafted a campfire.') + logger.log('Bot: Campfire crafted.') + return true + } + else { + logger.error('Bot: Failed to craft campfire.') + } + + return hasCampfire +} + +// Helper function to gather cobblestone +export async function ensureCobblestone(mineflayer: Mineflayer, requiredCobblestone: number, maxDistance: number = 4): Promise { + let cobblestoneCount = getItemCount(mineflayer, 'cobblestone') + + while (cobblestoneCount < requiredCobblestone) { + logger.log('Bot: Gathering more cobblestone...') + const cobblestoneShortage = requiredCobblestone - cobblestoneCount + + try { + const success = await collectBlock( + mineflayer, + 'stone', + cobblestoneShortage, + maxDistance, + ) + if (!success) { + await moveAway(mineflayer, 30) + continue + } + } + catch (err) { + if (err instanceof Error && err.message.includes('right tools')) { + await ensurePickaxe(mineflayer) + continue + } + else { + logger.withError(err).error('Error collecting cobblestone') + await moveAway(mineflayer, 30) + continue + } + } + + cobblestoneCount = getItemCount(mineflayer, 'cobblestone') + } + + logger.log('Bot: Collected enough cobblestone.') + return true +} + +export async function ensureCoal(mineflayer: Mineflayer, neededAmount: number, maxDistance: number = 4): Promise { + logger.log('Bot: Checking for coal...') + let coalCount = getItemCount(mineflayer, 'coal') + + while (coalCount < neededAmount) { + logger.log('Bot: Gathering more coal...') + const coalShortage = neededAmount - coalCount + + try { + await collectBlock(mineflayer, 'stone', coalShortage, maxDistance) + } + catch (err) { + if (err instanceof Error && err.message.includes('right tools')) { + await ensurePickaxe(mineflayer) + continue + } + else { + logger.withError(err).error('Error collecting cobblestone:') + moveAway(mineflayer, 30) + continue + } + } + + coalCount = getItemCount(mineflayer, 'cobblestone') + } + + logger.log('Bot: Collected enough cobblestone.') + return true +} + +// Define the valid tool types as a union type +type ToolType = 'pickaxe' | 'sword' | 'axe' | 'shovel' | 'hoe' + +// Define the valid materials as a union type +type MaterialType = 'diamond' | 'golden' | 'iron' | 'stone' | 'wooden' + +// Constants for crafting tools +const TOOLS_MATERIALS: MaterialType[] = [ + 'diamond', + 'golden', + 'iron', + 'stone', + 'wooden', +] + +export function materialsForTool(tool: ToolType): number { + switch (tool) { + case 'pickaxe': + case 'axe': + return 3 + case 'sword': + case 'hoe': + return 2 + case 'shovel': + return 1 + default: + return 0 + } +} + +// Helper function to ensure a specific tool, checking from best materials to wood +async function ensureTool(mineflayer: Mineflayer, toolType: ToolType, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} ${toolType}(s)...`) + + const neededMaterials = materialsForTool(toolType) + + // Check how many of the tool the bot currently has + let toolCount = mineflayer.bot.inventory + .items() + .filter(item => item.name.includes(toolType)) + .length + + if (toolCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more ${toolType}(s).`) + return true + } + + while (toolCount < quantity) { + // Iterate over the tool materials from best (diamond) to worst (wooden) + for (const material of TOOLS_MATERIALS) { + const toolRecipe = `${material}_${toolType}` // Craft tool name like diamond_pickaxe, iron_sword + const hasResources = await hasResourcesForTool(mineflayer, material, neededMaterials) + + // Check if we have enough material for the current tool + if (hasResources) { + await ensureCraftingTable(mineflayer) + + const sticksEnsured = await ensureSticks(mineflayer, 2) + + if (!sticksEnsured) { + logger.error( + `Bot: Failed to ensure planks or sticks for wooden ${toolType}.`, + ) + continue + } + + // Craft the tool + const crafted = await craftRecipe(mineflayer, toolRecipe, 1) + if (crafted) { + toolCount++ + mineflayer.bot.chat( + `I have crafted a ${material} ${toolType}. Total ${toolType}(s): ${toolCount}/${quantity}`, + ) + logger.log( + `Bot: ${material} ${toolType} crafted. Total ${toolCount}/${quantity}`, + ) + if (toolCount >= quantity) + return true + } + else { + logger.error(`Bot: Failed to craft ${material} ${toolType}.`) + } + } + else if (material === 'wooden') { + // Crafting planks if we don't have enough resources for wooden tools + logger.log(`Bot: Crafting planks for ${material} ${toolType}...`) + await ensurePlanks(mineflayer, 4) + } + } + } + + return toolCount >= quantity +} + +// Helper function to check if the bot has enough materials to craft a tool of a specific material +export async function hasResourcesForTool( + mineflayer: Mineflayer, + material: MaterialType, + num = 3, // Number of resources needed for most tools +): Promise { + switch (material) { + case 'diamond': + return getItemCount(mineflayer, 'diamond') >= num + case 'golden': + return getItemCount(mineflayer, 'gold_ingot') >= num + case 'iron': + return getItemCount(mineflayer, 'iron_ingot') >= num + case 'stone': + return getItemCount(mineflayer, 'cobblestone') >= num + case 'wooden': + return getItemCount(mineflayer, 'planks') >= num + default: + return false + } +} + +// Helper functions for specific tools: + +// Ensure a pickaxe +export async function ensurePickaxe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'pickaxe', quantity) +}; + +// Ensure a sword +export async function ensureSword(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'sword', quantity) +}; + +// Ensure an axe +export async function ensureAxe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'axe', quantity) +}; + +// Ensure a shovel +export async function ensureShovel(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'shovel', quantity) +}; + +export async function ensureHoe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'hoe', quantity) +}; diff --git a/services/minecraft/src/skills/actions/gather-wood.ts b/services/minecraft/src/skills/actions/gather-wood.ts new file mode 100644 index 00000000..a163ab15 --- /dev/null +++ b/services/minecraft/src/skills/actions/gather-wood.ts @@ -0,0 +1,101 @@ +import type { Mineflayer } from '../../libs/mineflayer' + +import { sleep } from '../../utils/helper' +import { useLogger } from '../../utils/logger' +import { breakBlockAt } from '../blocks' +import { goToPosition, moveAway } from '../movement' +import { getNearestBlocks } from '../world' +import { pickupNearbyItems } from './world-interactions' + +const logger = useLogger() + +/** + * Gather wood blocks nearby to collect logs. + * + * @param mineflayer The mineflayer instance. + * @param num The number of wood logs to gather. + * @param maxDistance The maximum distance to search for wood blocks. + * @returns Whether the wood gathering was successful. + */ +export async function gatherWood( + mineflayer: Mineflayer, + num: number, + maxDistance = 64, +): Promise { + logger.log(`Gathering wood... I need to collect ${num} logs.`) + mineflayer.bot.chat(`Gathering wood... I need to collect ${num} logs.`) + + try { + let logsCount = getLogsCount(mineflayer) + logger.log(`I currently have ${logsCount} logs.`) + + while (logsCount < num) { + // Gather 1 extra log to account for any failures + logger.log(`Looking for wood blocks nearby...`, logsCount, num) + + const woodBlock = mineflayer.bot.findBlock({ + matching: block => block.name.includes('log'), + maxDistance, + }) + + if (!woodBlock) { + logger.log('No wood blocks found nearby.') + await moveAway(mineflayer, 50) + continue + } + + const destinationReached = await goToPosition( + mineflayer, + woodBlock.position.x, + woodBlock.position.y, + woodBlock.position.z, + 2, + ) + + if (!destinationReached) { + logger.log('Unable to reach the wood block.') + continue // Try finding another wood block + } + + const aTree = await getNearestBlocks(mineflayer, woodBlock.name, 4, 4) + if (aTree.length === 0) { + logger.log('No wood blocks found nearby.') + await moveAway(mineflayer, 15) + continue + } + + try { + for (const aLog of aTree) { + await breakBlockAt(mineflayer, aLog.position.x, aLog.position.y, aLog.position.z) + await sleep(1200) // Simulate gathering delay + } + await pickupNearbyItems(mineflayer) + await sleep(2500) + logsCount = getLogsCount(mineflayer) + logger.log(`Collected logs. Total logs now: ${logsCount}.`) + } + catch (digError) { + console.error('Failed to break the wood block:', digError) + continue // Attempt to find and break another wood block + } + } + + logger.log(`Wood gathering complete! Total logs collected: ${logsCount}.`) + return true + } + catch (error) { + console.error('Failed to gather wood:', error) + return false + } +} + +/** + * Helper function to count the number of logs in the inventory. + * @returns The total number of logs. + */ +export function getLogsCount(mineflayer: Mineflayer): number { + return mineflayer.bot.inventory + .items() + .filter(item => item.name.includes('log')) + .reduce((acc, item) => acc + item.count, 0) +} diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts new file mode 100644 index 00000000..f2076037 --- /dev/null +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -0,0 +1,303 @@ +import type { Item } from 'prismarine-item' +import type { Mineflayer } from '../../libs/mineflayer' + +import { useLogger } from '../../utils/logger' +import { goToPlayer, goToPosition } from '../movement' +import { getNearestBlock } from '../world' + +const logger = useLogger() + +/** + * Equip an item from the bot's inventory. + * @param mineflayer The mineflayer instance. + * @param itemName The name of the item to equip. + * @returns Whether the item was successfully equipped. + */ +export async function equip(mineflayer: Mineflayer, itemName: string): Promise { + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`You do not have any ${itemName} to equip.`) + return false + } + let destination: 'hand' | 'head' | 'torso' | 'legs' | 'feet' = 'hand' + if (itemName.includes('leggings')) + destination = 'legs' + else if (itemName.includes('boots')) + destination = 'feet' + else if (itemName.includes('helmet')) + destination = 'head' + else if (itemName.includes('chestplate')) + destination = 'torso' + + await mineflayer.bot.equip(item, destination) + return true +} + +/** + * Discard an item from the bot's inventory. + * @param mineflayer The mineflayer instance. + * @param itemName The name of the item to discard. + * @param num The number of items to discard. Default is -1 for all. + * @returns Whether the item was successfully discarded. + */ +export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + let discarded = 0 + while (true) { + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + break + } + const toDiscard + = num === -1 ? item.count : Math.min(num - discarded, item.count) + await mineflayer.bot.toss(item.type, null, toDiscard) + discarded += toDiscard + if (num !== -1 && discarded >= num) { + break + } + } + if (discarded === 0) { + logger.log(`You do not have any ${itemName} to discard.`) + return false + } + logger.log(`Successfully discarded ${discarded} ${itemName}.`) + return true +} + +export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`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(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + await chestContainer.deposit(item.type, null, toPut) + await chestContainer.close() + logger.log(`Successfully put ${toPut} ${itemName} in the chest.`) + return true +} + +export async function takeFromChest( + mineflayer: Mineflayer, + itemName: string, + num = -1, +): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + const item = chestContainer + .containerItems() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`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() + logger.log(`Successfully took ${toTake} ${itemName} from the chest.`) + return true +} + +/** + * View the contents of a chest near the bot. + * @param mineflayer The mineflayer instance. + * @returns Whether the chest was successfully viewed. + */ +export async function viewChest(mineflayer: Mineflayer): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + const items = chestContainer.containerItems() + if (items.length === 0) { + logger.log(`The chest is empty.`) + } + else { + logger.log(`The chest contains:`) + for (const item of items) { + logger.log(`${item.count} ${item.name}`) + } + } + await chestContainer.close() + return true +} + +/** + * Ask to bot to eat a food item from its inventory. + * @param mineflayer The mineflayer instance. + * @param foodName The name of the food item to eat. + * @returns Whether the food was successfully eaten. + */ +export async function eat(mineflayer: Mineflayer, foodName = ''): Promise { + let item: Item | undefined + let name: string + if (foodName) { + item = mineflayer.bot.inventory.items().find(item => item.name.includes(foodName)) + name = foodName + } + else { + // @ts-expect-error -- ? + item = mineflayer.bot.inventory.items().find(item => item.foodPoints > 0) + name = 'food' + } + if (!item) { + logger.log(`You do not have any ${name} to eat.`) + return false + } + await mineflayer.bot.equip(item, 'hand') + await mineflayer.bot.consume() + logger.log(`Successfully ate ${item.name}.`) + return true +} + +/** + * Give an item to a player. + * @param mineflayer The mineflayer instance. + * @param itemType The name of the item to give. + * @param username The username of the player to give the item to. + * @param num The number of items to give. + * @returns Whether the item was successfully given. + */ +export async function giveToPlayer( + mineflayer: Mineflayer, + itemType: string, + username: string, + num = 1, +): Promise { + const player = mineflayer.bot.players[username]?.entity + if (!player) { + logger.log(`Could not find a player with username: ${username}.`) + return false + } + await goToPlayer(mineflayer, username) + await mineflayer.bot.lookAt(player.position) + await discard(mineflayer, itemType, num) + return true +} + +/** + * List the items in the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns An array of items in the bot's inventory. + */ +export async function listInventory(mineflayer: Mineflayer): Promise<{ name: string, count: number }[]> { + const items = await mineflayer.bot.inventory.items() + // sayItems(mineflayer, items) + + return items.map(item => ({ + name: item.name, + count: item.count, + })) +} + +export async function checkForItem(mineflayer: Mineflayer, itemName: string): Promise { + const items = await mineflayer.bot.inventory.items() + const searchableItems = items.filter(item => item.name.includes(itemName)) + sayItems(mineflayer, searchableItems) +} + +export async function sayItems(mineflayer: Mineflayer, items: Array | null = null) { + if (!items) { + items = mineflayer.bot.inventory.items() + if (mineflayer.bot.registry.isNewerOrEqualTo('1.9') && mineflayer.bot.inventory.slots[45]) + items.push(mineflayer.bot.inventory.slots[45]) + } + const output = items.map(item => `${item.name} x ${item.count}`).join(', ') + if (output) { + mineflayer.bot.chat(`My inventory contains: ${output}`) + } + else { + mineflayer.bot.chat('My inventory is empty.') + } +} + +/** + * Find the number of free slots in the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns The number of free slots in the bot's inventory. + */ +export function checkFreeSpace(mineflayer: Mineflayer): number { + const totalSlots = mineflayer.bot.inventory.slots.length + const usedSlots = mineflayer.bot.inventory.items().length + const freeSlots = totalSlots - usedSlots + logger.log(`You have ${freeSlots} free slots in your inventory.`) + return freeSlots +} + +/** + * Transfer all items from the bot's inventory to a chest. + * @param mineflayer The mineflayer instance. + * @returns Whether the items were successfully transferred. + */ +export async function transferAllToChest(mineflayer: Mineflayer): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + + for (const item of mineflayer.bot.inventory.items()) { + await chestContainer.deposit(item.type, null, item.count) + logger.log(`Put ${item.count} ${item.name} in the chest.`) + } + + await chestContainer.close() + return true +} + +/** + * Utility function to get item count in inventory + * @param mineflayer The mineflayer instance. + * @param itemName - The name of the item to count. + * @returns number of items in inventory + */ +export function getItemCount(mineflayer: Mineflayer, itemName: string): number { + return mineflayer.bot.inventory + .items() + .filter(item => item.name.includes(itemName)) + .reduce((acc, item) => acc + item.count, 0) +} + +/** + * Organize the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns Whether the inventory was successfully organized. + */ +export async function organizeInventory(mineflayer: Mineflayer): Promise { + const items = mineflayer.bot.inventory.items() + if (items.length === 0) { + logger.log(`Inventory is empty, nothing to organize.`) + return + } + + for (const item of items) { + await mineflayer.bot.moveSlotItem( + item.slot, + mineflayer.bot.inventory.findInventoryItem(item.type, null, false)?.slot ?? item.slot, + ) + } + logger.log(`Inventory has been organized.`) +} diff --git a/services/minecraft/src/skills/actions/world-interactions.ts b/services/minecraft/src/skills/actions/world-interactions.ts new file mode 100644 index 00000000..0e594635 --- /dev/null +++ b/services/minecraft/src/skills/actions/world-interactions.ts @@ -0,0 +1,374 @@ +import type { Bot } from 'mineflayer' +import type { Block } from 'prismarine-block' +import type { Mineflayer } from '../../libs/mineflayer' + +import pathfinder from 'mineflayer-pathfinder' +import { Vec3 } from 'vec3' + +import { sleep } from '../../utils/helper' +import { useLogger } from '../../utils/logger' +import { getNearestBlock, makeItem } from '../../utils/mcdata' +import { goToPosition } from '../movement' + +const logger = useLogger() + +export async function placeBlock( + mineflayer: Mineflayer, + blockType: string, + x: number, + y: number, + z: number, + placeOn: string = 'bottom', +): Promise { + // if (!gameData.getBlockId(blockType)) { + // logger.log(`Invalid block type: ${blockType}.`); + // return false; + // } + + const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + + let block = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(blockType)) + if (!block && mineflayer.bot.game.gameMode === 'creative') { + // TODO: Rework + await mineflayer.bot.creative.setInventorySlot(36, makeItem(blockType, 1)) // 36 is first hotbar slot + block = mineflayer.bot.inventory.items().find(item => item.name.includes(blockType)) + } + if (!block) { + logger.log(`Don't have any ${blockType} to place.`) + return false + } + + const targetBlock = mineflayer.bot.blockAt(targetDest) + if (!targetBlock) { + logger.log(`No block found at ${targetDest}.`) + return false + } + + if (targetBlock.name === blockType) { + logger.log(`${blockType} already at ${targetBlock.position}.`) + return false + } + + const emptyBlocks = [ + 'air', + 'water', + 'lava', + 'grass', + 'tall_grass', + 'snow', + 'dead_bush', + 'fern', + ] + if (!emptyBlocks.includes(targetBlock.name)) { + logger.log( + `${targetBlock.name} is in the way at ${targetBlock.position}.`, + ) + const removed = await breakBlockAt(mineflayer, x, y, z) + if (!removed) { + logger.log( + `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`, + ) + return false + } + await new Promise(resolve => setTimeout(resolve, 200)) // Wait for block to break + } + + // Determine the build-off block and face vector + const dirMap: { [key: string]: Vec3 } = { + 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: Vec3[] = [] + 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) + logger.log(`Unknown placeOn value "${placeOn}". Defaulting to bottom.`) + } + + // Add remaining directions + dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) + + let buildOffBlock: Block | null = null + let faceVec: Vec3 | null = null + + for (const d of dirs) { + const adjacentBlock = mineflayer.bot.blockAt(targetDest.plus(d)) + if (adjacentBlock && !emptyBlocks.includes(adjacentBlock.name)) { + buildOffBlock = adjacentBlock + faceVec = d.scaled(-1) // Invert direction + break + } + } + + if (!buildOffBlock || !faceVec) { + logger.log( + `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`, + ) + return false + } + + // Move away if too close + const pos = mineflayer.bot.entity.position + const posAbove = pos.offset(0, 1, 0) + const dontMoveFor = [ + 'torch', + 'redstone_torch', + 'redstone', + '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 = new pathfinder.goals.GoalInvert( + new pathfinder.goals.GoalNear( + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 2, + ), + ) + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto(goal) + } + + // Move closer if too far + if (mineflayer.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + await goToPosition( + mineflayer, + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 4, + ) + } + + await mineflayer.bot.equip(block, 'hand') + await mineflayer.bot.lookAt(buildOffBlock.position) + await sleep(500) + + try { + await mineflayer.bot.placeBlock(buildOffBlock, faceVec) + logger.log(`Placed ${blockType} at ${targetDest}.`) + await new Promise(resolve => setTimeout(resolve, 200)) + return true + } + catch (err) { + if (err instanceof Error) { + logger.log( + `Failed to place ${blockType} at ${targetDest}: ${err.message}`, + ) + } + else { + logger.log( + `Failed to place ${blockType} at ${targetDest}: ${String(err)}`, + ) + } + return false + } +} + +export async function breakBlockAt( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, +): Promise { + if (x == null || y == null || z == null) { + throw new Error('Invalid position to break block at.') + } + const blockPos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + const block = mineflayer.bot.blockAt(blockPos) + if (!block) { + logger.log(`No block found at position ${blockPos}.`) + return false + } + if (block.name !== 'air' && block.name !== 'water' && block.name !== 'lava') { + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(mineflayer, x, y, z) + } + if (mineflayer.bot.game.gameMode !== 'creative') { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null + if (!block.canHarvest(itemId)) { + logger.log(`Don't have right tools to break ${block.name}.`) + return false + } + } + if (!mineflayer.bot.canDigBlock(block)) { + logger.log(`Cannot break ${block.name} at ${blockPos}.`) + return false + } + await mineflayer.bot.lookAt(block.position, true) // Ensure the bot has finished turning + await sleep(500) + try { + await mineflayer.bot.dig(block, true) + logger.log( + `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed( + 1, + )}, z:${z.toFixed(1)}.`, + ) + return true + } + catch (err) { + console.error(`Failed to dig the block: ${err}`) + return false + } + } + else { + logger.log( + `Skipping block at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed( + 1, + )} because it is ${block.name}.`, + ) + return false + } +} + +export async function activateNearestBlock(mineflayer: Mineflayer, type: string) { + /** + * Activate the nearest block of the given type. + * @param {string} type, the type of block to activate. + * @returns {Promise} true if the block was activated, false otherwise. + * @example + * await skills.activateNearestBlock( "lever"); + * + */ + const block = getNearestBlock(mineflayer.bot, type, 16) + if (!block) { + logger.log(`Could not find any ${type} to activate.`) + return false + } + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + const pos = block.position + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto(new pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + await mineflayer.bot.activateBlock(block) + logger.log( + `Activated ${type} at x:${block.position.x.toFixed( + 1, + )}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`, + ) + return true +} + +export async function tillAndSow( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, + seedType: string | null = null, +): Promise { + x = Math.round(x) + y = Math.round(y) + z = Math.round(z) + const blockPos = new Vec3(x, y, z) + const block = mineflayer.bot.blockAt(blockPos) + if (!block) { + logger.log(`No block found at ${blockPos}.`) + return false + } + if ( + block.name !== 'grass_block' + && block.name !== 'dirt' + && block.name !== 'farmland' + ) { + logger.log(`Cannot till ${block.name}, must be grass_block or dirt.`) + return false + } + const above = mineflayer.bot.blockAt(blockPos.offset(0, 1, 0)) + if (above && above.name !== 'air') { + logger.log(`Cannot till, there is ${above.name} above the block.`) + return false + } + // Move closer if too far + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(mineflayer, x, y, z, 4) + } + if (block.name !== 'farmland') { + const hoe = mineflayer.bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + logger.log(`Cannot till, no hoes.`) + return false + } + await mineflayer.bot.equip(hoe, 'hand') + await mineflayer.bot.activateBlock(block) + logger.log( + `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 + const seeds = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(seedType || 'seed')) + if (!seeds) { + logger.log(`No ${seedType} to plant.`) + return false + } + await mineflayer.bot.equip(seeds, 'hand') + await mineflayer.bot.placeBlock(block, new Vec3(0, -1, 0)) + logger.log( + `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed( + 1, + )}, z:${z.toFixed(1)}.`, + ) + } + return true +} + +export async function pickupNearbyItems( + mineflayer: Mineflayer, + distance = 8, +): Promise { + const getNearestItem = (bot: Bot) => + bot.nearestEntity( + entity => + entity.name === 'item' + && entity.onGround + && bot.entity.position.distanceTo(entity.position) < distance, + ) + let nearestItem = getNearestItem(mineflayer.bot) + + let pickedUp = 0 + while (nearestItem) { + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto( + new pathfinder.goals.GoalFollow(nearestItem, 0.8), + () => {}, + ) + await sleep(500) + const prev = nearestItem + nearestItem = getNearestItem(mineflayer.bot) + if (prev === nearestItem) { + break + } + pickedUp++ + } + logger.log(`Picked up ${pickedUp} items.`) + return true +} diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts new file mode 100644 index 00000000..cda7ad75 --- /dev/null +++ b/services/minecraft/src/skills/base.ts @@ -0,0 +1,27 @@ +import type { Mineflayer } from '../libs/mineflayer' + +import { useLogger } from '../utils/logger' + +const logger = useLogger() + +/** + * Log a message to the context's output buffer + */ +export function log(mineflayer: Mineflayer, message: string): void { + logger.log(message) + mineflayer.bot.chat(message) +} + +/** + * Position in the world + */ +export interface Position { + x: number + y: number + z: number +} + +/** + * 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 00000000..35a928c6 --- /dev/null +++ b/services/minecraft/src/skills/blocks.ts @@ -0,0 +1,675 @@ +import type { SafeBlock } from 'mineflayer-pathfinder' +import type { Mineflayer } from '../libs/mineflayer' +import type { BlockFace } from './base' + +import pathfinderModel from 'mineflayer-pathfinder' +import { Vec3 } from 'vec3' + +import { getBlockId, makeItem } from '../utils/mcdata' +import { log } from './base' +import { goToPosition } from './movement' +import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world' + +const { goals, Movements } = pathfinderModel + +/** + * Place a torch if needed + */ +async function autoLight(mineflayer: Mineflayer): Promise { + if (shouldPlaceTorch(mineflayer)) { + try { + const pos = getPosition(mineflayer) + return await placeBlock(mineflayer, '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( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, +): Promise { + validatePosition(x, y, z) + + const block = mineflayer.bot.blockAt(new Vec3(x, y, z)) + if (isUnbreakableBlock(block)) + return false + + if (mineflayer.allowCheats) { + return breakWithCheats(mineflayer, x, y, z) + } + + await moveIntoRange(mineflayer, block) + + if (mineflayer.isCreative) { + return breakInCreative(mineflayer, block, x, y, z) + } + + return breakInSurvival(mineflayer, block, x, y, z) +} + +function validatePosition(x: number, y: number, z: number) { + if (x == null || y == null || z == null) { + throw new Error('Invalid position to break block at.') + } +} + +function isUnbreakableBlock(block: any): boolean { + return block.name === 'air' || block.name === 'water' || block.name === 'lava' +} + +async function breakWithCheats(mineflayer: Mineflayer, x: number, y: number, z: number): Promise { + mineflayer.bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + log(mineflayer, `Used /setblock to break block at ${x}, ${y}, ${z}.`) + return true +} + +async function moveIntoRange(mineflayer: Mineflayer, block: any) { + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + const pos = block.position + const movements = new Movements(mineflayer.bot) + movements.allowParkour = false + movements.allowSprinting = false + mineflayer.bot.pathfinder.setMovements(movements) + await mineflayer.bot.pathfinder.goto(new goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } +} + +async function breakInCreative(mineflayer: Mineflayer, block: any, x: number, y: number, z: number): Promise { + await mineflayer.bot.dig(block, true) + log(mineflayer, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + return true +} + +async function breakInSurvival(mineflayer: Mineflayer, block: any, x: number, y: number, z: number): Promise { + await mineflayer.bot.tool.equipForBlock(block) + + const itemId = mineflayer.bot.heldItem?.type + if (!block.canHarvest(itemId)) { + log(mineflayer, `Don't have right tools to break ${block.name}.`) + return false + } + + await mineflayer.bot.dig(block, true) + log(mineflayer, `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( + mineflayer: Mineflayer, + blockType: string, + x: number, + y: number, + z: number, + placeOn: BlockFace = 'bottom', + dontCheat = false, +): Promise { + if (!getBlockId(blockType)) { + log(mineflayer, `Invalid block type: ${blockType}.`) + return false + } + + const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + + if (mineflayer.allowCheats && !dontCheat) { + return placeWithCheats(mineflayer, blockType, targetDest, placeOn) + } + + return placeWithoutCheats(mineflayer, blockType, targetDest, placeOn) +} + +function getBlockState(blockType: string, placeOn: BlockFace): string { + const face = getInvertedFace(placeOn as 'north' | 'south' | 'east' | 'west') + let blockState = blockType + + if (blockType.includes('torch') && placeOn !== 'bottom') { + blockState = handleTorchState(blockType, placeOn, face) + } + + if (blockType.includes('button') || blockType === 'lever') { + blockState = handleButtonLeverState(blockState, placeOn, face) + } + + if (needsFacingState(blockType)) { + blockState += `[facing=${face}]` + } + + return blockState +} + +function getInvertedFace(placeOn: BlockFace): string { + const faceMap: Record = { + north: 'south', + south: 'north', + east: 'west', + west: 'east', + } + + return faceMap[placeOn] || placeOn +} + +function handleTorchState(blockType: string, placeOn: BlockFace, face: string): string { + let state = blockType.replace('torch', 'wall_torch') + if (placeOn !== 'side' && placeOn !== 'top') { + state += `[facing=${face}]` + } + return state +} + +function handleButtonLeverState(blockState: string, placeOn: BlockFace, face: string): string { + if (placeOn === 'top') { + return `${blockState}[face=ceiling]` + } + if (placeOn === 'bottom') { + return `${blockState}[face=floor]` + } + return `${blockState}[facing=${face}]` +} + +function needsFacingState(blockType: string): boolean { + return blockType === 'ladder' + || blockType === 'repeater' + || blockType === 'comparator' + || blockType.includes('stairs') +} + +async function placeWithCheats( + mineflayer: Mineflayer, + blockType: string, + targetDest: Vec3, + placeOn: BlockFace, +): Promise { + const blockState = getBlockState(blockType, placeOn) + + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) + + if (blockType.includes('door')) { + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) + } + + if (blockType.includes('bed')) { + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) + } + + log(mineflayer, `Used /setblock to place ${blockType} at ${targetDest}.`) + return true +} + +async function placeWithoutCheats( + mineflayer: Mineflayer, + blockType: string, + targetDest: Vec3, + placeOn: BlockFace, +): Promise { + const itemName = blockType === 'redstone_wire' ? 'redstone' : blockType + + let block = mineflayer.bot.inventory.items().find(item => item.name === itemName) + if (!block && mineflayer.isCreative) { + await mineflayer.bot.creative.setInventorySlot(36, makeItem(itemName, 1)) + block = mineflayer.bot.inventory.items().find(item => item.name === itemName) + } + + if (!block) { + log(mineflayer, `Don't have any ${blockType} to place.`) + return false + } + + const targetBlock = mineflayer.bot.blockAt(targetDest) + if (targetBlock?.name === blockType) { + log(mineflayer, `${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 ?? '')) { + if (!await clearBlockSpace(mineflayer, targetBlock, blockType)) { + return false + } + } + + const { buildOffBlock, faceVec } = findPlacementSpot(mineflayer, targetDest, placeOn, emptyBlocks) + if (!buildOffBlock) { + log(mineflayer, `Cannot place ${blockType} at ${targetBlock?.position}: nothing to place on.`) + return false + } + + if (!faceVec) { + log(mineflayer, `Cannot place ${blockType} at ${targetBlock?.position}: no valid face to place on.`) + return false + } + + await moveIntoPosition(mineflayer, blockType, targetBlock) + return await tryPlaceBlock(mineflayer, block, buildOffBlock, faceVec, blockType, targetDest) +} + +async function clearBlockSpace( + mineflayer: Mineflayer, + targetBlock: any, + blockType: string, +): Promise { + const removed = await breakBlockAt(mineflayer, targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, + ) + if (!removed) { + log(mineflayer, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) + return false + } + await new Promise(resolve => setTimeout(resolve, 200)) + return true +} + +function findPlacementSpot(mineflayer: Mineflayer, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { + 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 = getPlacementDirections(placeOn, dirMap) + + for (const d of dirs) { + const block = mineflayer.bot.blockAt(targetDest.plus(d)) + if (!emptyBlocks.includes(block?.name ?? '')) { + return { + buildOffBlock: block, + faceVec: new Vec3(-d.x, -d.y, -d.z), + } + } + } + + return { buildOffBlock: null, faceVec: null } +} + +function getPlacementDirections(placeOn: BlockFace, dirMap: Record): Vec3[] { + const directions: Vec3[] = [] + if (placeOn === 'side') { + directions.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) + } + else if (dirMap[placeOn]) { + directions.push(dirMap[placeOn]) + } + else { + directions.push(dirMap.bottom) + } + + directions.push(...Object.values(dirMap).filter(d => !directions.includes(d))) + return directions +} + +async function moveIntoPosition(mineflayer: Mineflayer, blockType: string, targetBlock: any) { + const dontMoveFor = [ + 'torch', + 'redstone_torch', + 'redstone_wire', + 'lever', + 'button', + 'rail', + 'detector_rail', + 'powered_rail', + 'activator_rail', + 'tripwire_hook', + 'tripwire', + 'water_bucket', + ] + + const pos = mineflayer.bot.entity.position + const posAbove = pos.plus(new Vec3(0, 1, 0)) + + if (!dontMoveFor.includes(blockType) + && (pos.distanceTo(targetBlock.position) < 1 + || posAbove.distanceTo(targetBlock.position) < 1)) { + await moveAwayFromBlock(mineflayer, targetBlock) + } + + if (mineflayer.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + await moveToBlock(mineflayer, targetBlock) + } +} + +async function moveAwayFromBlock(mineflayer: Mineflayer, targetBlock: any) { + const goal = new goals.GoalNear( + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 2, + ) + const invertedGoal = new goals.GoalInvert(goal) + mineflayer.bot.pathfinder.setMovements(new Movements(mineflayer.bot)) + await mineflayer.bot.pathfinder.goto(invertedGoal) +} + +async function moveToBlock(mineflayer: Mineflayer, targetBlock: any) { + const pos = targetBlock.position + const movements = new Movements(mineflayer.bot) + mineflayer.bot.pathfinder.setMovements(movements) + await mineflayer.bot.pathfinder.goto( + new goals.GoalNear(pos.x, pos.y, pos.z, 4), + ) +} + +async function tryPlaceBlock( + mineflayer: Mineflayer, + block: any, + buildOffBlock: any, + faceVec: Vec3, + blockType: string, + targetDest: Vec3, +): Promise { + await mineflayer.bot.equip(block, 'hand') + await mineflayer.bot.lookAt(buildOffBlock.position) + + try { + await mineflayer.bot.placeBlock(buildOffBlock, faceVec) + log(mineflayer, `Placed ${blockType} at ${targetDest}.`) + await new Promise(resolve => setTimeout(resolve, 200)) + return true + } + catch { + log(mineflayer, `Failed to place ${blockType} at ${targetDest}.`) + return false + } +} + +/** + * Use a door at the specified position + */ +export async function useDoor(mineflayer: Mineflayer, doorPos: Vec3 | null = null): Promise { + doorPos = doorPos || await findNearestDoor(mineflayer.bot) + + if (!doorPos) { + log(mineflayer, 'Could not find a door to use.') + return false + } + + await goToPosition(mineflayer, doorPos.x, doorPos.y, doorPos.z, 1) + while (mineflayer.bot.pathfinder.isMoving()) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + return await operateDoor(mineflayer, doorPos) +} + +async function findNearestDoor(bot: any): Promise { + 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 = getNearestBlock(bot, doorType, 16) + if (block) { + return block.position + } + } + return null +} + +async function operateDoor(mineflayer: Mineflayer, doorPos: Vec3): Promise { + const doorBlock = mineflayer.bot.blockAt(doorPos) + await mineflayer.bot.lookAt(doorPos) + + if (!doorBlock) { + log(mineflayer, `Cannot find door at ${doorPos}.`) + return false + } + + if (!doorBlock.getProperties().open) { + await mineflayer.bot.activateBlock(doorBlock) + } + + mineflayer.bot.setControlState('forward', true) + await new Promise(resolve => setTimeout(resolve, 600)) + mineflayer.bot.setControlState('forward', false) + await mineflayer.bot.activateBlock(doorBlock) + + mineflayer.bot.setControlState('forward', true) + await new Promise(resolve => setTimeout(resolve, 600)) + mineflayer.bot.setControlState('forward', false) + await mineflayer.bot.activateBlock(doorBlock) + + log(mineflayer, `Used door at ${doorPos}.`) + return true +} + +export async function tillAndSow( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, + seedType: string | null = null, +): Promise { + const pos = { x: Math.round(x), y: Math.round(y), z: Math.round(z) } + + const block = mineflayer.bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) + + if (!block) { + log(mineflayer, `Cannot till, no block at ${pos}.`) + return false + } + + if (!canTillBlock(block)) { + log(mineflayer, `Cannot till ${block.name}, must be grass_block or dirt.`) + return false + } + + const above = mineflayer.bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) + + if (!above) { + log(mineflayer, `Cannot till, no block above the block.`) + return false + } + + if (!isBlockClear(above)) { + log(mineflayer, `Cannot till, there is ${above.name} above the block.`) + return false + } + + await moveIntoRange(mineflayer, block) + + if (!await tillBlock(mineflayer, block, pos)) { + return false + } + + if (seedType) { + return await sowSeeds(mineflayer, block, seedType, pos) + } + + return true +} + +function canTillBlock(block: any): boolean { + return block.name === 'grass_block' || block.name === 'dirt' || block.name === 'farmland' +} + +function isBlockClear(block: any): boolean { + return block.name === 'air' +} + +async function tillBlock(mineflayer: Mineflayer, block: any, pos: any): Promise { + if (block.name === 'farmland') { + return true + } + + const hoe = mineflayer.bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + log(mineflayer, 'Cannot till, no hoes.') + return false + } + + await mineflayer.bot.equip(hoe, 'hand') + await mineflayer.bot.activateBlock(block) + log(mineflayer, `Tilled block x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + return true +} + +async function sowSeeds(mineflayer: Mineflayer, block: any, seedType: string, pos: any): Promise { + seedType = fixSeedName(seedType) + + const seeds = mineflayer.bot.inventory.items().find(item => item.name === seedType) + if (!seeds) { + log(mineflayer, `No ${seedType} to plant.`) + return false + } + + await mineflayer.bot.equip(seeds, 'hand') + await mineflayer.bot.placeBlock(block, new Vec3(0, -1, 0)) + log(mineflayer, `Planted ${seedType} at x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + return true +} + +function fixSeedName(seedType: string): string { + if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) { + return `${seedType}s` // Fix common mistake + } + return seedType +} + +export async function activateNearestBlock(mineflayer: Mineflayer, type: string): Promise { + const block = getNearestBlock(mineflayer, type, 16) + if (!block) { + log(mineflayer, `Could not find any ${type} to activate.`) + return false + } + + await moveIntoRange(mineflayer, block) + await mineflayer.bot.activateBlock(block) + log(mineflayer, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) + return true +} + +export async function collectBlock( + mineflayer: Mineflayer, + blockType: string, + num: number = 1, + exclude: Vec3[] | null = null, +): Promise { + if (num < 1) { + log(mineflayer, `Invalid number of blocks to collect: ${num}.`) + return false + } + + const blocktypes = getBlockTypes(blockType) + let collected = 0 + + mineflayer.once('interrupt', () => { + collected = -1 + }) + + for (let i = 0; i < num && collected >= 0; i++) { + const blocks = getValidBlocks(mineflayer, blocktypes, exclude) + + if (blocks.length === 0) { + logNoBlocksMessage(mineflayer, blockType, collected) + break + } + + const block = blocks[0] + if (!await canHarvestBlock(mineflayer, block, blockType)) { + return false + } + + if (!await tryCollectBlock(mineflayer, block, blockType)) { + break + } + + collected++ + } + + if (collected < 0) { + log(mineflayer, 'Collection interrupted.') + return false + } + + log(mineflayer, `Collected ${collected} ${blockType}.`) + return collected > 0 +} + +function getBlockTypes(blockType: string): string[] { + const blocktypes: string[] = [blockType] + + const ores = ['coal', 'diamond', 'emerald', 'iron', 'gold', 'lapis_lazuli', 'redstone'] + if (ores.includes(blockType)) { + blocktypes.push(`${blockType}_ore`) + } + if (blockType.endsWith('ore')) { + blocktypes.push(`deepslate_${blockType}`) + } + if (blockType === 'dirt') { + blocktypes.push('grass_block') + } + + return blocktypes +} + +function getValidBlocks(mineflayer: Mineflayer, blocktypes: string[], exclude: Vec3[] | null): any[] { + let blocks = getNearestBlocks(mineflayer, blocktypes, 64) + + if (exclude) { + blocks = blocks.filter( + block => !exclude.some(pos => + pos.x === block.position.x + && pos.y === block.position.y + && pos.z === block.position.z, + ), + ) + } + + const movements = new Movements(mineflayer.bot) + movements.dontMineUnderFallingBlock = false + return blocks.filter(block => movements.safeToBreak(block as SafeBlock)) +} + +function logNoBlocksMessage(mineflayer: Mineflayer, blockType: string, collected: number): void { + log(mineflayer, collected === 0 + ? `No ${blockType} nearby to collect.` + : `No more ${blockType} nearby to collect.`) +} + +async function canHarvestBlock(mineflayer: Mineflayer, block: any, blockType: string): Promise { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null + + if (!block.canHarvest(itemId)) { + log(mineflayer, `Don't have right tools to harvest ${blockType}.`) + return false + } + return true +} + +async function tryCollectBlock(mineflayer: Mineflayer, block: any, blockType: string): Promise { + try { + await mineflayer.bot.collectBlock.collect(block) + await autoLight(mineflayer) + return true + } + catch (err) { + if (err instanceof Error && err.name === 'NoChests') { + log(mineflayer, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) + return false + } + log(mineflayer, `Failed to collect ${blockType}: ${err}.`) + return true + } +} diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts new file mode 100644 index 00000000..ff98a0be --- /dev/null +++ b/services/minecraft/src/skills/combat.ts @@ -0,0 +1,135 @@ +import type { Entity } from 'prismarine-entity' +import type { Item } from 'prismarine-item' +import type { Mineflayer } from '../libs/mineflayer' + +import pathfinderModel from 'mineflayer-pathfinder' + +import { sleep } from '../utils/helper' +import { isHostile } from '../utils/mcdata' +import { log } from './base' +import { getNearbyEntities, getNearestEntityWhere } from './world' + +const { goals } = pathfinderModel + +interface WeaponItem extends Item { + attackDamage: number +} + +async function equipHighestAttack(mineflayer: Mineflayer): Promise { + const weapons = mineflayer.bot.inventory.items().filter(item => + item.name.includes('sword') + || (item.name.includes('axe') && !item.name.includes('pickaxe')), + ) as WeaponItem[] + + if (weapons.length === 0) { + const tools = mineflayer.bot.inventory.items().filter(item => + item.name.includes('pickaxe') + || item.name.includes('shovel'), + ) as WeaponItem[] + + if (tools.length === 0) + return + + tools.sort((a, b) => b.attackDamage - a.attackDamage) + const tool = tools[0] + if (tool) + await mineflayer.bot.equip(tool, 'hand') + return + } + + weapons.sort((a, b) => b.attackDamage - a.attackDamage) + const weapon = weapons[0] + if (weapon) + await mineflayer.bot.equip(weapon, 'hand') +} + +export async function attackNearest( + mineflayer: Mineflayer, + mobType: string, + kill = true, +): Promise { + const mob = getNearbyEntities(mineflayer, 24).find(entity => entity.name === mobType) + + if (mob) { + return await attackEntity(mineflayer, mob, kill) + } + + log(mineflayer, `Could not find any ${mobType} to attack.`) + return false +} + +export async function attackEntity( + mineflayer: Mineflayer, + entity: Entity, + kill = true, +): Promise { + const pos = entity.position + await equipHighestAttack(mineflayer) + + if (!kill) { + if (mineflayer.bot.entity.position.distanceTo(pos) > 5) { + const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4) + await mineflayer.bot.pathfinder.goto(goal) + } + await mineflayer.bot.attack(entity) + return true + } + + mineflayer.once('interrupt', () => { + mineflayer.bot.pvp.stop() + }) + + mineflayer.bot.pvp.attack(entity) + while (getNearbyEntities(mineflayer, 24).includes(entity)) { + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + log(mineflayer, `Successfully killed ${entity.name}.`) + return true +} + +export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise { + let attacked = false + let enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) + + while (enemy) { + await equipHighestAttack(mineflayer) + + if (mineflayer.bot.entity.position.distanceTo(enemy.position) >= 4 + && enemy.name !== 'creeper' && enemy.name !== 'phantom') { + try { + const goal = new goals.GoalFollow(enemy, 3.5) + await mineflayer.bot.pathfinder.goto(goal) + } + catch { /* might error if entity dies, ignore */ } + } + + if (mineflayer.bot.entity.position.distanceTo(enemy.position) <= 2) { + try { + const followGoal = new goals.GoalFollow(enemy, 2) + const invertedGoal = new goals.GoalInvert(followGoal) + await mineflayer.bot.pathfinder.goto(invertedGoal) + } + catch { /* might error if entity dies, ignore */ } + } + + mineflayer.bot.pvp.attack(enemy) + attacked = true + await sleep(500) + enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) + + mineflayer.once('interrupt', () => { + mineflayer.bot.pvp.stop() + return false + }) + } + + mineflayer.bot.pvp.stop() + if (attacked) { + log(mineflayer, 'Successfully defended self.') + } + else { + log(mineflayer, '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 00000000..2896c080 --- /dev/null +++ b/services/minecraft/src/skills/crafting.ts @@ -0,0 +1,343 @@ +import type { Block } from 'prismarine-block' +import type { Item } from 'prismarine-item' +import type { Recipe } from 'prismarine-recipe' +import type { Mineflayer } from '../libs/mineflayer' + +import { useLogger } from '../utils/logger' +import { getItemId, getItemName } from '../utils/mcdata' +import { ensureCraftingTable } from './actions/ensure' +import { collectBlock, placeBlock } from './blocks' +import { goToNearestBlock, goToPosition, moveAway } from './movement' +import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './world' + +const logger = useLogger() + +/* +Possible Scenarios: + +1. **Successful Craft Without Crafting Table**: + - The bot attempts to craft the item without a crafting table and succeeds. The function returns `true`. + +2. **Crafting Table Nearby**: + - The bot tries to craft without a crafting table but fails. + - The bot then checks for a nearby crafting table. + - If a crafting table is found, the bot moves to it and successfully crafts the item, returning `true`. + +3. **No Crafting Table Nearby, Place Crafting Table**: + - The bot fails to craft without a crafting table and does not find a nearby crafting table. + - The bot checks inventory for a crafting table, places it at a suitable location, and attempts crafting again. + - If successful, the function returns `true`. If the bot cannot find a suitable position or fails to craft, it returns `false`. + +4. **Insufficient Resources**: + - At any point, if the bot does not have the required resources to craft the item, it logs an appropriate message and returns `false`. + +5. **No Crafting Table and No Suitable Position**: + - If the bot does not find a crafting table and cannot find a suitable position to place one, it moves away and returns `false`. + +6. **Invalid Item Name**: + - If the provided item name is invalid, the function logs the error and returns `false`. +*/ +export async function craftRecipe( + mineflayer: Mineflayer, + incomingItemName: string, + num = 1, +): Promise { + let itemName = incomingItemName.replace(' ', '_').toLowerCase() + + if (itemName.endsWith('plank')) + itemName += 's' // Correct common mistakes + + const itemId = getItemId(itemName) + if (itemId === null) { + logger.log(`Invalid item name: ${itemName}`) + return false + } + + // Helper function to attempt crafting + async function attemptCraft( + recipes: Recipe[] | null, + craftingTable: Block | null = null, + ): Promise { + if (recipes && recipes.length > 0) { + const recipe = recipes[0] + try { + await mineflayer.bot.craft(recipe, num, craftingTable ?? undefined) + logger.log( + `Successfully crafted ${num} ${itemName}${ + craftingTable ? ' using crafting table' : '' + }.`, + ) + return true + } + catch (err) { + logger.log(`Failed to craft ${itemName}: ${(err as Error).message}`) + return false + } + } + return false + } + + // Helper function to move to a crafting table and attempt crafting with retry logic + async function moveToAndCraft(craftingTable: Block): Promise { + logger.log(`Crafting table found, moving to it.`) + const maxRetries = 2 + let attempts = 0 + let success = false + + while (attempts < maxRetries && !success) { + try { + await goToPosition( + mineflayer, + craftingTable.position.x, + craftingTable.position.y, + craftingTable.position.z, + 1, + ) + const recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) + success = await attemptCraft(recipes, craftingTable) + } + catch (err) { + logger.log( + `Attempt ${attempts + 1} to move to crafting table failed: ${ + (err as Error).message + }`, + ) + } + attempts++ + } + + return success + } + + // Helper function to find and use or place a crafting table + async function findAndUseCraftingTable( + craftingTableRange: number, + ): Promise { + let craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) + if (craftingTable) { + return await moveToAndCraft(craftingTable) + } + + logger.log(`No crafting table nearby, attempting to place one.`) + const hasCraftingTable = await ensureCraftingTable(mineflayer) + if (!hasCraftingTable) { + logger.log(`Failed to ensure a crafting table to craft ${itemName}.`) + return false + } + + const pos = getNearestFreeSpace(mineflayer, 1, 10) + if (pos) { + moveAway(mineflayer, 4) + logger.log( + `Placing crafting table at position (${pos.x}, ${pos.y}, ${pos.z}).`, + ) + await placeBlock(mineflayer, 'crafting_table', pos.x, pos.y, pos.z) + craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) + if (craftingTable) { + return await moveToAndCraft(craftingTable) + } + } + else { + logger.log('No suitable position found to place the crafting table.') + moveAway(mineflayer, 5) + return false + } + + return false + } + + // Step 1: Try to craft without a crafting table + logger.log(`Step 1: Try to craft without a crafting table`) + const recipes = mineflayer.bot.recipesFor(itemId, null, 1, null) + if (recipes && (await attemptCraft(recipes))) { + return true + } + + // Step 2: Find and use a crafting table + logger.log(`Step 2: Find and use a crafting table`) + const craftingTableRange = 32 + if (await findAndUseCraftingTable(craftingTableRange)) { + return true + } + + return false +} + +export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = 1): Promise { + const foods = [ + 'beef', + 'chicken', + 'cod', + 'mutton', + 'porkchop', + 'rabbit', + 'salmon', + 'tropical_fish', + ] + if (!itemName.includes('raw') && !foods.includes(itemName)) { + logger.log( + `Cannot smelt ${itemName}, must be a "raw" item, like "raw_iron".`, + ) + return false + } // TODO: allow cobblestone, sand, clay, etc. + + let placedFurnace = false + let furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) + if (!furnaceBlock) { + // Try to place furnace + const hasFurnace = getInventoryCounts(mineflayer).furnace > 0 + if (hasFurnace) { + const pos = getNearestFreeSpace(mineflayer, 1, 32) + if (pos) { + await placeBlock(mineflayer, 'furnace', pos.x, pos.y, pos.z) + } + else { + logger.log('No suitable position found to place the furnace.') + return false + } + furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) + placedFurnace = true + } + } + if (!furnaceBlock) { + logger.log(`There is no furnace nearby and I have no furnace.`) + return false + } + if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToNearestBlock(mineflayer, 'furnace', 4, 32) + } + await mineflayer.bot.lookAt(furnaceBlock.position) + + logger.log('smelting...') + const furnace = await mineflayer.bot.openFurnace(furnaceBlock) + // Check if the furnace is already smelting something + const inputItem = furnace.inputItem() + if ( + inputItem + && inputItem.type !== getItemId(itemName) + && inputItem.count > 0 + ) { + logger.log( + `The furnace is currently smelting ${getItemName( + inputItem.type, + )}.`, + ) + if (placedFurnace) + await collectBlock(mineflayer, 'furnace', 1) + return false + } + // Check if the bot has enough items to smelt + const invCounts = getInventoryCounts(mineflayer) + if (!invCounts[itemName] || invCounts[itemName] < num) { + logger.log(`I do not have enough ${itemName} to smelt.`) + if (placedFurnace) + await collectBlock(mineflayer, 'furnace', 1) + return false + } + + // Fuel the furnace + if (!furnace.fuelItem()) { + const fuel = mineflayer.bot.inventory + .items() + .find(item => item.name === 'coal' || item.name === 'charcoal') + const putFuel = Math.ceil(num / 8) + if (!fuel || fuel.count < putFuel) { + logger.log( + `I do not have enough coal or charcoal to smelt ${num} ${itemName}, I need ${putFuel} coal or charcoal`, + ) + if (placedFurnace) + await collectBlock(mineflayer, 'furnace', 1) + return false + } + await furnace.putFuel(fuel.type, null, putFuel) + logger.log( + `Added ${putFuel} ${getItemName(fuel.type)} to furnace fuel.`, + ) + } + // Put the items in the furnace + const itemId = getItemId(itemName) + if (itemId === null) { + logger.log(`Invalid item name: ${itemName}`) + return false + } + await furnace.putInput(itemId, null, num) + // Wait for the items to smelt + let total = 0 + let collectedLast = true + let smeltedItem: Item | null = null + await new Promise(resolve => setTimeout(resolve, 200)) + while (total < num) { + await new Promise(resolve => setTimeout(resolve, 10000)) + logger.log('checking...') + 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 + } + await mineflayer.bot.closeWindow(furnace) + + if (placedFurnace) { + await collectBlock(mineflayer, 'furnace', 1) + } + if (total === 0) { + logger.log(`Failed to smelt ${itemName}.`) + return false + } + if (total < num) { + logger.log( + `Only smelted ${total} ${getItemName(smeltedItem?.type || 0)}.`, + ) + return false + } + logger.log( + `Successfully smelted ${itemName}, got ${total} ${getItemName( + smeltedItem?.type || 0, + )}.`, + ) + return true +} + +export async function clearNearestFurnace(mineflayer: Mineflayer): Promise { + const furnaceBlock = getNearestBlock(mineflayer, 'furnace', 6) + if (!furnaceBlock) { + logger.log(`There is no furnace nearby.`) + return false + } + + logger.log('clearing furnace...') + const furnace = await mineflayer.bot.openFurnace(furnaceBlock) + logger.log('opened furnace...') + // Take the items out of the furnace + let smeltedItem: Item | null = null + let inputItem: Item | null = null + let fuelItem: Item | null = null + if (furnace.outputItem()) + smeltedItem = await furnace.takeOutput() + if (furnace.inputItem()) + inputItem = await furnace.takeInput() + if (furnace.fuelItem()) + fuelItem = await furnace.takeFuel() + logger.log(smeltedItem, inputItem, fuelItem) + 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` + logger.log( + `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`, + ) + await mineflayer.bot.closeWindow(furnace) + return true +} diff --git a/services/minecraft/src/skills/index.ts b/services/minecraft/src/skills/index.ts new file mode 100644 index 00000000..9f7d7ac8 --- /dev/null +++ b/services/minecraft/src/skills/index.ts @@ -0,0 +1,6 @@ +export * from './base' +export * from './blocks' +export * from './combat' +export * from './crafting' +export * from './inventory' +export * from './movement' diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts new file mode 100644 index 00000000..ec6c4ef3 --- /dev/null +++ b/services/minecraft/src/skills/inventory.ts @@ -0,0 +1,222 @@ +import type { Mineflayer } from '../libs/mineflayer' + +import { log } from './base' +import { goToPlayer, goToPosition } from './movement' +import { getNearestBlock } from './world' + +export async function equip(mineflayer: Mineflayer, itemName: string): Promise { + const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName) + if (!item) { + log(mineflayer, `You do not have any ${itemName} to equip.`) + return false + } + + if (itemName.includes('leggings')) { + await mineflayer.bot.equip(item, 'legs') + } + else if (itemName.includes('boots')) { + await mineflayer.bot.equip(item, 'feet') + } + else if (itemName.includes('helmet')) { + await mineflayer.bot.equip(item, 'head') + } + else if (itemName.includes('chestplate') || itemName.includes('elytra')) { + await mineflayer.bot.equip(item, 'torso') + } + else if (itemName.includes('shield')) { + await mineflayer.bot.equip(item, 'off-hand') + } + else { + await mineflayer.bot.equip(item, 'hand') + } + + log(mineflayer, `Equipped ${itemName}.`) + return true +} + +export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + let discarded = 0 + + while (true) { + const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) + if (!item) { + break + } + + const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) + await mineflayer.bot.toss(item.type, null, toDiscard) + discarded += toDiscard + + if (num !== -1 && discarded >= num) { + break + } + } + + if (discarded === 0) { + log(mineflayer, `You do not have any ${itemName} to discard.`) + return false + } + + log(mineflayer, `Discarded ${discarded} ${itemName}.`) + return true +} + +export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + log(mineflayer, 'Could not find a chest nearby.') + return false + } + + const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) + if (!item) { + log(mineflayer, `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(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) + + const chestContainer = await mineflayer.bot.openContainer(chest) + await chestContainer.deposit(item.type, null, toPut) + await chestContainer.close() + + log(mineflayer, `Successfully put ${toPut} ${itemName} in the chest.`) + return true +} + +export async function takeFromChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + log(mineflayer, 'Could not find a chest nearby.') + return false + } + + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await mineflayer.bot.openContainer(chest) + + const item = chestContainer.containerItems().find(item => item.name === itemName) + if (!item) { + log(mineflayer, `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(mineflayer, `Successfully took ${toTake} ${itemName} from the chest.`) + return true +} + +export async function viewChest(mineflayer: Mineflayer): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + log(mineflayer, 'Could not find a chest nearby.') + return false + } + + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await mineflayer.bot.openContainer(chest) + const items = chestContainer.containerItems() + + if (items.length === 0) { + log(mineflayer, 'The chest is empty.') + } + else { + log(mineflayer, 'The chest contains:') + for (const item of items) { + log(mineflayer, `${item.count} ${item.name}`) + } + } + + await chestContainer.close() + return true +} + +export async function consume(mineflayer: Mineflayer, itemName = ''): Promise { + let item + let name + + if (itemName) { + item = mineflayer.bot.inventory.items().find(item => item.name === itemName) + name = itemName + } + + if (!item) { + log(mineflayer, `You do not have any ${name} to eat.`) + return false + } + + await mineflayer.bot.equip(item, 'hand') + await mineflayer.bot.consume() + log(mineflayer, `Consumed ${item.name}.`) + return true +} + +export async function giveToPlayer( + mineflayer: Mineflayer, + itemType: string, + username: string, + num = 1, +): Promise { + const player = mineflayer.bot.players[username]?.entity + if (!player) { + log(mineflayer, `Could not find ${username}.`) + return false + } + + // Move to player position + await goToPlayer(mineflayer, username, 3) + + // Look at player before dropping items + await mineflayer.bot.lookAt(player.position) + + // Drop items and wait for collection + const success = await dropItemsAndWaitForCollection(mineflayer, itemType, username, num) + if (!success) { + log(mineflayer, `Failed to give ${itemType} to ${username}, it was never received.`) + return false + } + + return true +} + +async function dropItemsAndWaitForCollection( + mineflayer: Mineflayer, + itemType: string, + username: string, + num: number, +): Promise { + if (!await discard(mineflayer, itemType, num)) { + return false + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Clean up playerCollect listener when timeout occurs + // eslint-disable-next-line ts/no-use-before-define + mineflayer.bot.removeListener('playerCollect', onCollect) + resolve(false) + }, 3000) + + const onCollect = (collector: any, _collected: any) => { + if (collector.username === username) { + log(mineflayer, `${username} received ${itemType}.`) + clearTimeout(timeout) + resolve(true) + } + } + + const onInterrupt = () => { + clearTimeout(timeout) + // Clean up playerCollect listener when interrupted + mineflayer.bot.removeListener('playerCollect', onCollect) + resolve(false) + } + + mineflayer.bot.once('playerCollect', onCollect) + mineflayer.once('interrupt', onInterrupt) + }) +} diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts new file mode 100644 index 00000000..2225e194 --- /dev/null +++ b/services/minecraft/src/skills/movement.ts @@ -0,0 +1,230 @@ +import type { Entity } from 'prismarine-entity' +import type { Mineflayer } from '../libs/mineflayer' + +import { randomInt } from 'es-toolkit' +import pathfinder from 'mineflayer-pathfinder' +import { Vec3 } from 'vec3' + +import { sleep } from '../utils/helper' +import { useLogger } from '../utils/logger' +import { log } from './base' +import { getNearestBlock, getNearestEntityWhere } from './world' + +const logger = useLogger() +const { goals, Movements } = pathfinder + +export async function goToPosition( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, + minDistance = 2, +): Promise { + if (x == null || y == null || z == null) { + log(mineflayer, `Missing coordinates, given x:${x} y:${y} z:${z}`) + return false + } + + if (mineflayer.allowCheats) { + mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) + log(mineflayer, `Teleported to ${x}, ${y}, ${z}.`) + return true + } + + await mineflayer.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) + log(mineflayer, `You have reached ${x}, ${y}, ${z}.`) + return true +} + +export async function goToNearestBlock( + mineflayer: Mineflayer, + blockType: string, + minDistance = 2, + range = 64, +): Promise { + const MAX_RANGE = 512 + if (range > MAX_RANGE) { + log(mineflayer, `Maximum search range capped at ${MAX_RANGE}.`) + range = MAX_RANGE + } + + const block = getNearestBlock(mineflayer, blockType, range) + if (!block) { + log(mineflayer, `Could not find any ${blockType} in ${range} blocks.`) + return false + } + + log(mineflayer, `Found ${blockType} at ${block.position}.`) + await goToPosition(mineflayer, block.position.x, block.position.y, block.position.z, minDistance) + return true +} + +export async function goToNearestEntity( + mineflayer: Mineflayer, + entityType: string, + minDistance = 2, + range = 64, +): Promise { + const entity = getNearestEntityWhere( + mineflayer, + entity => entity.name === entityType, + range, + ) + + if (!entity) { + log(mineflayer, `Could not find any ${entityType} in ${range} blocks.`) + return false + } + + const distance = mineflayer.bot.entity.position.distanceTo(entity.position) + log(mineflayer, `Found ${entityType} ${distance} blocks away.`) + await goToPosition( + mineflayer, + entity.position.x, + entity.position.y, + entity.position.z, + minDistance, + ) + return true +} + +export async function goToPlayer( + mineflayer: Mineflayer, + username: string, + distance = 3, +): Promise { + if (mineflayer.allowCheats) { + mineflayer.bot.chat(`/tp @s ${username}`) + log(mineflayer, `Teleported to ${username}.`) + return true + } + + const player = mineflayer.bot.players[username]?.entity + if (!player) { + log(mineflayer, `Could not find ${username}.`) + return false + } + + await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(player, distance)) + log(mineflayer, `You have reached ${username}.`) + return true +} + +export async function followPlayer( + mineflayer: Mineflayer, + username: string, + distance = 4, +): Promise { + const player = mineflayer.bot.players[username]?.entity + if (!player) { + return false + } + + log(mineflayer, `I am now actively following player ${username}.`) + + const movements = new Movements(mineflayer.bot) + mineflayer.bot.pathfinder.setMovements(movements) + mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) + + mineflayer.once('interrupt', () => { + mineflayer.bot.pathfinder.stop() + }) + + return true +} + +export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { + try { + const pos = mineflayer.bot.entity.position + let newX: number = 0 + let newZ: number = 0 + let suitableGoal = false + + while (!suitableGoal) { + const rand1 = randomInt(0, 2) + const rand2 = randomInt(0, 2) + const bigRand1 = randomInt(0, 101) + const bigRand2 = randomInt(0, 101) + + newX = Math.floor( + pos.x + ((distance * bigRand1) / 100) * (rand1 ? 1 : -1), + ) + newZ = Math.floor( + pos.z + ((distance * bigRand2) / 100) * (rand2 ? 1 : -1), + ) + + const block = mineflayer.bot.blockAt(new Vec3(newX, pos.y - 1, newZ)) + + if (block?.name !== 'water' && block?.name !== 'lava') { + suitableGoal = true + } + } + + const farGoal = new pathfinder.goals.GoalXZ(newX, newZ) + + await mineflayer.bot.pathfinder.goto(farGoal) + const newPos = mineflayer.bot.entity.position + logger.log(`I moved away from nearest entity to ${newPos}.`) + await sleep(500) + return true + } + catch (err) { + logger.log(`I failed to move away: ${(err as Error).message}`) + return false + } +} + +export async function moveAwayFromEntity( + mineflayer: Mineflayer, + entity: Entity, + distance = 16, +): Promise { + const goal = new goals.GoalFollow(entity, distance) + const invertedGoal = new goals.GoalInvert(goal) + await mineflayer.bot.pathfinder.goto(invertedGoal) + return true +} + +export async function stay(mineflayer: Mineflayer, seconds = 30): Promise { + const start = Date.now() + const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 + + while (Date.now() < targetTime) { + await sleep(500) + } + + log(mineflayer, `I stayed for ${(Date.now() - start) / 1000} seconds.`) + return true +} + +export async function goToBed(mineflayer: Mineflayer): Promise { + const beds = mineflayer.bot.findBlocks({ + matching: block => block.name.includes('bed'), + maxDistance: 32, + count: 1, + }) + + if (beds.length === 0) { + log(mineflayer, 'I could not find a bed to sleep in.') + return false + } + + const loc = beds[0] + await goToPosition(mineflayer, loc.x, loc.y, loc.z) + + const bed = mineflayer.bot.blockAt(loc) + if (!bed) { + log(mineflayer, 'I could not find a bed to sleep in.') + return false + } + + await mineflayer.bot.sleep(bed) + log(mineflayer, 'I am in bed.') + + while (mineflayer.bot.isSleeping) { + await sleep(500) + } + + log(mineflayer, 'I have woken up.') + return true +} diff --git a/services/minecraft/src/skills/world.ts b/services/minecraft/src/skills/world.ts new file mode 100644 index 00000000..fd7e9504 --- /dev/null +++ b/services/minecraft/src/skills/world.ts @@ -0,0 +1,196 @@ +import type { Block } from 'prismarine-block' +import type { Entity } from 'prismarine-entity' +import type { Item } from 'prismarine-item' +import type { Vec3 } from 'vec3' +import type { Mineflayer } from '../libs/mineflayer' + +import pf from 'mineflayer-pathfinder' + +import * as mc from '../utils/mcdata' + +export function getNearestFreeSpace( + mineflayer: Mineflayer, + size: number = 1, + distance: number = 8, +): Vec3 | undefined { + /** + * Get the nearest empty space with solid blocks beneath it of the given size. + * @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( 1, 8); + */ + const empty_pos = mineflayer.bot.findBlocks({ + matching: (block: Block | null) => { + return block !== null && 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++) { + const top = mineflayer.bot.blockAt(empty_pos[i].offset(x, 0, z)) + const bottom = mineflayer.bot.blockAt(empty_pos[i].offset(x, -1, z)) + if ( + !top + || top.name !== 'air' + || !bottom + || (bottom.drops?.length ?? 0) === 0 + || !bottom.diggable + ) { + empty = false + break + } + } + if (!empty) + break + } + if (empty) { + return empty_pos[i] + } + } + return undefined +} + +export function getNearestBlocks(mineflayer: Mineflayer, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { + const blockIds = blockTypes === null + ? mc.getAllBlockIds(['air']) + : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId).filter((id): id is number => id !== null) + + const positions = mineflayer.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) + + return positions + .map((pos) => { + const block = mineflayer.bot.blockAt(pos) + const dist = pos.distanceTo(mineflayer.bot.entity.position) + return block ? { block, distance: dist } : null + }) + .filter((item): item is { block: Block, distance: number } => item !== null) + .sort((a, b) => a.distance - b.distance) + .map(item => item.block) +} + +export function getNearestBlock(mineflayer: Mineflayer, blockType: string, distance: number = 16): Block | null { + const blocks = getNearestBlocks(mineflayer, blockType, distance, 1) + return blocks[0] || null +} + +export function getNearbyEntities(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { + return Object.values(mineflayer.bot.entities) + .filter((entity): entity is Entity => + entity !== null + && entity.position.distanceTo(mineflayer.bot.entity.position) <= maxDistance, + ) + .sort((a, b) => + a.position.distanceTo(mineflayer.bot.entity.position) + - b.position.distanceTo(mineflayer.bot.entity.position), + ) +} + +export function getNearestEntityWhere(mineflayer: Mineflayer, predicate: (entity: Entity) => boolean, maxDistance: number = 16): Entity | null { + return mineflayer.bot.nearestEntity(entity => + predicate(entity) + && mineflayer.bot.entity.position.distanceTo(entity.position) < maxDistance, + ) +} + +export function getNearbyPlayers(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { + return getNearbyEntities(mineflayer, maxDistance) + .filter(entity => + entity.type === 'player' + && entity.username !== mineflayer.bot.username, + ) +} + +export function getInventoryStacks(mineflayer: Mineflayer): Item[] { + return mineflayer.bot.inventory.items().filter((item): item is Item => item !== null) +} + +export function getInventoryCounts(mineflayer: Mineflayer): Record { + return getInventoryStacks(mineflayer).reduce((counts, item) => { + counts[item.name] = (counts[item.name] || 0) + item.count + return counts + }, {} as Record) +} + +export function getCraftableItems(mineflayer: Mineflayer): string[] { + const table = getNearestBlock(mineflayer, 'crafting_table') + || getInventoryStacks(mineflayer).find(item => item.name === 'crafting_table') + return mc.getAllItems() + .filter(item => mineflayer.bot.recipesFor(item.id, null, 1, table as Block | null).length > 0) + .map(item => item.name) +} + +export function getPosition(mineflayer: Mineflayer): Vec3 { + return mineflayer.bot.entity.position +} + +export function getNearbyEntityTypes(mineflayer: Mineflayer): string[] { + return [...new Set( + getNearbyEntities(mineflayer, 16) + .map(mob => mob.name) + .filter((name): name is string => name !== undefined), + )] +} + +export function getNearbyPlayerNames(mineflayer: Mineflayer): string[] { + return [...new Set( + getNearbyPlayers(mineflayer, 64) + .map(player => player.username) + .filter((name): name is string => + name !== undefined + && name !== mineflayer.bot.username, + ), + )] +} + +export function getNearbyBlockTypes(mineflayer: Mineflayer, distance: number = 16): string[] { + return [...new Set( + getNearestBlocks(mineflayer, null, distance) + .map(block => block.name), + )] +} + +export async function isClearPath(mineflayer: Mineflayer, target: Entity): Promise { + const movements = new pf.Movements(mineflayer.bot) + movements.canDig = false + // movements.canPlaceOn = false // TODO: fix this + + const goal = new pf.goals.GoalNear( + target.position.x, + target.position.y, + target.position.z, + 1, + ) + + const path = await mineflayer.bot.pathfinder.getPathTo(movements, goal, 100) + return path.status === 'success' +} + +export function shouldPlaceTorch(mineflayer: Mineflayer): boolean { + // if (!mineflayer.bot.modes.isOn('torch_placing') || mineflayer.bot.interrupt_code) { + // return false + // } + + const pos = getPosition(mineflayer) + const nearestTorch = getNearestBlock(mineflayer, 'torch', 6) + || getNearestBlock(mineflayer, 'wall_torch', 6) + + if (nearestTorch) { + return false + } + + const block = mineflayer.bot.blockAt(pos) + const hasTorch = mineflayer.bot.inventory.items().some(item => item?.name === 'torch') + + return Boolean(hasTorch && block?.name === 'air') +} + +export function getBiomeName(mineflayer: Mineflayer): string { + const biomeId = mineflayer.bot.world.getBiome(mineflayer.bot.entity.position) + return mc.getAllBiomes()[biomeId].name +} diff --git a/services/minecraft/src/utils/helper.ts b/services/minecraft/src/utils/helper.ts new file mode 100644 index 00000000..e0600bf2 --- /dev/null +++ b/services/minecraft/src/utils/helper.ts @@ -0,0 +1,39 @@ +export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Returns a retirable anonymous function with configured retryLimit and delayInterval + * + * @param retryLimit Number of retry attempts + * @param delayInterval Delay between retries in milliseconds + * @param func Function to be called + * @returns A wrapped function with the same signature as func + */ +export function toRetriable( + retryLimit: number, + delayInterval: number, + func: (...args: A[]) => Promise, + hooks?: { + onError?: (err: unknown) => void + }, +): (...args: A[]) => Promise { + let retryCount = 0 + return async function (args: A): Promise { + try { + return await func(args) + } + catch (err) { + if (hooks?.onError) { + hooks.onError(err) + } + + if (retryCount < retryLimit) { + retryCount++ + await sleep(delayInterval) + return await toRetriable(retryLimit - retryCount, delayInterval, func)(args) + } + else { + throw err + } + } + } +} diff --git a/services/minecraft/src/utils/logger.ts b/services/minecraft/src/utils/logger.ts new file mode 100644 index 00000000..8155023a --- /dev/null +++ b/services/minecraft/src/utils/logger.ts @@ -0,0 +1,27 @@ +import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' + +export type Logger = ReturnType + +export function initLogger() { + setGlobalLogLevel(LogLevel.Debug) + setGlobalFormat(Format.Pretty) + + const logger = useLogg('logger').useGlobalConfig() + logger.log('Logger initialized') +} + +/** + * Get logger instance with directory name and filename + * @returns logger instance configured with "directoryName/filename" + */ +export function useLogger() { + const stack = new Error('logger').stack + const caller = stack?.split('\n')[2] + + // Match the parent directory and filename without extension + const match = caller?.match(/\/([^/]+)\/([^/]+?)\.[jt]s/) + const dirName = match?.[1] || 'unknown' + const fileName = match?.[2] || 'unknown' + + return useLogg(`${dirName}/${fileName}`).useGlobalConfig() +} diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts new file mode 100644 index 00000000..8a18ba40 --- /dev/null +++ b/services/minecraft/src/utils/mcdata.ts @@ -0,0 +1,274 @@ +import type { Biome, ShapedRecipe, ShapelessRecipe } from 'minecraft-data' +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' + +import minecraftData from 'minecraft-data' +import prismarineItem from 'prismarine-item' + +const GAME_VERSION = '1.20' + +export const gameData = minecraftData(GAME_VERSION) +export const Item = prismarineItem(GAME_VERSION) + +export const WOOD_TYPES: string[] = [ + 'oak', + 'spruce', + 'birch', + 'jungle', + 'acacia', + 'dark_oak', +] + +export const MATCHING_WOOD_BLOCKS: string[] = [ + 'log', + 'planks', + 'sign', + 'boat', + 'fence_gate', + 'door', + 'fence', + 'slab', + 'stairs', + 'button', + 'pressure_plate', + 'trapdoor', +] + +export const WOOL_COLORS: string[] = [ + 'white', + 'orange', + 'magenta', + 'light_blue', + 'yellow', + 'lime', + 'pink', + 'gray', + 'light_gray', + 'cyan', + 'purple', + 'blue', + 'brown', + 'green', + 'red', + 'black', +] + +export function isHuntable(mob: Entity): boolean { + if (!mob || !mob.name) + return false + const animals: string[] = [ + 'chicken', + 'cow', + 'llama', + 'mooshroom', + 'pig', + 'rabbit', + 'sheep', + ] + return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16] // metadata[16] indicates baby status +} + +export function isHostile(mob: Entity): boolean { + if (!mob || !mob.name) + return false + return ( + (mob.type === 'mob' || mob.type === 'hostile') + && mob.name !== 'iron_golem' + && mob.name !== 'snow_golem' + ) +} + +export function getItemId(itemName: string): number { + const item = gameData.itemsByName[itemName] + + return item?.id || 0 +} + +export function getItemName(itemId: number): string { + const item = gameData.items[itemId] + return item.name || '' +} + +export function getBlockId(blockName: string): number { + const block = gameData.blocksByName?.[blockName] + return block?.id || 0 +} + +export function getBlockName(blockId: number): string { + const block = gameData.blocks[blockId] + return block.name || '' +} + +export function getAllItems(ignore: string[] = []): any[] { + const items: any[] = [] + for (const itemId in gameData.items) { + const item = gameData.items[itemId] + if (!ignore.includes(item.name)) { + items.push(item) + } + } + return items +} + +export function getAllItemIds(ignore: string[] = []): number[] { + const items = getAllItems(ignore) + const itemIds: number[] = [] + for (const item of items) { + itemIds.push(item.id) + } + return itemIds +} + +export function getAllBlocks(ignore: string[] = []): any[] { + const blocks: any[] = [] + for (const blockId in gameData.blocks) { + const block = gameData.blocks[blockId] + if (!ignore.includes(block.name)) { + blocks.push(block) + } + } + return blocks +} + +export function getAllBlockIds(ignore: string[] = []): number[] { + const blocks = getAllBlocks(ignore) + const blockIds: number[] = [] + for (const block of blocks) { + blockIds.push(block.id) + } + return blockIds +} + +export function getAllBiomes(): Record { + return gameData.biomes +} + +export function getItemCraftingRecipes(itemName: string): any[] | null { + const itemId = getItemId(itemName) + if (!itemId || !gameData.recipes[itemId]) { + return null + } + + const recipes: Record[] = [] + for (const r of gameData.recipes[itemId]) { + const recipe: Record = {} + let ingredients: number[] = [] + + if (isShapelessRecipe(r)) { + // Handle shapeless recipe + ingredients = r.ingredients.map((ing: any) => ing.id) + } + else if (isShapedRecipe(r)) { + // Handle shaped recipe + ingredients = r.inShape + .flat() + .map((ing: any) => ing?.id) + .filter(Boolean) + } + + for (const ingredientId of ingredients) { + const ingredientName = getItemName(ingredientId) + if (ingredientName === null) + continue + if (!recipe[ingredientName]) + recipe[ingredientName] = 0 + recipe[ingredientName]++ + } + + recipes.push(recipe) + } + + return recipes +} + +// Type guards +function isShapelessRecipe(recipe: any): recipe is ShapelessRecipe { + return 'ingredients' in recipe +} + +function isShapedRecipe(recipe: any): recipe is ShapedRecipe { + return 'inShape' in recipe +} + +export function getItemSmeltingIngredient( + itemName: string, +): string | undefined { + 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: string): string[] { + const itemId = getItemId(itemName) + const sources: string[] = [] + if (!itemId) + return sources + for (const block of getAllBlocks()) { + if (block.drops && block.drops.includes(itemId)) { + sources.push(block.name) + } + } + return sources +} + +export function getItemAnimalSource(itemName: string): string | undefined { + 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: string): string | null { + const block = gameData.blocksByName[blockName] + if (!block || !block.harvestTools) { + return null + } + const toolIds = Object.keys(block.harvestTools).map(id => Number.parseInt(id)) + const toolName = getItemName(toolIds[0]) + return toolName || null // Assuming the first tool is the simplest +} + +export function makeItem(name: string, amount = 1): InstanceType { + const itemId = getItemId(name) + if (itemId === null) + throw new Error(`Item ${name} not found.`) + return new Item(itemId, amount) +} + +// Function to get the nearest block of a specific type using Mineflayer +export function getNearestBlock( + bot: Bot, + blockType: string, + maxDistance: number, +) { + const blocks = bot.findBlocks({ + matching: block => block.name === blockType, + maxDistance, + count: 1, + }) + + if (blocks.length === 0) + return null + + const nearestBlockPosition = blocks[0] + return bot.blockAt(nearestBlockPosition) +} diff --git a/services/minecraft/tsconfig.json b/services/minecraft/tsconfig.json new file mode 100644 index 00000000..b7208af9 --- /dev/null +++ b/services/minecraft/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "moduleDetection": "auto", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/services/minecraft/vitest.config.ts b/services/minecraft/vitest.config.ts new file mode 100644 index 00000000..647f3936 --- /dev/null +++ b/services/minecraft/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +})