├── .editorconfig ├── .env ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── cspell.config.yaml ├── docs └── preview.png ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── src ├── agents │ ├── action │ │ ├── adapter.test.ts │ │ ├── adapter.ts │ │ ├── index.ts │ │ ├── tools.test.ts │ │ └── tools.ts │ ├── chat │ │ ├── adapter.ts │ │ ├── index.ts │ │ ├── llm.ts │ │ └── types.ts │ ├── memory │ │ └── index.ts │ └── planning │ │ ├── adapter.ts │ │ └── index.ts ├── composables │ ├── bot.ts │ ├── config.ts │ └── neuri.ts ├── libs │ ├── llm-agent │ │ ├── chat.ts │ │ ├── completion.ts │ │ ├── container.ts │ │ ├── handler.ts │ │ ├── index.ts │ │ ├── prompt.ts │ │ ├── types.ts │ │ └── voice.ts │ └── mineflayer │ │ ├── action.ts │ │ ├── base-agent.ts │ │ ├── command.ts │ │ ├── components.ts │ │ ├── core.ts │ │ ├── health.ts │ │ ├── index.ts │ │ ├── memory.ts │ │ ├── message.ts │ │ ├── plugin.ts │ │ ├── status.ts │ │ ├── ticker.ts │ │ └── types.ts ├── main.ts ├── plugins │ ├── echo.ts │ ├── follow.ts │ ├── pathfinder.ts │ └── status.ts ├── skills │ ├── actions │ │ ├── collect-block.ts │ │ ├── ensure.ts │ │ ├── gather-wood.ts │ │ ├── inventory.ts │ │ └── world-interactions.ts │ ├── base.ts │ ├── blocks.ts │ ├── combat.ts │ ├── crafting.ts │ ├── index.ts │ ├── inventory.ts │ ├── movement.ts │ └── world.ts └── utils │ ├── helper.ts │ ├── logger.ts │ └── mcdata.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | OPENAI_API_BASEURL='' 2 | OPENAI_API_KEY='' 3 | OPENAI_MODEL='deepseek-chat' 4 | OPENAI_REASONING_MODEL='deepseek-reasoner' 5 | 6 | BOT_USERNAME='' 7 | BOT_HOSTNAME='' 8 | BOT_PORT='' 9 | BOT_PASSWORD='' 10 | BOT_VERSION='' 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nekomeowww, luoling8192] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v3 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: lts/* 21 | cache: pnpm 22 | 23 | - name: Install 24 | run: pnpm install 25 | 26 | - name: Lint 27 | run: pnpm run lint 28 | 29 | typecheck: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: pnpm/action-setup@v3 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: lts/* 37 | cache: pnpm 38 | 39 | - name: Install 40 | run: pnpm install 41 | 42 | - name: Typecheck 43 | run: pnpm run typecheck 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS specific 2 | .Trash/ 3 | .trash/ 4 | .DS_Store 5 | 6 | # Logs & temp files 7 | .temp 8 | *.log 9 | 10 | # IDE 11 | .idea 12 | 13 | # Environments 14 | *.local 15 | 16 | # Build 17 | dist 18 | 19 | # ESLint 20 | .eslintcache 21 | 22 | # Node.js 23 | node_modules 24 | .npmrc 25 | 26 | # Vite 27 | .vite-inspect 28 | 29 | # Nuxt 30 | .nuxt 31 | 32 | # Vue 33 | components.d.ts 34 | 35 | # Unit tests 36 | coverage/ 37 | **/testdata/** 38 | 39 | # Cloudflare Workers & Pages 40 | .wrangler 41 | .dev.vars 42 | 43 | # Netlify 44 | **/.netlify/* 45 | **/.netlify/functions-serve/* 46 | 47 | # VitePress - documentation 48 | **/.vitepress/docsMetadata.json 49 | **/.vitepress/cache/ 50 | 51 | # Obsidian - documentation 52 | **/.obsidian/ 53 | 54 | # Golang related 55 | **/pkg 56 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off", "fixable": true }, 15 | { "rule": "format/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-indent", "severity": "off", "fixable": true }, 17 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 18 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 19 | { "rule": "*-order", "severity": "off", "fixable": true }, 20 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 21 | { "rule": "*-newline", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true }, 23 | { "rule": "*semi", "severity": "off", "fixable": true } 24 | ], 25 | 26 | // Enable eslint for all supported languages 27 | "eslint.validate": [ 28 | "javascript", 29 | "javascriptreact", 30 | "typescript", 31 | "typescriptreact", 32 | "vue", 33 | "html", 34 | "markdown", 35 | "json", 36 | "json5", 37 | "jsonc", 38 | "yaml", 39 | "toml", 40 | "xml", 41 | "gql", 42 | "graphql", 43 | "astro", 44 | "css", 45 | "less", 46 | "scss", 47 | "pcss", 48 | "postcss" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Airi Maintainers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⛏️ Minecraft agent player for [アイリ (Airi)](https://airi.moeru.ai) 2 | 3 | > [!WARNING] 4 | > 5 | > As the PoC and initial implementation has been achieved, as well as since all the [`airi-minecraft`](https://github.com/moeru-ai/airi-minecraft) history of changes has been merged in to the meta repo of [`airi`](https://github.com/moeru-ai/airi) under [`services/minecraft`](https://github.com/moeru-ai/airi/tree/main/services/minecraft) directory with [`219407a`](https://github.com/moeru-ai/airi/commit/219407aacaaa8d371d7b916f040667fc4f77f474) commit, this repository will be archived and no longer maintained. 6 | > 7 | > If you wish to take a closer look on how we implemented it, please go to: [`services/minecraft`](https://github.com/moeru-ai/airi/tree/main/services/minecraft). 8 | > 9 | > This doesn't mean [アイリ (Airi)](https://airi.moeru.ai) was discontinued, we are still actively maintain and developing it to achieve out roadmap. As always, you are welcome to join us and contribute to [`airi`](https://github.com/moeru-ai/airi). 10 | 11 | > [!NOTE] 12 | > 13 | > 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). 14 | 15 | An intelligent Minecraft bot powered by LLM. AIRI can understand natural language commands, interact with the world, and assist players in various tasks. 16 | 17 | ## 🎥 Preview 18 | 19 | ![demo](./docs/preview.png) 20 | 21 | ## ✨ Features 22 | 23 | - 🗣️ Natural language understanding 24 | - 🏃‍♂️ Advanced pathfinding and navigation 25 | - 🛠️ Block breaking and placing 26 | - 🎯 Combat and PvP capabilities 27 | - 🔄 Auto-reconnect on disconnection 28 | - 📦 Inventory management 29 | - 🤝 Player following and interaction 30 | - 🌍 World exploration and mapping 31 | 32 | ## 🚀 Getting Started 33 | 34 | ### 📋 Prerequisites 35 | 36 | - 📦 Node.js 22+ 37 | - 🔧 pnpm 38 | - 🎮 A Minecraft server (1.20+) 39 | 40 | ### 🔨 Installation 41 | 42 | 1. Clone the repository: 43 | 44 | ```bash 45 | git clone https://github.com/moeru-ai/airi-minecraft.git 46 | cd airi-mc 47 | ``` 48 | 49 | 2. Install dependencies: 50 | 51 | ```bash 52 | pnpm install 53 | ``` 54 | 55 | 3. Create a `.env.local` file with your configuration: 56 | 57 | ```env 58 | OPENAI_API_KEY=your_openai_api_key 59 | OPENAI_API_BASEURL=your_openai_api_baseurl 60 | 61 | BOT_USERNAME=your_bot_username 62 | BOT_HOSTNAME=localhost 63 | BOT_PORT=25565 64 | BOT_PASSWORD=optional_password 65 | BOT_VERSION=1.20 66 | ``` 67 | 68 | 4. Start the bot: 69 | 70 | ```bash 71 | pnpm dev 72 | ``` 73 | 74 | ## 🎮 Usage 75 | 76 | Once the bot is connected, you can interact with it using chat commands in Minecraft. All commands start with `#`. 77 | 78 | ### Basic Commands 79 | 80 | - `#help` - Show available commands 81 | - `#follow` - Make the bot follow you 82 | - `#stop` - Stop the current action 83 | - `#come` - Make the bot come to your location 84 | 85 | ### Natural Language Commands 86 | 87 | You can also give the bot natural language commands, and it will try to understand and execute them. For example: 88 | 89 | - "Build a house" 90 | - "Find some diamonds" 91 | - "Help me fight these zombies" 92 | - "Collect wood from nearby trees" 93 | 94 | ## 🛠️ Development 95 | 96 | ### Project Structure 97 | 98 | ``` 99 | src/ 100 | ├── agents/ # AI agent implementations 101 | ├── composables/# Reusable composable functions 102 | ├── libs/ # Core library code 103 | ├── mineflayer/ # Mineflayer plugin implementations 104 | ├── prompts/ # AI prompt templates 105 | ├── skills/ # Bot skills and actions 106 | └── utils/ # Utility functions 107 | ``` 108 | 109 | ### Commands 110 | 111 | - `pnpm dev` - Start the bot in development mode 112 | - `pnpm lint` - Run ESLint 113 | - `pnpm typecheck` - Run TypeScript type checking 114 | - `pnpm test` - Run tests 115 | 116 | ## 🙏 Acknowledgements 117 | 118 | - https://github.com/kolbytn/mindcraft 119 | 120 | ## 🤝 Contributing 121 | 122 | Contributions are welcome! Please feel free to submit a Pull Request. 123 | -------------------------------------------------------------------------------- /cspell.config.yaml: -------------------------------------------------------------------------------- 1 | version: '0.2' 2 | ignorePaths: [] 3 | dictionaryDefinitions: [] 4 | dictionaries: [] 5 | words: 6 | - aichat 7 | - airi 8 | - antfu 9 | - awilix 10 | - bumpp 11 | - collectblock 12 | - convo 13 | - defu 14 | - dotenvx 15 | - execa 16 | - guiiai 17 | - Infobar 18 | - KHTML 19 | - logg 20 | - luoling 21 | - mcdata 22 | - mdast 23 | - Messagable 24 | - messagestr 25 | - mineflayer 26 | - mooshroom 27 | - neko 28 | - NekoMeowww 29 | - neuri 30 | - noancestors 31 | - ofetch 32 | - OneLinerable 33 | - openai 34 | - prismarine 35 | - reactjs 36 | - Retriable 37 | - serpapi 38 | - shiki 39 | - shikijs 40 | - Streamable 41 | - testdata 42 | - typeschema 43 | - valibot 44 | - vitepress 45 | - vuejs 46 | - xsai 47 | ignoreWords: [] 48 | import: [] 49 | -------------------------------------------------------------------------------- /docs/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeru-ai/airi-minecraft/755598557d1d91a5b130addf083a082f860b261b/docs/preview.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | formatters: true, 5 | rules: { 6 | 'import/order': [ 7 | 'error', 8 | { 9 | 'groups': [ 10 | ['type'], 11 | ['builtin', 'external'], 12 | ['parent', 'sibling', 'index'], 13 | ], 14 | 'newlines-between': 'always', 15 | }, 16 | ], 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@proj-airi/minecraft-bot", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "packageManager": "pnpm@9.15.5", 6 | "description": "An intelligent Minecraft bot powered by LLM. AIRI can understand natural language commands, interact with the world, and assist players in various tasks.", 7 | "main": "src/main.ts", 8 | "scripts": { 9 | "dev": "dotenvx run -f .env -f .env.local --overload --debug --ignore=MISSING_ENV_FILE -- tsx src/main.ts", 10 | "start": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE -- tsx src/main.ts", 11 | "lint": "eslint .", 12 | "lint:fix": "eslint . --fix", 13 | "typecheck": "tsc --noEmit", 14 | "test": "vitest", 15 | "postinstall": "npx simple-git-hooks" 16 | }, 17 | "dependencies": { 18 | "@dotenvx/dotenvx": "^1.34.0", 19 | "@guiiai/logg": "^1.0.7", 20 | "@proj-airi/server-sdk": "^0.1.4", 21 | "@typeschema/zod": "^0.14.0", 22 | "awilix": "^12.0.4", 23 | "dotenv": "^16.4.7", 24 | "es-toolkit": "^1.32.0", 25 | "eventemitter3": "^5.0.1", 26 | "minecraft-data": "^3.83.1", 27 | "mineflayer": "^4.26.0", 28 | "mineflayer-armor-manager": "^2.0.1", 29 | "mineflayer-auto-eat": "^5.0.0", 30 | "mineflayer-collectblock": "^1.6.0", 31 | "mineflayer-pathfinder": "^2.4.5", 32 | "mineflayer-pvp": "^1.3.2", 33 | "mineflayer-tool": "^1.2.0", 34 | "neuri": "^0.0.21", 35 | "prismarine-block": "^1.21.0", 36 | "prismarine-entity": "^2.5.0", 37 | "prismarine-item": "^1.16.0", 38 | "prismarine-recipe": "^1.3.1", 39 | "prismarine-viewer": "^1.30.0", 40 | "prismarine-windows": "^2.9.0", 41 | "vec3": "^0.1.10", 42 | "zod": "^3.24.1", 43 | "zod-to-json-schema": "^3.24.1" 44 | }, 45 | "devDependencies": { 46 | "@antfu/eslint-config": "^4.1.1", 47 | "eslint": "^9.19.0", 48 | "lint-staged": "^15.4.3", 49 | "simple-git-hooks": "^2.11.1", 50 | "tsx": "^4.19.2", 51 | "typescript": "^5.7.3", 52 | "vitest": "^3.0.4" 53 | }, 54 | "simple-git-hooks": { 55 | "pre-commit": "pnpm lint-staged" 56 | }, 57 | "lint-staged": { 58 | "*": "eslint --fix" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/agents/action/adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { messages, system, user } from 'neuri/openai' 2 | import { beforeAll, describe, expect, it } from 'vitest' 3 | 4 | import { initBot, useBot } from '../../composables/bot' 5 | import { config, initEnv } from '../../composables/config' 6 | import { createNeuriAgent } from '../../composables/neuri' 7 | import { generateSystemBasicPrompt } from '../../libs/llm-agent/prompt' 8 | import { initLogger } from '../../utils/logger' 9 | 10 | describe('openAI agent', { timeout: 0 }, () => { 11 | beforeAll(() => { 12 | initLogger() 13 | initEnv() 14 | initBot({ botConfig: config.bot }) 15 | }) 16 | 17 | it('should initialize the agent', async () => { 18 | const { bot } = useBot() 19 | const agent = await createNeuriAgent(bot) 20 | 21 | await new Promise((resolve) => { 22 | bot.bot.once('spawn', async () => { 23 | const text = await agent.handle( 24 | messages( 25 | system(generateSystemBasicPrompt('airi')), 26 | user('Hello, who are you?'), 27 | ), 28 | async (c) => { 29 | const completion = await c.reroute('query', c.messages, { model: config.openai.model }) 30 | return await completion?.firstContent() 31 | }, 32 | ) 33 | 34 | expect(text?.toLowerCase()).toContain('airi') 35 | 36 | resolve() 37 | }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/agents/action/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { Agent } from 'neuri' 2 | import type { Message } from 'neuri/openai' 3 | import type { Mineflayer } from '../../libs/mineflayer' 4 | import type { PlanStep } from '../planning/adapter' 5 | 6 | import { agent } from 'neuri' 7 | import { system, user } from 'neuri/openai' 8 | 9 | import { BaseLLMHandler } from '../../libs/llm-agent/handler' 10 | import { useLogger } from '../../utils/logger' 11 | import { actionsList } from './tools' 12 | 13 | export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { 14 | const logger = useLogger() 15 | logger.log('Initializing action agent') 16 | let actionAgent = agent('action') 17 | 18 | Object.values(actionsList).forEach((action) => { 19 | actionAgent = actionAgent.tool( 20 | action.name, 21 | action.schema, 22 | async ({ parameters }) => { 23 | logger.withFields({ name: action.name, parameters }).log('Calling action') 24 | mineflayer.memory.actions.push(action) 25 | const fn = action.perform(mineflayer) 26 | return await fn(...Object.values(parameters)) 27 | }, 28 | { description: action.description }, 29 | ) 30 | }) 31 | 32 | return actionAgent.build() 33 | } 34 | 35 | export class ActionLLMHandler extends BaseLLMHandler { 36 | public async executeStep(step: PlanStep): Promise { 37 | const systemPrompt = this.generateActionSystemPrompt() 38 | const userPrompt = this.generateActionUserPrompt(step) 39 | const messages = [system(systemPrompt), user(userPrompt)] 40 | 41 | const result = await this.handleAction(messages) 42 | return result 43 | } 44 | 45 | private generateActionSystemPrompt(): string { 46 | return `You are a Minecraft bot action executor. Your task is to execute a given step using available tools. 47 | You have access to various tools that can help you accomplish tasks. 48 | When using a tool: 49 | 1. Choose the most appropriate tool for the task 50 | 2. Determine the correct parameters based on the context 51 | 3. Handle any errors or unexpected situations 52 | 53 | Remember to: 54 | - Be precise with tool parameters 55 | - Consider the current state of the bot 56 | - Handle failures gracefully` 57 | } 58 | 59 | private generateActionUserPrompt(step: PlanStep): string { 60 | return `Execute this step: ${step.description} 61 | 62 | Suggested tool: ${step.tool} 63 | Params: ${JSON.stringify(step.params)} 64 | 65 | Please use the appropriate tool with the correct parameters to accomplish this step. 66 | If the suggested tool is not appropriate, you may choose a different one.` 67 | } 68 | 69 | public async handleAction(messages: Message[]): Promise { 70 | const result = await this.config.agent.handleStateless(messages, async (context) => { 71 | this.logger.log('Processing action...') 72 | const retryHandler = this.createRetryHandler( 73 | async ctx => (await this.handleCompletion(ctx, 'action', ctx.messages)).content, 74 | ) 75 | return await retryHandler(context) 76 | }) 77 | 78 | if (!result) { 79 | throw new Error('Failed to process action') 80 | } 81 | 82 | return result 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/agents/action/index.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../../libs/mineflayer' 2 | import type { Action } from '../../libs/mineflayer/action' 3 | import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent' 4 | import type { PlanStep } from '../planning/adapter' 5 | 6 | import { useBot } from '../../composables/bot' 7 | import { AbstractAgent } from '../../libs/mineflayer/base-agent' 8 | import { actionsList } from './tools' 9 | 10 | interface ActionState { 11 | executing: boolean 12 | label: string 13 | startTime: number 14 | } 15 | 16 | /** 17 | * ActionAgentImpl implements the ActionAgent interface to handle action execution 18 | * Manages action lifecycle, state tracking and error handling 19 | */ 20 | export class ActionAgentImpl extends AbstractAgent implements ActionAgent { 21 | public readonly type = 'action' as const 22 | private actions: Map 23 | private mineflayer: Mineflayer 24 | private currentActionState: ActionState 25 | 26 | constructor(config: AgentConfig) { 27 | super(config) 28 | this.actions = new Map() 29 | this.mineflayer = useBot().bot 30 | this.currentActionState = { 31 | executing: false, 32 | label: '', 33 | startTime: 0, 34 | } 35 | } 36 | 37 | protected async initializeAgent(): Promise { 38 | this.logger.log('Initializing action agent') 39 | actionsList.forEach(action => this.actions.set(action.name, action)) 40 | 41 | // Set up event listeners 42 | this.on('message', async ({ sender, message }) => { 43 | await this.handleAgentMessage(sender, message) 44 | }) 45 | } 46 | 47 | protected async destroyAgent(): Promise { 48 | this.actions.clear() 49 | this.removeAllListeners() 50 | } 51 | 52 | public async performAction(step: PlanStep): Promise { 53 | if (!this.initialized) { 54 | throw new Error('Action agent not initialized') 55 | } 56 | 57 | const action = this.actions.get(step.tool) 58 | if (!action) { 59 | throw new Error(`Unknown action: ${step.tool}`) 60 | } 61 | 62 | this.logger.withFields({ 63 | action: step.tool, 64 | description: step.description, 65 | params: step.params, 66 | }).log('Performing action') 67 | 68 | // Update action state 69 | this.updateActionState(true, step.description) 70 | 71 | try { 72 | // Execute action with provided parameters 73 | const result = await action.perform(this.mineflayer)(...Object.values(step.params)) 74 | return this.formatActionOutput({ 75 | message: result, 76 | timedout: false, 77 | interrupted: false, 78 | }) 79 | } 80 | catch (error) { 81 | this.logger.withError(error).error('Action failed') 82 | throw error 83 | } 84 | finally { 85 | this.updateActionState(false) 86 | } 87 | } 88 | 89 | public getAvailableActions(): Action[] { 90 | return Array.from(this.actions.values()) 91 | } 92 | 93 | private async handleAgentMessage(sender: string, message: string): Promise { 94 | if (sender === 'system' && message.includes('interrupt') && this.currentActionState.executing) { 95 | // Handle interruption 96 | this.logger.log('Received interrupt request') 97 | // Additional interrupt handling logic here 98 | } 99 | } 100 | 101 | private updateActionState(executing: boolean, label = ''): void { 102 | this.currentActionState = { 103 | executing, 104 | label, 105 | startTime: executing ? Date.now() : this.currentActionState.startTime, 106 | } 107 | } 108 | 109 | private formatActionOutput(result: { message: string | null, timedout: boolean, interrupted: boolean }): string { 110 | if (result.timedout) { 111 | return 'Action timed out' 112 | } 113 | if (result.interrupted) { 114 | return 'Action was interrupted' 115 | } 116 | return result.message || 'Action completed successfully' 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/agents/action/tools.test.ts: -------------------------------------------------------------------------------- 1 | import { messages, system, user } from 'neuri/openai' 2 | import { beforeAll, describe, expect, it } from 'vitest' 3 | 4 | import { initBot, useBot } from '../../composables/bot' 5 | import { config, initEnv } from '../../composables/config' 6 | import { createNeuriAgent } from '../../composables/neuri' 7 | import { generateActionAgentPrompt } from '../../libs/llm-agent/prompt' 8 | import { sleep } from '../../utils/helper' 9 | import { initLogger } from '../../utils/logger' 10 | 11 | describe('actions agent', { timeout: 0 }, () => { 12 | beforeAll(() => { 13 | initLogger() 14 | initEnv() 15 | initBot({ botConfig: config.bot }) 16 | }) 17 | 18 | it('should choose right query command', async () => { 19 | const { bot } = useBot() 20 | const agent = await createNeuriAgent(bot) 21 | 22 | await new Promise((resolve) => { 23 | bot.bot.once('spawn', async () => { 24 | const text = await agent.handle(messages( 25 | system(generateActionAgentPrompt(bot)), 26 | user('What\'s your status?'), 27 | ), async (c) => { 28 | const completion = await c.reroute('query', c.messages, { model: config.openai.model }) 29 | return await completion?.firstContent() 30 | }) 31 | 32 | expect(text?.toLowerCase()).toContain('position') 33 | 34 | resolve() 35 | }) 36 | }) 37 | }) 38 | 39 | it('should choose right action command', async () => { 40 | const { bot } = useBot() 41 | const agent = await createNeuriAgent(bot) 42 | 43 | await new Promise((resolve) => { 44 | bot.bot.on('spawn', async () => { 45 | const text = await agent.handle(messages( 46 | system(generateActionAgentPrompt(bot)), 47 | user('goToPlayer: luoling8192'), 48 | ), async (c) => { 49 | const completion = await c.reroute('action', c.messages, { model: config.openai.model }) 50 | 51 | return await completion?.firstContent() 52 | }) 53 | 54 | expect(text).toContain('goToPlayer') 55 | 56 | await sleep(10000) 57 | resolve() 58 | }) 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/agents/action/tools.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '../../libs/mineflayer' 2 | 3 | import { z } from 'zod' 4 | 5 | import * as skills from '../../skills' 6 | import { collectBlock } from '../../skills/actions/collect-block' 7 | import { discard, equip, putInChest, takeFromChest, viewChest } from '../../skills/actions/inventory' 8 | import { activateNearestBlock, placeBlock } from '../../skills/actions/world-interactions' 9 | import * as world from '../../skills/world' 10 | import { useLogger } from '../../utils/logger' 11 | 12 | // Utils 13 | const pad = (str: string): string => `\n${str}\n` 14 | 15 | function formatInventoryItem(item: string, count: number): string { 16 | return count > 0 ? `\n- ${item}: ${count}` : '' 17 | } 18 | 19 | function formatWearingItem(slot: string, item: string | undefined): string { 20 | return item ? `\n${slot}: ${item}` : '' 21 | } 22 | 23 | export const actionsList: Action[] = [ 24 | { 25 | name: 'stats', 26 | description: 'Get your bot\'s location, health, hunger, and time of day.', 27 | schema: z.object({}), 28 | perform: mineflayer => (): string => { 29 | const status = mineflayer.status.toOneLiner() 30 | return status 31 | }, 32 | }, 33 | { 34 | name: 'inventory', 35 | description: 'Get your bot\'s inventory.', 36 | schema: z.object({}), 37 | perform: mineflayer => (): string => { 38 | const inventory = world.getInventoryCounts(mineflayer) 39 | const items = Object.entries(inventory) 40 | .map(([item, count]) => formatInventoryItem(item, count)) 41 | .join('') 42 | 43 | const wearing = [ 44 | formatWearingItem('Head', mineflayer.bot.inventory.slots[5]?.name), 45 | formatWearingItem('Torso', mineflayer.bot.inventory.slots[6]?.name), 46 | formatWearingItem('Legs', mineflayer.bot.inventory.slots[7]?.name), 47 | formatWearingItem('Feet', mineflayer.bot.inventory.slots[8]?.name), 48 | ].filter(Boolean).join('') 49 | 50 | return pad(`INVENTORY${items || ': Nothing'} 51 | ${mineflayer.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} 52 | WEARING: ${wearing || 'Nothing'}`) 53 | }, 54 | }, 55 | { 56 | name: 'nearbyBlocks', 57 | description: 'Get the blocks near the bot.', 58 | schema: z.object({}), 59 | perform: mineflayer => (): string => { 60 | const blocks = world.getNearbyBlockTypes(mineflayer) 61 | useLogger().withFields({ blocks }).log('nearbyBlocks') 62 | return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) 63 | }, 64 | }, 65 | { 66 | name: 'craftable', 67 | description: 'Get the craftable items with the bot\'s inventory.', 68 | schema: z.object({}), 69 | perform: mineflayer => (): string => { 70 | const craftable = world.getCraftableItems(mineflayer) 71 | return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) 72 | }, 73 | }, 74 | { 75 | name: 'entities', 76 | description: 'Get the nearby players and entities.', 77 | schema: z.object({}), 78 | perform: mineflayer => (): string => { 79 | const players = world.getNearbyPlayerNames(mineflayer) 80 | const entities = world.getNearbyEntityTypes(mineflayer) 81 | .filter((e: string) => e !== 'player' && e !== 'item') 82 | 83 | const result = [ 84 | ...players.map((p: string) => `- Human player: ${p}`), 85 | ...entities.map((e: string) => `- entities: ${e}`), 86 | ] 87 | 88 | return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) 89 | }, 90 | }, 91 | // getNewAction(): Action { 92 | // return { 93 | // name: 'newAction', 94 | // description: 'Perform new and unknown custom behaviors that are not available as a command.', 95 | // schema: z.object({ 96 | // prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), 97 | // }), 98 | // perform: (mineflayer: BotContext) => async (prompt: string) => { 99 | // if (!settings.allow_insecure_coding) 100 | // return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' 101 | // return await ctx.coder.generateCode(mineflayer.history) 102 | // }, 103 | // } 104 | // }, 105 | 106 | // todo: must 'stop now' can be used to stop the agent 107 | { 108 | name: 'stop', 109 | description: 'Force stop all actions and commands that are currently executing.', 110 | schema: z.object({}), 111 | perform: mineflayer => async () => { 112 | // await ctx.actions.stop() 113 | // ctx.clearBotLogs() 114 | // ctx.actions.cancelResume() 115 | // ctx.bot.emit('idle') 116 | 117 | mineflayer.emit('interrupt') 118 | 119 | const msg = 'Agent stopped.' 120 | // if (mineflayer.self_prompter.on) 121 | // msg += ' Self-prompting still active.' 122 | return msg 123 | }, 124 | }, 125 | 126 | // getStfuAction(): Action { 127 | // return { 128 | // name: 'stfu', 129 | // description: 'Stop all chatting and self prompting, but continue current action.', 130 | // schema: z.object({}), 131 | // perform: (mineflayer: BotContext) => async () => { 132 | // ctx.openChat('Shutting up.') 133 | // ctx.shutUp() 134 | // return 'Shutting up.' 135 | // }, 136 | // } 137 | // }, 138 | 139 | // getRestartAction(): Action { 140 | // return { 141 | // name: 'restart', 142 | // description: 'Restart the agent process.', 143 | // schema: z.object({}), 144 | // perform: (mineflayer: BotContext) => async () => { 145 | // ctx.cleanKill() 146 | // return 'Restarting agent...' 147 | // }, 148 | // } 149 | // }, 150 | 151 | // getClearChatAction(): Action { 152 | // return { 153 | // name: 'clearChat', 154 | // description: 'Clear the chat history.', 155 | // schema: z.object({}), 156 | // perform: (mineflayer: BotContext) => async () => { 157 | // ctx.history.clear() 158 | // return `${ctx.name}'s chat history was cleared, starting new conversation from scratch.` 159 | // }, 160 | // } 161 | // }, 162 | { 163 | name: 'goToPlayer', 164 | description: 'Go to the given player.', 165 | schema: z.object({ 166 | player_name: z.string().describe('The name of the player to go to.'), 167 | closeness: z.number().describe('How close to get to the player.').min(0), 168 | }), 169 | perform: mineflayer => async (player_name: string, closeness: number) => { 170 | await skills.goToPlayer(mineflayer, player_name, closeness) 171 | return 'Moving to player...' 172 | }, 173 | }, 174 | 175 | { 176 | name: 'followPlayer', 177 | description: 'Endlessly follow the given player.', 178 | schema: z.object({ 179 | player_name: z.string().describe('name of the player to follow.'), 180 | follow_dist: z.number().describe('The distance to follow from.').min(0), 181 | }), 182 | perform: mineflayer => async (player_name: string, follow_dist: number) => { 183 | await skills.followPlayer(mineflayer, player_name, follow_dist) 184 | return 'Following player...' 185 | }, 186 | }, 187 | 188 | { 189 | name: 'goToCoordinates', 190 | description: 'Go to the given x, y, z location.', 191 | schema: z.object({ 192 | x: z.number().describe('The x coordinate.'), 193 | y: z.number().describe('The y coordinate.').min(-64).max(320), 194 | z: z.number().describe('The z coordinate.'), 195 | closeness: z.number().describe('How close to get to the location.').min(0), 196 | }), 197 | perform: mineflayer => async (x: number, y: number, z: number, closeness: number) => { 198 | await skills.goToPosition(mineflayer, x, y, z, closeness) 199 | return 'Moving to coordinates...' 200 | }, 201 | }, 202 | 203 | { 204 | name: 'searchForBlock', 205 | description: 'Find and go to the nearest block of a given type in a given range.', 206 | schema: z.object({ 207 | type: z.string().describe('The block type to go to.'), 208 | search_range: z.number().describe('The range to search for the block.').min(32).max(512), 209 | }), 210 | perform: mineflayer => async (block_type: string, range: number) => { 211 | await skills.goToNearestBlock(mineflayer, block_type, 4, range) 212 | return 'Searching for block...' 213 | }, 214 | }, 215 | 216 | { 217 | name: 'searchForEntity', 218 | description: 'Find and go to the nearest entity of a given type in a given range.', 219 | schema: z.object({ 220 | type: z.string().describe('The type of entity to go to.'), 221 | search_range: z.number().describe('The range to search for the entity.').min(32).max(512), 222 | }), 223 | perform: mineflayer => async (entity_type: string, range: number) => { 224 | await skills.goToNearestEntity(mineflayer, entity_type, 4, range) 225 | return 'Searching for entity...' 226 | }, 227 | }, 228 | 229 | { 230 | name: 'moveAway', 231 | description: 'Move away from the current location in any direction by a given distance.', 232 | schema: z.object({ 233 | distance: z.number().describe('The distance to move away.').min(0), 234 | }), 235 | perform: mineflayer => async (distance: number) => { 236 | await skills.moveAway(mineflayer, distance) 237 | return 'Moving away...' 238 | }, 239 | }, 240 | 241 | { 242 | name: 'givePlayer', 243 | description: 'Give the specified item to the given player.', 244 | schema: z.object({ 245 | player_name: z.string().describe('The name of the player to give the item to.'), 246 | item_name: z.string().describe('The name of the item to give.'), 247 | num: z.number().int().describe('The number of items to give.').min(1), 248 | }), 249 | perform: mineflayer => async (player_name: string, item_name: string, num: number) => { 250 | await skills.giveToPlayer(mineflayer, item_name, player_name, num) 251 | return 'Giving items to player...' 252 | }, 253 | }, 254 | 255 | { 256 | name: 'consume', 257 | description: 'Eat/drink the given item.', 258 | schema: z.object({ 259 | item_name: z.string().describe('The name of the item to consume.'), 260 | }), 261 | perform: mineflayer => async (item_name: string) => { 262 | await skills.consume(mineflayer, item_name) 263 | return 'Consuming item...' 264 | }, 265 | }, 266 | 267 | { 268 | name: 'equip', 269 | description: 'Equip the given item.', 270 | schema: z.object({ 271 | item_name: z.string().describe('The name of the item to equip.'), 272 | }), 273 | perform: mineflayer => async (item_name: string) => { 274 | await equip(mineflayer, item_name) 275 | return 'Equipping item...' 276 | }, 277 | }, 278 | 279 | { 280 | name: 'putInChest', 281 | description: 'Put the given item in the nearest chest.', 282 | schema: z.object({ 283 | item_name: z.string().describe('The name of the item to put in the chest.'), 284 | num: z.number().int().describe('The number of items to put in the chest.').min(1), 285 | }), 286 | perform: mineflayer => async (item_name: string, num: number) => { 287 | await putInChest(mineflayer, item_name, num) 288 | return 'Putting items in chest...' 289 | }, 290 | }, 291 | 292 | { 293 | name: 'takeFromChest', 294 | description: 'Take the given items from the nearest chest.', 295 | schema: z.object({ 296 | item_name: z.string().describe('The name of the item to take.'), 297 | num: z.number().int().describe('The number of items to take.').min(1), 298 | }), 299 | perform: mineflayer => async (item_name: string, num: number) => { 300 | await takeFromChest(mineflayer, item_name, num) 301 | return 'Taking items from chest...' 302 | }, 303 | }, 304 | 305 | { 306 | name: 'viewChest', 307 | description: 'View the items/counts of the nearest chest.', 308 | schema: z.object({}), 309 | perform: mineflayer => async () => { 310 | await viewChest(mineflayer) 311 | return 'Viewing chest contents...' 312 | }, 313 | }, 314 | 315 | { 316 | name: 'discard', 317 | description: 'Discard the given item from the inventory.', 318 | schema: z.object({ 319 | item_name: z.string().describe('The name of the item to discard.'), 320 | num: z.number().int().describe('The number of items to discard.').min(1), 321 | }), 322 | perform: mineflayer => async (item_name: string, num: number) => { 323 | await discard(mineflayer, item_name, num) 324 | return 'Discarding items...' 325 | }, 326 | }, 327 | 328 | { 329 | name: 'collectBlocks', 330 | description: 'Collect the nearest blocks of a given type.', 331 | schema: z.object({ 332 | type: z.string().describe('The block type to collect.'), 333 | num: z.number().int().describe('The number of blocks to collect.').min(1), 334 | }), 335 | perform: mineflayer => async (type: string, num: number) => { 336 | await collectBlock(mineflayer, type, num) 337 | return 'Collecting blocks...' 338 | }, 339 | }, 340 | 341 | { 342 | name: 'craftRecipe', 343 | description: 'Craft the given recipe a given number of times.', 344 | schema: z.object({ 345 | recipe_name: z.string().describe('The name of the output item to craft.'), 346 | 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), 347 | }), 348 | perform: mineflayer => async (recipe_name: string, num: number) => { 349 | await skills.craftRecipe(mineflayer, recipe_name, num) 350 | return 'Crafting items...' 351 | }, 352 | }, 353 | 354 | { 355 | name: 'smeltItem', 356 | description: 'Smelt the given item the given number of times.', 357 | schema: z.object({ 358 | item_name: z.string().describe('The name of the input item to smelt.'), 359 | num: z.number().int().describe('The number of times to smelt the item.').min(1), 360 | }), 361 | perform: mineflayer => async (item_name: string, num: number) => { 362 | await skills.smeltItem(mineflayer, item_name, num) 363 | return 'Smelting items...' 364 | }, 365 | }, 366 | 367 | { 368 | name: 'clearFurnace', 369 | description: 'Take all items out of the nearest furnace.', 370 | schema: z.object({}), 371 | perform: mineflayer => async () => { 372 | await skills.clearNearestFurnace(mineflayer) 373 | return 'Clearing furnace...' 374 | }, 375 | }, 376 | 377 | { 378 | name: 'placeHere', 379 | description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.', 380 | schema: z.object({ 381 | type: z.string().describe('The block type to place.'), 382 | }), 383 | perform: mineflayer => async (type: string) => { 384 | const pos = mineflayer.bot.entity.position 385 | await placeBlock(mineflayer, type, pos.x, pos.y, pos.z) 386 | return 'Placing block...' 387 | }, 388 | }, 389 | 390 | { 391 | name: 'attack', 392 | description: 'Attack and kill the nearest entity of a given type.', 393 | schema: z.object({ 394 | type: z.string().describe('The type of entity to attack.'), 395 | }), 396 | perform: mineflayer => async (type: string) => { 397 | await skills.attackNearest(mineflayer, type, true) 398 | return 'Attacking entity...' 399 | }, 400 | }, 401 | 402 | { 403 | name: 'attackPlayer', 404 | description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', 405 | schema: z.object({ 406 | player_name: z.string().describe('The name of the player to attack.'), 407 | }), 408 | perform: mineflayer => async (player_name: string) => { 409 | const player = mineflayer.bot.players[player_name]?.entity 410 | if (!player) { 411 | skills.log(mineflayer, `Could not find player ${player_name}.`) 412 | return 'Player not found' 413 | } 414 | await skills.attackEntity(mineflayer, player, true) 415 | return 'Attacking player...' 416 | }, 417 | }, 418 | 419 | { 420 | name: 'goToBed', 421 | description: 'Go to the nearest bed and sleep.', 422 | schema: z.object({}), 423 | perform: mineflayer => async () => { 424 | await skills.goToBed(mineflayer) 425 | return 'Going to bed...' 426 | }, 427 | }, 428 | 429 | { 430 | name: 'activate', 431 | description: 'Activate the nearest object of a given type.', 432 | schema: z.object({ 433 | type: z.string().describe('The type of object to activate.'), 434 | }), 435 | perform: mineflayer => async (type: string) => { 436 | await activateNearestBlock(mineflayer, type) 437 | return 'Activating block...' 438 | }, 439 | }, 440 | 441 | { 442 | name: 'stay', 443 | description: 'Stay in the current location no matter what. Pauses all modes.', 444 | schema: z.object({ 445 | type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), 446 | }), 447 | perform: mineflayer => async (seconds: number) => { 448 | await skills.stay(mineflayer, seconds) 449 | return 'Staying in place...' 450 | }, 451 | }, 452 | // getSetModeAction(): Action { 453 | // return { 454 | // name: 'setMode', 455 | // description: 'Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.', 456 | // schema: z.object({ 457 | // mode_name: z.string().describe('The name of the mode to enable.'), 458 | // on: z.boolean().describe('Whether to enable or disable the mode.'), 459 | // }), 460 | // perform: (mineflayer: BotContext) => async (mode_name: string, on: boolean) => { 461 | // const modes = ctx.bot.modes 462 | // if (!modes.exists(mode_name)) 463 | // return `Mode ${mode_name} does not exist.${modes.getDocs()}` 464 | // if (modes.isOn(mode_name) === on) 465 | // return `Mode ${mode_name} is already ${on ? 'on' : 'off'}.` 466 | // modes.setOn(mode_name, on) 467 | // return `Mode ${mode_name} is now ${on ? 'on' : 'off'}.` 468 | // }, 469 | // } 470 | // }, 471 | 472 | // getGoalAction(): Action { 473 | // return { 474 | // name: 'goal', 475 | // description: 'Set a goal prompt to endlessly work towards with continuous self-prompting.', 476 | // schema: z.object({ 477 | // selfPrompt: z.string().describe('The goal prompt.'), 478 | // }), 479 | // perform: (mineflayer: BotContext) => async (prompt: string) => { 480 | // if (convoManager.inConversation()) { 481 | // ctx.self_prompter.setPrompt(prompt) 482 | // convoManager.scheduleSelfPrompter() 483 | // } 484 | // else { 485 | // ctx.self_prompter.start(prompt) 486 | // } 487 | // return 'Goal set...' 488 | // }, 489 | // } 490 | // }, 491 | 492 | // getEndGoalAction(): Action { 493 | // return { 494 | // name: 'endGoal', 495 | // description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', 496 | // schema: z.object({}), 497 | // perform: (mineflayer: BotContext) => async () => { 498 | // ctx.self_prompter.stop() 499 | // convoManager.cancelSelfPrompter() 500 | // return 'Self-prompting stopped.' 501 | // }, 502 | // } 503 | // }, 504 | 505 | // getStartConversationAction(): Action { 506 | // return { 507 | // name: 'startConversation', 508 | // description: 'Start a conversation with a player. Use for bots only.', 509 | // schema: z.object({ 510 | // player_name: z.string().describe('The name of the player to send the message to.'), 511 | // message: z.string().describe('The message to send.'), 512 | // }), 513 | // perform: (mineflayer: BotContext) => async (player_name: string, message: string) => { 514 | // if (!convoManager.isOtherAgent(player_name)) 515 | // return `${player_name} is not a bot, cannot start conversation.` 516 | // if (convoManager.inConversation() && !convoManager.inConversation(player_name)) 517 | // convoManager.forceEndCurrentConversation() 518 | // else if (convoManager.inConversation(player_name)) 519 | // ctx.history.add('system', `You are already in conversation with ${player_name}. Don't use this command to talk to them.`) 520 | // convoManager.startConversation(player_name, message) 521 | // }, 522 | // } 523 | // }, 524 | 525 | // getEndConversationAction(): Action { 526 | // return { 527 | // name: 'endConversation', 528 | // description: 'End the conversation with the given player.', 529 | // schema: z.object({ 530 | // player_name: z.string().describe('The name of the player to end the conversation with.'), 531 | // }), 532 | // perform: (mineflayer: BotContext) => async (player_name: string) => { 533 | // if (!convoManager.inConversation(player_name)) 534 | // return `Not in conversation with ${player_name}.` 535 | // convoManager.endConversation(player_name) 536 | // return `Converstaion with ${player_name} ended.` 537 | // }, 538 | // } 539 | // }, 540 | ] 541 | -------------------------------------------------------------------------------- /src/agents/chat/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { ChatHistory } from './types' 2 | 3 | import { system, user } from 'neuri/openai' 4 | 5 | import { BaseLLMHandler } from '../../libs/llm-agent/handler' 6 | 7 | export function generateChatAgentPrompt(): string { 8 | return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals. 9 | 10 | Guidelines: 11 | 1. Be friendly and helpful 12 | 2. Keep responses concise but informative 13 | 3. Use game-appropriate language 14 | 4. Acknowledge player's emotions and intentions 15 | 5. Ask for clarification when needed 16 | 6. Remember context from previous messages 17 | 7. Be proactive in suggesting helpful actions 18 | 19 | You can: 20 | - Answer questions about the game 21 | - Help with tasks and crafting 22 | - Give directions and suggestions 23 | - Engage in casual conversation 24 | - Coordinate with other bots 25 | 26 | Remember that you're operating in a Minecraft world and should maintain that context in your responses.` 27 | } 28 | 29 | export class ChatLLMHandler extends BaseLLMHandler { 30 | public async generateResponse( 31 | message: string, 32 | history: ChatHistory[], 33 | ): Promise { 34 | const systemPrompt = generateChatAgentPrompt() 35 | const chatHistory = this.formatChatHistory(history, this.config.maxContextLength ?? 10) 36 | const messages = [ 37 | system(systemPrompt), 38 | ...chatHistory, 39 | user(message), 40 | ] 41 | 42 | const result = await this.config.agent.handleStateless(messages, async (context) => { 43 | this.logger.log('Generating response...') 44 | const retryHandler = this.createRetryHandler( 45 | async ctx => (await this.handleCompletion(ctx, 'chat', ctx.messages)).content, 46 | ) 47 | return await retryHandler(context) 48 | }) 49 | 50 | if (!result) { 51 | throw new Error('Failed to generate response') 52 | } 53 | 54 | return result 55 | } 56 | 57 | private formatChatHistory( 58 | history: ChatHistory[], 59 | maxLength: number, 60 | ): Array<{ role: 'user' | 'assistant', content: string }> { 61 | const recentHistory = history.slice(-maxLength) 62 | return recentHistory.map(entry => ({ 63 | role: entry.sender === 'bot' ? 'assistant' : 'user', 64 | content: entry.message, 65 | })) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/agents/chat/index.ts: -------------------------------------------------------------------------------- 1 | import type { ChatAgent } from '../../libs/mineflayer/base-agent' 2 | import type { ChatAgentConfig, ChatContext } from './types' 3 | 4 | import { AbstractAgent } from '../../libs/mineflayer/base-agent' 5 | import { generateChatResponse } from './llm' 6 | 7 | export class ChatAgentImpl extends AbstractAgent implements ChatAgent { 8 | public readonly type = 'chat' as const 9 | private activeChats: Map 10 | private maxHistoryLength: number 11 | private idleTimeout: number 12 | private llmConfig: ChatAgentConfig['llm'] 13 | 14 | constructor(config: ChatAgentConfig) { 15 | super(config) 16 | this.activeChats = new Map() 17 | this.maxHistoryLength = config.maxHistoryLength ?? 50 18 | this.idleTimeout = config.idleTimeout ?? 5 * 60 * 1000 // 5 minutes 19 | this.llmConfig = config.llm 20 | } 21 | 22 | protected async initializeAgent(): Promise { 23 | this.logger.log('Initializing chat agent') 24 | 25 | this.on('message', async ({ sender, message }) => { 26 | await this.handleAgentMessage(sender, message) 27 | }) 28 | 29 | setInterval(() => { 30 | this.checkIdleChats() 31 | }, 60 * 1000) 32 | } 33 | 34 | protected async destroyAgent(): Promise { 35 | this.activeChats.clear() 36 | this.removeAllListeners() 37 | } 38 | 39 | public async processMessage(message: string, sender: string): Promise { 40 | if (!this.initialized) { 41 | throw new Error('Chat agent not initialized') 42 | } 43 | 44 | this.logger.withFields({ sender, message }).log('Processing message') 45 | 46 | try { 47 | // Get or create chat context 48 | const context = this.getOrCreateContext(sender) 49 | 50 | // Add message to history 51 | this.addToHistory(context, sender, message) 52 | 53 | // Update last activity time 54 | context.lastUpdate = Date.now() 55 | 56 | // Generate response using LLM 57 | const response = await this.generateResponse(message, context) 58 | 59 | // Add response to history 60 | this.addToHistory(context, this.id, response) 61 | 62 | return response 63 | } 64 | catch (error) { 65 | this.logger.withError(error).error('Failed to process message') 66 | throw error 67 | } 68 | } 69 | 70 | public startConversation(player: string): void { 71 | if (!this.initialized) { 72 | throw new Error('Chat agent not initialized') 73 | } 74 | 75 | this.logger.withField('player', player).log('Starting conversation') 76 | 77 | const context = this.getOrCreateContext(player) 78 | context.startTime = Date.now() 79 | context.lastUpdate = Date.now() 80 | } 81 | 82 | public endConversation(player: string): void { 83 | if (!this.initialized) { 84 | throw new Error('Chat agent not initialized') 85 | } 86 | 87 | this.logger.withField('player', player).log('Ending conversation') 88 | 89 | if (this.activeChats.has(player)) { 90 | const context = this.activeChats.get(player)! 91 | // Archive chat history if needed 92 | this.archiveChat(context) 93 | this.activeChats.delete(player) 94 | } 95 | } 96 | 97 | private getOrCreateContext(player: string): ChatContext { 98 | let context = this.activeChats.get(player) 99 | if (!context) { 100 | context = { 101 | player, 102 | startTime: Date.now(), 103 | lastUpdate: Date.now(), 104 | history: [], 105 | } 106 | this.activeChats.set(player, context) 107 | } 108 | return context 109 | } 110 | 111 | private addToHistory(context: ChatContext, sender: string, message: string): void { 112 | context.history.push({ 113 | sender, 114 | message, 115 | timestamp: Date.now(), 116 | }) 117 | 118 | // Trim history if too long 119 | if (context.history.length > this.maxHistoryLength) { 120 | context.history = context.history.slice(-this.maxHistoryLength) 121 | } 122 | } 123 | 124 | private async generateResponse(message: string, context: ChatContext): Promise { 125 | return await generateChatResponse(message, context.history, { 126 | agent: this.llmConfig.agent, 127 | model: this.llmConfig.model, 128 | maxContextLength: this.maxHistoryLength, 129 | }) 130 | } 131 | 132 | private checkIdleChats(): void { 133 | const now = Date.now() 134 | for (const [player, context] of this.activeChats.entries()) { 135 | if (now - context.lastUpdate > this.idleTimeout) { 136 | this.logger.withField('player', player).log('Ending idle conversation') 137 | this.endConversation(player) 138 | } 139 | } 140 | } 141 | 142 | private async archiveChat(context: ChatContext): Promise { 143 | // Archive chat history to persistent storage if needed 144 | this.logger.withFields({ 145 | player: context.player, 146 | messageCount: context.history.length, 147 | duration: Date.now() - context.startTime, 148 | }).log('Archiving chat history') 149 | } 150 | 151 | private async handleAgentMessage(sender: string, message: string): Promise { 152 | if (sender === 'system') { 153 | if (message.includes('interrupt')) { 154 | // Handle system interrupt 155 | for (const player of this.activeChats.keys()) { 156 | this.endConversation(player) 157 | } 158 | } 159 | } 160 | else { 161 | // Handle messages from other agents 162 | const context = this.activeChats.get(sender) 163 | if (context) { 164 | await this.processMessage(message, sender) 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/agents/chat/llm.ts: -------------------------------------------------------------------------------- 1 | import type { Agent, Neuri } from 'neuri' 2 | import type { ChatHistory } from './types' 3 | 4 | import { agent } from 'neuri' 5 | import { system, user } from 'neuri/openai' 6 | 7 | import { config as appConfig } from '../../composables/config' 8 | import { toRetriable } from '../../utils/helper' 9 | import { useLogger } from '../../utils/logger' 10 | import { generateChatAgentPrompt } from './adapter' 11 | 12 | interface LLMChatConfig { 13 | agent: Neuri 14 | model?: string 15 | retryLimit?: number 16 | delayInterval?: number 17 | maxContextLength?: number 18 | } 19 | 20 | export async function createChatNeuriAgent(): Promise { 21 | return agent('chat').build() 22 | } 23 | 24 | export async function generateChatResponse( 25 | message: string, 26 | history: ChatHistory[], 27 | config: LLMChatConfig, 28 | ): Promise { 29 | const systemPrompt = generateChatAgentPrompt() 30 | const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10) 31 | const userPrompt = message 32 | const logger = useLogger() 33 | 34 | const messages = [ 35 | system(systemPrompt), 36 | ...chatHistory, 37 | user(userPrompt), 38 | ] 39 | 40 | const content = await config.agent.handleStateless(messages, async (c) => { 41 | logger.log('Generating response...') 42 | 43 | const handleCompletion = async (c: any): Promise => { 44 | const completion = await c.reroute('chat', c.messages, { 45 | model: config.model ?? appConfig.openai.model, 46 | }) 47 | 48 | if (!completion || 'error' in completion) { 49 | logger.withFields(c).error('Completion failed') 50 | throw new Error(completion?.error?.message ?? 'Unknown error') 51 | } 52 | 53 | const content = await completion.firstContent() 54 | logger.withFields({ usage: completion.usage, content }).log('Response generated') 55 | return content 56 | } 57 | 58 | const retriableHandler = toRetriable( 59 | config.retryLimit ?? 3, 60 | config.delayInterval ?? 1000, 61 | handleCompletion, 62 | ) 63 | 64 | return await retriableHandler(c) 65 | }) 66 | 67 | if (!content) { 68 | throw new Error('Failed to generate response') 69 | } 70 | 71 | return content 72 | } 73 | 74 | function formatChatHistory( 75 | history: ChatHistory[], 76 | maxLength: number, 77 | ): Array<{ role: 'user' | 'assistant', content: string }> { 78 | // Take the most recent messages up to maxLength 79 | const recentHistory = history.slice(-maxLength) 80 | 81 | return recentHistory.map(entry => ({ 82 | role: entry.sender === 'bot' ? 'assistant' : 'user', 83 | content: entry.message, 84 | })) 85 | } 86 | -------------------------------------------------------------------------------- /src/agents/chat/types.ts: -------------------------------------------------------------------------------- 1 | import type { Neuri } from 'neuri' 2 | 3 | export interface ChatHistory { 4 | sender: string 5 | message: string 6 | timestamp: number 7 | } 8 | 9 | export interface ChatContext { 10 | player: string 11 | startTime: number 12 | lastUpdate: number 13 | history: ChatHistory[] 14 | } 15 | 16 | export interface ChatAgentConfig { 17 | id: string 18 | type: 'chat' 19 | llm: { 20 | agent: Neuri 21 | model?: string 22 | } 23 | maxHistoryLength?: number 24 | idleTimeout?: number 25 | } 26 | -------------------------------------------------------------------------------- /src/agents/memory/index.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'neuri/openai' 2 | import type { Action } from '../../libs/mineflayer' 3 | import type { AgentConfig, MemoryAgent } from '../../libs/mineflayer/base-agent' 4 | import type { Logger } from '../../utils/logger' 5 | 6 | import { Memory } from '../../libs/mineflayer/memory' 7 | import { useLogger } from '../../utils/logger' 8 | 9 | export class MemoryAgentImpl implements MemoryAgent { 10 | public readonly type = 'memory' as const 11 | public readonly id: string 12 | private memory: Map 13 | private initialized: boolean 14 | private memoryInstance: Memory 15 | private logger: Logger 16 | 17 | constructor(config: AgentConfig) { 18 | this.id = config.id 19 | this.memory = new Map() 20 | this.initialized = false 21 | this.memoryInstance = new Memory() 22 | this.logger = useLogger() 23 | } 24 | 25 | async init(): Promise { 26 | if (this.initialized) { 27 | return 28 | } 29 | 30 | this.logger.log('Initializing memory agent') 31 | this.initialized = true 32 | } 33 | 34 | async destroy(): Promise { 35 | this.memory.clear() 36 | this.initialized = false 37 | } 38 | 39 | remember(key: string, value: unknown): void { 40 | if (!this.initialized) { 41 | throw new Error('Memory agent not initialized') 42 | } 43 | 44 | this.logger.withFields({ key, value }).log('Storing memory') 45 | this.memory.set(key, value) 46 | } 47 | 48 | recall(key: string): T | undefined { 49 | if (!this.initialized) { 50 | throw new Error('Memory agent not initialized') 51 | } 52 | 53 | const value = this.memory.get(key) as T | undefined 54 | this.logger.withFields({ key, value }).log('Recalling memory') 55 | return value 56 | } 57 | 58 | forget(key: string): void { 59 | if (!this.initialized) { 60 | throw new Error('Memory agent not initialized') 61 | } 62 | 63 | this.logger.withFields({ key }).log('Forgetting memory') 64 | this.memory.delete(key) 65 | } 66 | 67 | getMemorySnapshot(): Record { 68 | if (!this.initialized) { 69 | throw new Error('Memory agent not initialized') 70 | } 71 | 72 | return Object.fromEntries(this.memory.entries()) 73 | } 74 | 75 | addChatMessage(message: Message): void { 76 | if (!this.initialized) { 77 | throw new Error('Memory agent not initialized') 78 | } 79 | 80 | this.memoryInstance.chatHistory.push(message) 81 | this.logger.withFields({ message }).log('Adding chat message to memory') 82 | } 83 | 84 | addAction(action: Action): void { 85 | if (!this.initialized) { 86 | throw new Error('Memory agent not initialized') 87 | } 88 | 89 | this.memoryInstance.actions.push(action) 90 | this.logger.withFields({ action }).log('Adding action to memory') 91 | } 92 | 93 | getChatHistory(): Message[] { 94 | if (!this.initialized) { 95 | throw new Error('Memory agent not initialized') 96 | } 97 | 98 | return this.memoryInstance.chatHistory 99 | } 100 | 101 | getActions(): Action[] { 102 | if (!this.initialized) { 103 | throw new Error('Memory agent not initialized') 104 | } 105 | 106 | return this.memoryInstance.actions 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/agents/planning/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { Agent } from 'neuri' 2 | import type { Action } from '../../libs/mineflayer/action' 3 | 4 | import { agent } from 'neuri' 5 | import { system, user } from 'neuri/openai' 6 | 7 | import { BaseLLMHandler } from '../../libs/llm-agent/handler' 8 | 9 | export async function createPlanningNeuriAgent(): Promise { 10 | return agent('planning').build() 11 | } 12 | 13 | export interface PlanStep { 14 | description: string 15 | tool: string 16 | params: Record 17 | } 18 | 19 | export class PlanningLLMHandler extends BaseLLMHandler { 20 | public async generatePlan( 21 | goal: string, 22 | availableActions: Action[], 23 | sender: string, 24 | feedback?: string, 25 | ): Promise { 26 | const systemPrompt = this.generatePlanningAgentSystemPrompt(availableActions) 27 | const userPrompt = this.generatePlanningAgentUserPrompt(goal, sender, feedback) 28 | const messages = [system(systemPrompt), user(userPrompt)] 29 | 30 | const result = await this.config.agent.handleStateless(messages, async (context) => { 31 | this.logger.log('Generating plan...') 32 | const retryHandler = this.createRetryHandler( 33 | async ctx => (await this.handleCompletion(ctx, 'planning', ctx.messages)).content, 34 | ) 35 | return await retryHandler(context) 36 | }) 37 | 38 | if (!result) { 39 | throw new Error('Failed to generate plan') 40 | } 41 | 42 | return this.parsePlanContent(result) 43 | } 44 | 45 | private parsePlanContent(content: string): PlanStep[] { 46 | // Split content into steps (numbered list) 47 | const steps = content.split(/\d+\./).filter(step => step.trim().length > 0) 48 | 49 | return steps.map((step) => { 50 | const lines = step.trim().split('\n') 51 | const description = lines[0].trim() 52 | 53 | // Extract tool name and parameters 54 | let tool = '' 55 | const params: Record = {} 56 | 57 | for (const line of lines) { 58 | const trimmed = line.trim() 59 | 60 | // Extract tool name 61 | if (trimmed.startsWith('Tool:')) { 62 | tool = trimmed.split(':')[1].trim() 63 | continue 64 | } 65 | 66 | // Extract parameters 67 | if (trimmed === 'Params:') { 68 | let i = lines.indexOf(line) + 1 69 | while (i < lines.length) { 70 | const paramLine = lines[i].trim() 71 | if (paramLine === '') 72 | break 73 | 74 | const paramMatch = paramLine.match(/(\w+):\s*(.+)/) 75 | if (paramMatch) { 76 | const [, key, value] = paramMatch 77 | // Try to parse numbers and booleans 78 | if (value === 'true') 79 | params[key] = true 80 | else if (value === 'false') 81 | params[key] = false 82 | else if (/^\d+$/.test(value)) 83 | params[key] = Number.parseInt(value) 84 | else if (/^\d*\.\d+$/.test(value)) 85 | params[key] = Number.parseFloat(value) 86 | else params[key] = value.trim() 87 | } 88 | i++ 89 | } 90 | } 91 | } 92 | 93 | return { 94 | description, 95 | tool, 96 | params, 97 | } 98 | }) 99 | } 100 | 101 | private generatePlanningAgentSystemPrompt(availableActions: Action[]): string { 102 | const actionsList = availableActions 103 | .map((action) => { 104 | const params = Object.keys(action.schema.shape) 105 | .map(name => ` - ${name}`) 106 | .join('\n') 107 | return `- ${action.name}: ${action.description}\n Parameters:\n${params}` 108 | }) 109 | .join('\n\n') 110 | 111 | return `You are a Minecraft bot planner. Break down goals into simple action steps. 112 | 113 | Available tools: 114 | ${actionsList} 115 | 116 | Format each step as: 117 | 1. Action description (short, direct command) 118 | 2. Tool name 119 | 3. Required parameters 120 | 121 | Example: 122 | 1. Follow player 123 | Tool: followPlayer 124 | Params: 125 | player: luoling8192 126 | follow_dist: 3 127 | 128 | Keep steps: 129 | - Short and direct 130 | - Action-focused 131 | - Parameters precise 132 | - Generate all steps at once` 133 | } 134 | 135 | private generatePlanningAgentUserPrompt(goal: string, sender: string, feedback?: string): string { 136 | let prompt = `${sender}: ${goal} 137 | 138 | Generate minimal steps with exact parameters. 139 | Use the sender's name (${sender}) for player-related parameters.` 140 | 141 | if (feedback) { 142 | prompt += `\n\nPrevious attempt failed: ${feedback}` 143 | } 144 | return prompt 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/composables/bot.ts: -------------------------------------------------------------------------------- 1 | import type { MineflayerOptions } from '../libs/mineflayer' 2 | 3 | import { Mineflayer } from '../libs/mineflayer' 4 | 5 | // Singleton instance of the Mineflayer bot 6 | let botInstance: Mineflayer | null = null 7 | 8 | /** 9 | * Initialize a new Mineflayer bot instance. 10 | * Follows singleton pattern to ensure only one bot exists at a time. 11 | */ 12 | export async function initBot(options: MineflayerOptions): Promise<{ bot: Mineflayer }> { 13 | if (botInstance) { 14 | throw new Error('Bot already initialized') 15 | } 16 | 17 | botInstance = await Mineflayer.asyncBuild(options) 18 | return { bot: botInstance } 19 | } 20 | 21 | /** 22 | * Get the current bot instance. 23 | * Throws if bot is not initialized. 24 | */ 25 | export function useBot(): { bot: Mineflayer } { 26 | if (!botInstance) { 27 | throw new Error('Bot not initialized') 28 | } 29 | 30 | return { 31 | bot: botInstance, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/composables/config.ts: -------------------------------------------------------------------------------- 1 | import type { BotOptions } from 'mineflayer' 2 | 3 | import { env } from 'node:process' 4 | 5 | import { useLogger } from '../utils/logger' 6 | 7 | const logger = useLogger() 8 | 9 | // Configuration interfaces 10 | interface OpenAIConfig { 11 | apiKey: string 12 | baseUrl: string 13 | model: string 14 | reasoningModel: string 15 | } 16 | 17 | interface AiriConfig { 18 | wsBaseUrl: string 19 | clientName: string 20 | } 21 | 22 | interface Config { 23 | openai: OpenAIConfig 24 | bot: BotOptions 25 | airi: AiriConfig 26 | } 27 | 28 | // Helper functions for type-safe environment variable parsing 29 | function getEnvVar(key: string, defaultValue: string): string { 30 | return env[key] || defaultValue 31 | } 32 | 33 | function getEnvNumber(key: string, defaultValue: number): number { 34 | return Number.parseInt(env[key] || String(defaultValue)) 35 | } 36 | 37 | // Default configurations 38 | const defaultConfig: Config = { 39 | openai: { 40 | apiKey: '', 41 | baseUrl: '', 42 | model: '', 43 | reasoningModel: '', 44 | }, 45 | bot: { 46 | username: 'airi-bot', 47 | host: 'localhost', 48 | port: 25565, 49 | password: '', 50 | version: '1.20', 51 | }, 52 | airi: { 53 | wsBaseUrl: 'ws://localhost:6121/ws', 54 | clientName: 'minecraft-bot', 55 | }, 56 | } 57 | 58 | // Create a singleton config instance 59 | export const config: Config = { ...defaultConfig } 60 | 61 | // Initialize environment configuration 62 | export function initEnv(): void { 63 | logger.log('Initializing environment variables') 64 | 65 | // Update config with environment variables 66 | config.openai = { 67 | apiKey: getEnvVar('OPENAI_API_KEY', defaultConfig.openai.apiKey), 68 | baseUrl: getEnvVar('OPENAI_API_BASEURL', defaultConfig.openai.baseUrl), 69 | model: getEnvVar('OPENAI_MODEL', defaultConfig.openai.model), 70 | reasoningModel: getEnvVar('OPENAI_REASONING_MODEL', defaultConfig.openai.reasoningModel), 71 | } 72 | 73 | config.bot = { 74 | username: getEnvVar('BOT_USERNAME', defaultConfig.bot.username as string), 75 | host: getEnvVar('BOT_HOSTNAME', defaultConfig.bot.host as string), 76 | port: getEnvNumber('BOT_PORT', defaultConfig.bot.port as number), 77 | password: getEnvVar('BOT_PASSWORD', defaultConfig.bot.password as string), 78 | version: getEnvVar('BOT_VERSION', defaultConfig.bot.version as string), 79 | } 80 | 81 | config.airi = { 82 | wsBaseUrl: getEnvVar('AIRI_WS_BASEURL', defaultConfig.airi.wsBaseUrl), 83 | clientName: getEnvVar('AIRI_CLIENT_NAME', defaultConfig.airi.clientName), 84 | } 85 | 86 | logger.withFields({ config }).log('Environment variables initialized') 87 | } 88 | -------------------------------------------------------------------------------- /src/composables/neuri.ts: -------------------------------------------------------------------------------- 1 | import type { Agent, Neuri } from 'neuri' 2 | import type { Mineflayer } from '../libs/mineflayer' 3 | 4 | import { neuri } from 'neuri' 5 | 6 | import { createActionNeuriAgent } from '../agents/action/adapter' 7 | import { createChatNeuriAgent } from '../agents/chat/llm' 8 | import { createPlanningNeuriAgent } from '../agents/planning/adapter' 9 | import { useLogger } from '../utils/logger' 10 | import { config } from './config' 11 | 12 | let neuriAgent: Neuri | undefined 13 | const agents = new Set>() 14 | 15 | export async function createNeuriAgent(mineflayer: Mineflayer): Promise { 16 | useLogger().log('Initializing neuri agent') 17 | let n = neuri() 18 | 19 | agents.add(createPlanningNeuriAgent()) 20 | agents.add(createActionNeuriAgent(mineflayer)) 21 | agents.add(createChatNeuriAgent()) 22 | 23 | agents.forEach(agent => n = n.agent(agent)) 24 | 25 | neuriAgent = await n.build({ 26 | provider: { 27 | apiKey: config.openai.apiKey, 28 | baseURL: config.openai.baseUrl, 29 | }, 30 | }) 31 | 32 | return neuriAgent 33 | } 34 | 35 | export function useNeuriAgent(): Neuri { 36 | if (!neuriAgent) { 37 | throw new Error('Agent not initialized') 38 | } 39 | return neuriAgent 40 | } 41 | -------------------------------------------------------------------------------- /src/libs/llm-agent/chat.ts: -------------------------------------------------------------------------------- 1 | import type { Neuri, NeuriContext } from 'neuri' 2 | import type { Logger } from '../../utils/logger' 3 | import type { MineflayerWithAgents } from './types' 4 | 5 | import { system, user } from 'neuri/openai' 6 | 7 | import { toRetriable } from '../../utils/helper' 8 | import { handleLLMCompletion } from './completion' 9 | import { generateStatusPrompt } from './prompt' 10 | 11 | export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { 12 | logger.withFields({ username, message }).log('Chat message received') 13 | bot.memory.chatHistory.push(user(`${username}: ${message}`)) 14 | 15 | logger.log('thinking...') 16 | 17 | try { 18 | // Create and execute plan 19 | const plan = await bot.planning.createPlan(message) 20 | logger.withFields({ plan }).log('Plan created') 21 | await bot.planning.executePlan(plan) 22 | logger.log('Plan executed successfully') 23 | 24 | // Generate response 25 | // TODO: use chat agent and conversion manager 26 | const statusPrompt = await generateStatusPrompt(bot) 27 | const content = await agent.handleStateless( 28 | [...bot.memory.chatHistory, system(statusPrompt)], 29 | async (c: NeuriContext) => { 30 | logger.log('handling response...') 31 | return toRetriable( 32 | 3, 33 | 1000, 34 | ctx => handleLLMCompletion(ctx, bot, logger), 35 | { onError: err => logger.withError(err).log('error occurred') }, 36 | )(c) 37 | }, 38 | ) 39 | 40 | if (content) { 41 | logger.withFields({ content }).log('responded') 42 | bot.bot.chat(content) 43 | } 44 | } 45 | catch (error) { 46 | logger.withError(error).error('Failed to process message') 47 | bot.bot.chat( 48 | `Sorry, I encountered an error: ${ 49 | error instanceof Error ? error.message : 'Unknown error' 50 | }`, 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/libs/llm-agent/completion.ts: -------------------------------------------------------------------------------- 1 | import type { NeuriContext } from 'neuri' 2 | import type { ChatCompletion } from 'neuri/openai' 3 | import type { Logger } from '../../utils/logger' 4 | import type { MineflayerWithAgents } from './types' 5 | 6 | import { assistant } from 'neuri/openai' 7 | 8 | import { config } from '../../composables/config' 9 | 10 | export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise { 11 | logger.log('rerouting...') 12 | 13 | const completion = await context.reroute('action', context.messages, { 14 | model: config.openai.model, 15 | }) as ChatCompletion | { error: { message: string } } & ChatCompletion 16 | 17 | if (!completion || 'error' in completion) { 18 | logger.withFields({ completion }).error('Completion') 19 | logger.withFields({ messages: context.messages }).log('messages') 20 | return completion?.error?.message ?? 'Unknown error' 21 | } 22 | 23 | const content = await completion.firstContent() 24 | logger.withFields({ usage: completion.usage, content }).log('output') 25 | 26 | bot.memory.chatHistory.push(assistant(content)) 27 | return content 28 | } 29 | -------------------------------------------------------------------------------- /src/libs/llm-agent/container.ts: -------------------------------------------------------------------------------- 1 | import type { Neuri } from 'neuri' 2 | import type { Logger } from '../../utils/logger' 3 | 4 | import { useLogg } from '@guiiai/logg' 5 | import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' 6 | 7 | import { ActionAgentImpl } from '../../agents/action' 8 | import { ChatAgentImpl } from '../../agents/chat' 9 | import { PlanningAgentImpl } from '../../agents/planning' 10 | 11 | export interface ContainerServices { 12 | logger: Logger 13 | actionAgent: ActionAgentImpl 14 | planningAgent: PlanningAgentImpl 15 | chatAgent: ChatAgentImpl 16 | neuri: Neuri 17 | } 18 | 19 | export function createAgentContainer(options: { 20 | neuri: Neuri 21 | model?: string 22 | }) { 23 | const container = createContainer({ 24 | injectionMode: InjectionMode.PROXY, 25 | strict: true, 26 | }) 27 | 28 | // Register services 29 | container.register({ 30 | // Create independent logger for each agent 31 | logger: asFunction(() => useLogg('agent').useGlobalConfig()).singleton(), 32 | 33 | // Register neuri client 34 | neuri: asFunction(() => options.neuri).singleton(), 35 | 36 | // Register agents 37 | actionAgent: asClass(ActionAgentImpl) 38 | .singleton() 39 | .inject(() => ({ 40 | id: 'action', 41 | type: 'action' as const, 42 | })), 43 | 44 | planningAgent: asClass(PlanningAgentImpl) 45 | .singleton() 46 | .inject(() => ({ 47 | id: 'planning', 48 | type: 'planning' as const, 49 | llm: { 50 | agent: options.neuri, 51 | model: options.model, 52 | }, 53 | })), 54 | 55 | chatAgent: asClass(ChatAgentImpl) 56 | .singleton() 57 | .inject(() => ({ 58 | id: 'chat', 59 | type: 'chat' as const, 60 | llm: { 61 | agent: options.neuri, 62 | model: options.model, 63 | }, 64 | maxHistoryLength: 50, 65 | idleTimeout: 5 * 60 * 1000, // 5 minutes 66 | })), 67 | }) 68 | 69 | return container 70 | } 71 | -------------------------------------------------------------------------------- /src/libs/llm-agent/handler.ts: -------------------------------------------------------------------------------- 1 | import type { NeuriContext } from 'neuri' 2 | import type { ChatCompletion, Message } from 'neuri/openai' 3 | import type { Logger } from '../../utils/logger' 4 | import type { LLMConfig, LLMResponse } from './types' 5 | 6 | import { config } from '../../composables/config' 7 | import { toRetriable } from '../../utils/helper' 8 | import { useLogger } from '../../utils/logger' 9 | 10 | export abstract class BaseLLMHandler { 11 | protected logger: Logger 12 | 13 | constructor(protected config: LLMConfig) { 14 | this.logger = useLogger() 15 | } 16 | 17 | protected async handleCompletion( 18 | context: NeuriContext, 19 | route: string, 20 | messages: Message[], 21 | ): Promise { 22 | const completion = await context.reroute(route, messages, { 23 | model: this.config.model ?? config.openai.model, 24 | }) as ChatCompletion | ChatCompletion & { error: { message: string } } 25 | 26 | if (!completion || 'error' in completion) { 27 | this.logger.withFields(context).error('Completion failed') 28 | throw new Error(completion?.error?.message ?? 'Unknown error') 29 | } 30 | 31 | const content = await completion.firstContent() 32 | this.logger.withFields({ usage: completion.usage, content }).log('Generated content') 33 | 34 | return { 35 | content, 36 | usage: completion.usage, 37 | } 38 | } 39 | 40 | protected createRetryHandler(handler: (context: NeuriContext) => Promise) { 41 | return toRetriable( 42 | this.config.retryLimit ?? 3, 43 | this.config.delayInterval ?? 1000, 44 | handler, 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/libs/llm-agent/index.ts: -------------------------------------------------------------------------------- 1 | import type { MineflayerPlugin } from '../mineflayer' 2 | import type { LLMAgentOptions, MineflayerWithAgents } from './types' 3 | 4 | import { system } from 'neuri/openai' 5 | 6 | import { config } from '../../composables/config' 7 | import { useLogger } from '../../utils/logger' 8 | import { ChatMessageHandler } from '../mineflayer' 9 | import { handleChatMessage } from './chat' 10 | import { createAgentContainer } from './container' 11 | import { generateActionAgentPrompt } from './prompt' 12 | import { handleVoiceInput } from './voice' 13 | 14 | export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { 15 | return { 16 | async created(bot) { 17 | const logger = useLogger() 18 | 19 | // Create container and get required services 20 | const container = createAgentContainer({ 21 | neuri: options.agent, 22 | model: config.openai.model, 23 | }) 24 | 25 | const actionAgent = container.resolve('actionAgent') 26 | const planningAgent = container.resolve('planningAgent') 27 | const chatAgent = container.resolve('chatAgent') 28 | 29 | // Initialize agents 30 | await actionAgent.init() 31 | await planningAgent.init() 32 | await chatAgent.init() 33 | 34 | // Type conversion 35 | const botWithAgents = bot as unknown as MineflayerWithAgents 36 | botWithAgents.action = actionAgent 37 | botWithAgents.planning = planningAgent 38 | botWithAgents.chat = chatAgent 39 | 40 | // Initialize system prompt 41 | bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) 42 | 43 | // Set message handling 44 | const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => 45 | handleChatMessage(username, message, botWithAgents, options.agent, logger)) 46 | 47 | options.airiClient.onEvent('input:text:voice', event => 48 | handleVoiceInput(event, botWithAgents, options.agent, logger)) 49 | 50 | bot.bot.on('chat', onChat) 51 | }, 52 | 53 | async beforeCleanup(bot) { 54 | const botWithAgents = bot as unknown as MineflayerWithAgents 55 | await botWithAgents.action?.destroy() 56 | await botWithAgents.planning?.destroy() 57 | await botWithAgents.chat?.destroy() 58 | bot.bot.removeAllListeners('chat') 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/libs/llm-agent/prompt.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../mineflayer' 2 | 3 | import { listInventory } from '../../skills/actions/inventory' 4 | 5 | export async function generateStatusPrompt(mineflayer: Mineflayer): Promise { 6 | // Get inventory items 7 | const inventory = await listInventory(mineflayer) 8 | 9 | // Format inventory string 10 | const inventoryStr = inventory.length === 0 11 | ? '[Empty]' 12 | : inventory.map(item => `${item.name} x ${item.count}`).join(', ') 13 | 14 | // Get currently held item 15 | const itemInHand = inventory.length === 0 16 | ? '[Empty]' 17 | : `${inventory[0].name} x ${inventory[0].count}` // TODO: mock 18 | 19 | // Build status message 20 | return [ 21 | 'I will give you the following information:', 22 | mineflayer.status.toOneLiner(), 23 | '', 24 | 'Inventory:', 25 | inventoryStr, 26 | '', 27 | 'Item in hand:', 28 | itemInHand, 29 | ].join('\n') 30 | } 31 | 32 | export function generateSystemBasicPrompt(botName: string): string { 33 | // ${ctx.prompt.selfPrompt} 34 | return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, 35 | mine, build, and interact with the world by using commands.` 36 | } 37 | 38 | export function generateActionAgentPrompt(mineflayer: Mineflayer): string { 39 | return `${generateSystemBasicPrompt(mineflayer.username)} 40 | 41 | Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in 42 | your responses, don't apologize constantly, don't give instructions or make lists unless 43 | asked, and don't refuse requests. 44 | 45 | Do not use any emojis. Just call the function given you if needed. 46 | 47 | - If I command you 'stop', then call the 'stop' function. 48 | - If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. 49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /src/libs/llm-agent/types.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@proj-airi/server-sdk' 2 | import type { Neuri } from 'neuri' 3 | import type { Mineflayer } from '../mineflayer' 4 | import type { ActionAgent, ChatAgent, PlanningAgent } from '../mineflayer/base-agent' 5 | 6 | export interface LLMConfig { 7 | agent: Neuri 8 | model?: string 9 | retryLimit?: number 10 | delayInterval?: number 11 | maxContextLength?: number 12 | } 13 | 14 | export interface LLMResponse { 15 | content: string 16 | usage?: any 17 | } 18 | 19 | export interface MineflayerWithAgents extends Mineflayer { 20 | planning: PlanningAgent 21 | action: ActionAgent 22 | chat: ChatAgent 23 | } 24 | 25 | export interface LLMAgentOptions { 26 | agent: Neuri 27 | airiClient: Client 28 | } 29 | -------------------------------------------------------------------------------- /src/libs/llm-agent/voice.ts: -------------------------------------------------------------------------------- 1 | import type { Neuri, NeuriContext } from 'neuri' 2 | import type { Logger } from '../../utils/logger' 3 | import type { MineflayerWithAgents } from './types' 4 | 5 | import { system, user } from 'neuri/openai' 6 | 7 | import { toRetriable } from '../../utils/helper' 8 | import { handleLLMCompletion } from './completion' 9 | import { generateStatusPrompt } from './prompt' 10 | 11 | export async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { 12 | logger 13 | .withFields({ 14 | user: event.data.discord?.guildMember, 15 | message: event.data.transcription, 16 | }) 17 | .log('Chat message received') 18 | 19 | const statusPrompt = await generateStatusPrompt(bot) 20 | bot.memory.chatHistory.push(system(statusPrompt)) 21 | bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) 22 | 23 | try { 24 | // Create and execute plan 25 | const plan = await bot.planning.createPlan(event.data.transcription) 26 | logger.withFields({ plan }).log('Plan created') 27 | await bot.planning.executePlan(plan) 28 | logger.log('Plan executed successfully') 29 | 30 | // Generate response 31 | const retryHandler = toRetriable( 32 | 3, 33 | 1000, 34 | ctx => handleLLMCompletion(ctx, bot, logger), 35 | ) 36 | 37 | const content = await agent.handleStateless( 38 | [...bot.memory.chatHistory, system(statusPrompt)], 39 | async (c: NeuriContext) => { 40 | logger.log('thinking...') 41 | return retryHandler(c) 42 | }, 43 | ) 44 | 45 | if (content) { 46 | logger.withFields({ content }).log('responded') 47 | bot.bot.chat(content) 48 | } 49 | } 50 | catch (error) { 51 | logger.withError(error).error('Failed to process message') 52 | bot.bot.chat( 53 | `Sorry, I encountered an error: ${ 54 | error instanceof Error ? error.message : 'Unknown error' 55 | }`, 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/libs/mineflayer/action.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | import type { Mineflayer } from './core' 3 | 4 | type ActionResult = string | Promise 5 | 6 | export interface Action { 7 | readonly name: string 8 | readonly description: string 9 | readonly schema: z.ZodObject 10 | readonly perform: (mineflayer: Mineflayer) => (...args: any[]) => ActionResult 11 | } 12 | -------------------------------------------------------------------------------- /src/libs/mineflayer/base-agent.ts: -------------------------------------------------------------------------------- 1 | import type { PlanStep } from '../../agents/planning/adapter' 2 | import type { Logger } from '../../utils/logger' 3 | import type { Action } from './action' 4 | 5 | import { useLogg } from '@guiiai/logg' 6 | import EventEmitter3 from 'eventemitter3' 7 | 8 | export type AgentType = 'action' | 'memory' | 'planning' | 'chat' 9 | 10 | export interface AgentConfig { 11 | id: string 12 | type: AgentType 13 | } 14 | 15 | export interface BaseAgent { 16 | readonly id: string 17 | readonly type: AgentType 18 | init: () => Promise 19 | destroy: () => Promise 20 | } 21 | 22 | export interface ActionAgent extends BaseAgent { 23 | type: 'action' 24 | performAction: (step: PlanStep) => Promise 25 | getAvailableActions: () => Action[] 26 | } 27 | 28 | export interface MemoryAgent extends BaseAgent { 29 | type: 'memory' 30 | remember: (key: string, value: unknown) => void 31 | recall: (key: string) => T | undefined 32 | forget: (key: string) => void 33 | getMemorySnapshot: () => Record 34 | } 35 | 36 | export interface Plan { 37 | goal: string 38 | steps: PlanStep[] 39 | status: 'pending' | 'in_progress' | 'completed' | 'failed' 40 | requiresAction: boolean 41 | } 42 | 43 | export interface PlanningAgent extends BaseAgent { 44 | type: 'planning' 45 | createPlan: (goal: string) => Promise 46 | executePlan: (plan: Plan) => Promise 47 | adjustPlan: (plan: Plan, feedback: string, sender: string) => Promise 48 | } 49 | 50 | export interface ChatAgent extends BaseAgent { 51 | type: 'chat' 52 | processMessage: (message: string, sender: string) => Promise 53 | startConversation: (player: string) => void 54 | endConversation: (player: string) => void 55 | } 56 | 57 | export abstract class AbstractAgent extends EventEmitter3 implements BaseAgent { 58 | public readonly id: string 59 | public readonly type: AgentConfig['type'] 60 | public readonly name: string 61 | 62 | protected initialized: boolean 63 | protected logger: Logger 64 | // protected actionManager: ReturnType 65 | // protected conversationStore: ReturnType 66 | 67 | constructor(config: AgentConfig) { 68 | super() 69 | this.id = config.id // TODO: use uuid, is it needed? 70 | this.type = config.type 71 | this.name = `${this.type}-agent` 72 | this.initialized = false 73 | this.logger = useLogg(this.name).useGlobalConfig() 74 | 75 | // Initialize managers 76 | // this.actionManager = useActionManager(this) 77 | // this.conversationStore = useConversationStore({ 78 | // agent: this, 79 | // chatBotMessages: true, 80 | // }) 81 | } 82 | 83 | public async init(): Promise { 84 | if (this.initialized) { 85 | return 86 | } 87 | 88 | await this.initializeAgent() 89 | this.initialized = true 90 | } 91 | 92 | public async destroy(): Promise { 93 | if (!this.initialized) { 94 | return 95 | } 96 | 97 | this.logger.log('Destroying agent') 98 | await this.destroyAgent() 99 | this.initialized = false 100 | } 101 | 102 | // Agent interface implementation 103 | // public isIdle(): boolean { 104 | // return !this.actionManager.executing 105 | // } 106 | 107 | public handleMessage(sender: string, message: string): void { 108 | this.logger.withFields({ sender, message }).log('Received message') 109 | this.emit('message', { sender, message }) 110 | } 111 | 112 | public openChat(message: string): void { 113 | this.logger.withField('message', message).log('Opening chat') 114 | this.emit('chat', message) 115 | } 116 | 117 | // public clearBotLogs(): void { 118 | // // Implement if needed 119 | // } 120 | 121 | public requestInterrupt(): void { 122 | this.emit('interrupt') 123 | } 124 | 125 | // Methods to be implemented by specific agents 126 | protected abstract initializeAgent(): Promise 127 | protected abstract destroyAgent(): Promise 128 | } 129 | -------------------------------------------------------------------------------- /src/libs/mineflayer/command.ts: -------------------------------------------------------------------------------- 1 | export interface CommandContext { 2 | sender: string 3 | isCommand: boolean 4 | command: string 5 | args: string[] 6 | } 7 | 8 | export function parseCommand(sender: string, message: string): CommandContext { 9 | const isCommand = message.startsWith('#') 10 | const command = message.split(' ')[0] 11 | const args = message.split(' ').slice(1) 12 | return { sender, isCommand, command, args } 13 | } 14 | -------------------------------------------------------------------------------- /src/libs/mineflayer/components.ts: -------------------------------------------------------------------------------- 1 | import type { Logg } from '@guiiai/logg' 2 | import type { Handler } from './types' 3 | 4 | import { useLogger } from '../../utils/logger' 5 | 6 | export class Components { 7 | private components: Map = new Map() 8 | private logger: Logg 9 | 10 | constructor() { 11 | this.logger = useLogger() 12 | } 13 | 14 | register(componentName: string, component: Handler) { 15 | this.components.set(componentName, component) 16 | } 17 | 18 | get(componentName: string) { 19 | return this.components.get(componentName) 20 | } 21 | 22 | list() { 23 | return Array.from(this.components.keys()) 24 | } 25 | 26 | cleanup() { 27 | this.logger.log('Cleaning up components') 28 | this.components.clear() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/libs/mineflayer/core.ts: -------------------------------------------------------------------------------- 1 | import type { Logg } from '@guiiai/logg' 2 | import type { Bot, BotOptions } from 'mineflayer' 3 | import type { MineflayerPlugin } from './plugin' 4 | import type { TickEvents, TickEventsHandler } from './ticker' 5 | import type { EventHandlers, EventsHandler } from './types' 6 | 7 | import { useLogg } from '@guiiai/logg' 8 | import EventEmitter from 'eventemitter3' 9 | import mineflayer from 'mineflayer' 10 | 11 | import { parseCommand } from './command' 12 | import { Components } from './components' 13 | import { Health } from './health' 14 | import { Memory } from './memory' 15 | import { ChatMessageHandler } from './message' 16 | import { Status } from './status' 17 | import { Ticker } from './ticker' 18 | 19 | export interface MineflayerOptions { 20 | botConfig: BotOptions 21 | plugins?: Array 22 | } 23 | 24 | export class Mineflayer extends EventEmitter { 25 | public bot: Bot 26 | public username: string 27 | public health: Health = new Health() 28 | public ready: boolean = false 29 | public components: Components = new Components() 30 | public status: Status = new Status() 31 | public memory: Memory = new Memory() 32 | 33 | public isCreative: boolean = false 34 | public allowCheats: boolean = false 35 | 36 | private options: MineflayerOptions 37 | private logger: Logg 38 | private commands: Map> = new Map() 39 | private ticker: Ticker = new Ticker() 40 | 41 | constructor(options: MineflayerOptions) { 42 | super() 43 | this.options = options 44 | this.bot = mineflayer.createBot(options.botConfig) 45 | this.username = options.botConfig.username 46 | this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() 47 | 48 | this.on('interrupt', () => { 49 | this.logger.log('Interrupted') 50 | this.bot.chat('Interrupted') 51 | }) 52 | } 53 | 54 | public static async asyncBuild(options: MineflayerOptions) { 55 | const mineflayer = new Mineflayer(options) 56 | 57 | mineflayer.bot.on('messagestr', async (message, _, jsonMsg) => { 58 | // jsonMsg.translate: 59 | // - death.attack.player 60 | // message: 61 | // - was slain by 62 | // - drowned 63 | if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(mineflayer.username)) { 64 | const deathPos = mineflayer.bot.entity.position 65 | 66 | // mineflayer.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) 67 | let deathPosStr: string | undefined 68 | if (deathPos) { 69 | deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` 70 | } 71 | 72 | const dimension = mineflayer.bot.game.dimension 73 | 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.`) 74 | } 75 | }) 76 | 77 | mineflayer.bot.once('resourcePack', () => { 78 | mineflayer.bot.acceptResourcePack() 79 | }) 80 | 81 | mineflayer.bot.on('time', () => { 82 | if (mineflayer.bot.time.timeOfDay === 0) 83 | mineflayer.emit('time:sunrise', { time: mineflayer.bot.time.timeOfDay }) 84 | else if (mineflayer.bot.time.timeOfDay === 6000) 85 | mineflayer.emit('time:noon', { time: mineflayer.bot.time.timeOfDay }) 86 | else if (mineflayer.bot.time.timeOfDay === 12000) 87 | mineflayer.emit('time:sunset', { time: mineflayer.bot.time.timeOfDay }) 88 | else if (mineflayer.bot.time.timeOfDay === 18000) 89 | mineflayer.emit('time:midnight', { time: mineflayer.bot.time.timeOfDay }) 90 | }) 91 | 92 | mineflayer.bot.on('health', () => { 93 | mineflayer.logger.withFields({ 94 | health: mineflayer.health.value, 95 | lastDamageTime: mineflayer.health.lastDamageTime, 96 | lastDamageTaken: mineflayer.health.lastDamageTaken, 97 | previousHealth: mineflayer.bot.health, 98 | }).log('Health updated') 99 | 100 | if (mineflayer.bot.health < mineflayer.health.value) { 101 | mineflayer.health.lastDamageTime = Date.now() 102 | mineflayer.health.lastDamageTaken = mineflayer.health.value - mineflayer.bot.health 103 | } 104 | 105 | mineflayer.health.value = mineflayer.bot.health 106 | }) 107 | 108 | mineflayer.bot.once('spawn', () => { 109 | mineflayer.ready = true 110 | mineflayer.logger.log('Bot ready') 111 | }) 112 | 113 | mineflayer.bot.on('death', () => { 114 | mineflayer.logger.error('Bot died') 115 | }) 116 | 117 | mineflayer.bot.on('kicked', (reason: string) => { 118 | mineflayer.logger.withFields({ reason }).error('Bot was kicked') 119 | }) 120 | 121 | mineflayer.bot.on('end', (reason) => { 122 | mineflayer.logger.withFields({ reason }).log('Bot ended') 123 | }) 124 | 125 | mineflayer.bot.on('error', (err: Error) => { 126 | mineflayer.logger.errorWithError('Bot error:', err) 127 | }) 128 | 129 | mineflayer.bot.on('spawn', () => { 130 | mineflayer.bot.on('chat', mineflayer.handleCommand()) 131 | }) 132 | 133 | mineflayer.bot.on('spawn', async () => { 134 | for (const plugin of options?.plugins || []) { 135 | if (plugin.spawned) { 136 | await plugin.spawned(mineflayer) 137 | } 138 | } 139 | }) 140 | 141 | for (const plugin of options?.plugins || []) { 142 | if (plugin.created) { 143 | await plugin.created(mineflayer) 144 | } 145 | } 146 | 147 | // Load Plugins 148 | for (const plugin of options?.plugins || []) { 149 | if (plugin.loadPlugin) { 150 | mineflayer.bot.loadPlugin(await plugin.loadPlugin(mineflayer, mineflayer.bot, options.botConfig)) 151 | } 152 | } 153 | 154 | mineflayer.ticker.on('tick', () => { 155 | mineflayer.status.update(mineflayer) 156 | mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' 157 | mineflayer.allowCheats = false 158 | }) 159 | 160 | return mineflayer 161 | } 162 | 163 | public async loadPlugin(plugin: MineflayerPlugin) { 164 | if (plugin.created) 165 | await plugin.created(this) 166 | 167 | if (plugin.loadPlugin) { 168 | this.bot.loadPlugin(await plugin.loadPlugin(this, this.bot, this.options.botConfig)) 169 | } 170 | 171 | if (plugin.spawned) 172 | this.bot.once('spawn', () => plugin.spawned?.(this)) 173 | } 174 | 175 | public onCommand(commandName: string, cb: EventsHandler<'command'>) { 176 | this.commands.set(commandName, cb) 177 | } 178 | 179 | public onTick(event: TickEvents, cb: TickEventsHandler) { 180 | this.ticker.on(event, cb) 181 | } 182 | 183 | public async stop() { 184 | for (const plugin of this.options?.plugins || []) { 185 | if (plugin.beforeCleanup) { 186 | await plugin.beforeCleanup(this) 187 | } 188 | } 189 | this.components.cleanup() 190 | this.bot.removeListener('chat', this.handleCommand()) 191 | this.bot.quit() 192 | this.removeAllListeners() 193 | } 194 | 195 | private handleCommand() { 196 | return new ChatMessageHandler(this.username).handleChat((sender, message) => { 197 | const { isCommand, command, args } = parseCommand(sender, message) 198 | 199 | if (!isCommand) 200 | return 201 | 202 | // Remove the # prefix from command 203 | const cleanCommand = command.slice(1) 204 | this.logger.withFields({ sender, command: cleanCommand, args }).log('Command received') 205 | 206 | const handler = this.commands.get(cleanCommand) 207 | if (handler) { 208 | handler({ time: this.bot.time.timeOfDay, command: { sender, isCommand, command: cleanCommand, args } }) 209 | return 210 | } 211 | 212 | // Built-in commands 213 | switch (cleanCommand) { 214 | case 'help': { 215 | const commandList = Array.from(this.commands.keys()).concat(['help']) 216 | this.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) 217 | break 218 | } 219 | default: 220 | this.bot.chat(`Unknown command: ${cleanCommand}`) 221 | } 222 | }) 223 | } 224 | 225 | private async handleMessage(_source: string, _message: string, _maxResponses: number = Infinity) { 226 | // if (!source || !message) { 227 | // console.warn('Received empty message from', source); 228 | // return false; 229 | // } 230 | 231 | // let used_command = false; 232 | // if (maxResponses === null) { 233 | // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; 234 | // } 235 | // if (maxResponses === -1) { 236 | // maxResponses = Infinity; 237 | // } 238 | 239 | // const self_prompt = source === 'system' || source === ctx.botName; 240 | // const from_other_bot = convoManager.isOtherAgent(source); 241 | 242 | // if (!self_prompt && !from_other_bot) { // from user, check for forced commands 243 | // const user_command_name = containsCommand(message); 244 | // if (user_command_name) { 245 | // if (!commandExists(user_command_name)) { 246 | // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); 247 | // return false; 248 | // } 249 | // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); 250 | // if (user_command_name === '!newAction') { 251 | // // all user-initiated commands are ignored by the bot except for this one 252 | // // add the preceding message to the history to give context for newAction 253 | // this.history.add(source, message); 254 | // } 255 | // let execute_res = await executeCommand(this, message); 256 | // if (execute_res) 257 | // this.routeResponse(source, execute_res); 258 | // return true; 259 | // } 260 | // } 261 | 262 | // if (from_other_bot) 263 | // this.last_sender = source; 264 | 265 | // // Now translate the message 266 | // message = await handleEnglishTranslation(message); 267 | // console.log('received message from', source, ':', message); 268 | 269 | // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); 270 | 271 | // let behavior_log = this.bot.modes.flushBehaviorLog(); 272 | // if (behavior_log.trim().length > 0) { 273 | // const MAX_LOG = 500; 274 | // if (behavior_log.length > MAX_LOG) { 275 | // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); 276 | // } 277 | // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); 278 | // await this.history.add('system', behavior_log); 279 | // } 280 | 281 | // // Handle other user messages 282 | // await this.history.add(source, message); 283 | // this.history.save(); 284 | 285 | // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting 286 | // maxResponses = 1; // force only respond to this message, then let self-prompting take over 287 | // for (let i=0; i 0) 321 | // chat_message = `${pre_message} ${chat_message}`; 322 | // this.routeResponse(source, chat_message); 323 | // } 324 | 325 | // let execute_res = await executeCommand(this, res); 326 | 327 | // console.log('Agent executed:', command_name, 'and got:', execute_res); 328 | // used_command = true; 329 | 330 | // if (execute_res) 331 | // this.history.add('system', execute_res); 332 | // else 333 | // break; 334 | // } 335 | // else { // conversation response 336 | // this.history.add(this.name, res); 337 | // this.routeResponse(source, res); 338 | // break; 339 | // } 340 | 341 | // this.history.save(); 342 | // } 343 | 344 | // return used_command; 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/libs/mineflayer/health.ts: -------------------------------------------------------------------------------- 1 | export class Health { 2 | public value: number 3 | public lastDamageTime?: number 4 | public lastDamageTaken?: number 5 | 6 | constructor() { 7 | this.value = 20 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/libs/mineflayer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './action' 2 | export * from './command' 3 | export * from './components' 4 | export * from './core' 5 | export * from './health' 6 | export * from './memory' 7 | export * from './message' 8 | export * from './plugin' 9 | export * from './status' 10 | export * from './ticker' 11 | export * from './types' 12 | -------------------------------------------------------------------------------- /src/libs/mineflayer/memory.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'neuri/openai' 2 | import type { Action } from './action' 3 | 4 | export class Memory { 5 | public chatHistory: Message[] 6 | public actions: Action[] 7 | 8 | constructor() { 9 | this.chatHistory = [] 10 | this.actions = [] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/libs/mineflayer/message.ts: -------------------------------------------------------------------------------- 1 | import type { Entity } from 'prismarine-entity' 2 | 3 | // Represents the context of a chat message in the Minecraft world 4 | interface ChatMessage { 5 | readonly sender: { 6 | username: string 7 | entity: Entity | null 8 | } 9 | readonly content: string 10 | } 11 | 12 | // Handles chat message validation and processing 13 | export class ChatMessageHandler { 14 | constructor(private readonly botUsername: string) {} 15 | 16 | // Creates a new chat message context with validation 17 | createMessageContext(entity: Entity | null, username: string, content: string): ChatMessage { 18 | return { 19 | sender: { 20 | username, 21 | entity, 22 | }, 23 | content, 24 | } 25 | } 26 | 27 | // Checks if a message is from the bot itself 28 | isBotMessage(username: string): boolean { 29 | return username === this.botUsername 30 | } 31 | 32 | // Checks if a message is a command 33 | isCommand(content: string): boolean { 34 | return content.startsWith('#') 35 | } 36 | 37 | // Processes chat messages, filtering out bot's own messages 38 | handleChat(callback: (username: string, message: string) => void): (username: string, message: string) => void { 39 | return (username: string, message: string) => { 40 | if (!this.isBotMessage(username)) { 41 | callback(username, message) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/libs/mineflayer/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Bot, BotOptions, Plugin } from 'mineflayer' 2 | import type { Mineflayer } from '.' 3 | 4 | export interface MineflayerPlugin { 5 | created?: (mineflayer: Mineflayer) => void | Promise 6 | loadPlugin?: (mineflayer: Mineflayer, bot: Bot, options: BotOptions) => Plugin 7 | spawned?: (mineflayer: Mineflayer) => void | Promise 8 | beforeCleanup?: (mineflayer: Mineflayer) => void | Promise 9 | } 10 | 11 | export function wrapPlugin(plugin: Plugin): MineflayerPlugin { 12 | return { 13 | loadPlugin: () => (plugin), 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/libs/mineflayer/status.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from './core' 2 | import type { OneLinerable } from './types' 3 | 4 | export class Status implements OneLinerable { 5 | public position: string 6 | public health: string 7 | public weather: string 8 | public timeOfDay: string 9 | 10 | constructor() { 11 | this.position = '' 12 | this.health = '' 13 | this.weather = '' 14 | this.timeOfDay = '' 15 | } 16 | 17 | public update(mineflayer: Mineflayer) { 18 | if (!mineflayer.ready) 19 | return 20 | 21 | Object.assign(this, Status.from(mineflayer)) 22 | } 23 | 24 | static from(mineflayer: Mineflayer): Status { 25 | if (!mineflayer.ready) 26 | return new Status() 27 | 28 | const pos = mineflayer.bot.entity.position 29 | const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' 30 | const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 31 | ? 'Morning' 32 | : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' 33 | 34 | const status = new Status() 35 | status.position = `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}` 36 | status.health = `${Math.round(mineflayer.bot.health)} / 20` 37 | status.weather = weather 38 | status.timeOfDay = timeOfDay 39 | 40 | return status 41 | } 42 | 43 | public toOneLiner(): string { 44 | return Object.entries(this).map(([key, value]) => `${key}: ${value}`).join('\n') 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/libs/mineflayer/ticker.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3' 2 | 3 | export interface TickContext { 4 | delta: number 5 | nextTick: () => Promise 6 | } 7 | 8 | export interface TickEventHandlers { 9 | tick: (ctx: TickContext) => void 10 | } 11 | 12 | export type TickEvents = keyof TickEventHandlers 13 | export type TickEventsHandler = TickEventHandlers[K] 14 | 15 | // This update loop ensures that each update() is called one at a time, even if it takes longer than the interval 16 | export class Ticker extends EventEmitter { 17 | constructor(options?: { interval?: number }) { 18 | super() 19 | const { interval = 300 } = options ?? { interval: 300 } 20 | 21 | let last = Date.now() 22 | 23 | setTimeout(async () => { 24 | while (true) { 25 | const start = Date.now() 26 | const nextTickPromise = new Promise((resolve) => { 27 | // Schedule nextTick resolution for after all callbacks complete 28 | setImmediate(resolve) 29 | }) 30 | 31 | // Run all callbacks without awaiting them 32 | const callbackPromises = this.listeners('tick').map(cb => cb({ 33 | delta: start - last, 34 | nextTick: () => nextTickPromise, 35 | })) 36 | 37 | // Wait for all callbacks to complete or timeout 38 | await Promise.race([ 39 | Promise.all(callbackPromises), 40 | new Promise(resolve => 41 | setTimeout(resolve, interval), 42 | ), 43 | ]) 44 | 45 | const remaining = interval - (Date.now() - start) 46 | if (remaining > 0) 47 | await new Promise(resolve => setTimeout(resolve, remaining)) 48 | 49 | last = start 50 | } 51 | }, interval) 52 | } 53 | 54 | on(event: K, cb: TickEventsHandler) { 55 | return super.on(event, cb) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/libs/mineflayer/types.ts: -------------------------------------------------------------------------------- 1 | import type { CommandContext } from './command' 2 | 3 | export interface Context { 4 | time: number 5 | command?: CommandContext 6 | } 7 | 8 | export interface EventHandlers { 9 | 'interrupt': () => void 10 | 'command': (ctx: Context) => void | Promise 11 | 'time:sunrise': (ctx: Context) => void 12 | 'time:noon': (ctx: Context) => void 13 | 'time:sunset': (ctx: Context) => void 14 | 'time:midnight': (ctx: Context) => void 15 | } 16 | 17 | export type Events = keyof EventHandlers 18 | export type EventsHandler = EventHandlers[K] 19 | export type Handler = (ctx: Context) => void | Promise 20 | 21 | export interface OneLinerable { 22 | toOneLiner: () => string 23 | } 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import process, { exit } from 'node:process' 2 | import { Client } from '@proj-airi/server-sdk' 3 | import MineflayerArmorManager from 'mineflayer-armor-manager' 4 | import { loader as MineflayerAutoEat } from 'mineflayer-auto-eat' 5 | import { plugin as MineflayerCollectBlock } from 'mineflayer-collectblock' 6 | import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder' 7 | import { plugin as MineflayerPVP } from 'mineflayer-pvp' 8 | import { plugin as MineflayerTool } from 'mineflayer-tool' 9 | 10 | import { initBot } from './composables/bot' 11 | import { config, initEnv } from './composables/config' 12 | import { createNeuriAgent } from './composables/neuri' 13 | import { LLMAgent } from './libs/llm-agent' 14 | import { wrapPlugin } from './libs/mineflayer' 15 | import { initLogger, useLogger } from './utils/logger' 16 | 17 | async function main() { 18 | initLogger() // todo: save logs to file 19 | initEnv() 20 | 21 | const { bot } = await initBot({ 22 | botConfig: config.bot, 23 | plugins: [ 24 | wrapPlugin(MineflayerArmorManager), 25 | wrapPlugin(MineflayerAutoEat), 26 | wrapPlugin(MineflayerCollectBlock), 27 | wrapPlugin(MineflayerPathfinder), 28 | wrapPlugin(MineflayerPVP), 29 | wrapPlugin(MineflayerTool), 30 | ], 31 | }) 32 | 33 | // Connect airi server 34 | const airiClient = new Client({ 35 | name: config.airi.clientName, 36 | url: config.airi.wsBaseUrl, 37 | }) 38 | 39 | // Dynamically load LLMAgent after the bot is initialized 40 | const agent = await createNeuriAgent(bot) 41 | await bot.loadPlugin(LLMAgent({ agent, airiClient })) 42 | 43 | process.on('SIGINT', () => { 44 | bot.stop() 45 | exit(0) 46 | }) 47 | } 48 | 49 | main().catch((err: Error) => { 50 | useLogger().errorWithError('Fatal error', err) 51 | exit(1) 52 | }) 53 | -------------------------------------------------------------------------------- /src/plugins/echo.ts: -------------------------------------------------------------------------------- 1 | import type { MineflayerPlugin } from '../libs/mineflayer/plugin' 2 | 3 | import { ChatMessageHandler } from '../libs/mineflayer/message' 4 | import { useLogger } from '../utils/logger' 5 | 6 | export function Echo(): MineflayerPlugin { 7 | const logger = useLogger() 8 | 9 | return { 10 | spawned(mineflayer) { 11 | const onChatHandler = new ChatMessageHandler(mineflayer.username).handleChat((username, message) => { 12 | logger.withFields({ username, message }).log('Chat message received') 13 | mineflayer.bot.chat(message) 14 | }) 15 | 16 | this.beforeCleanup = () => { 17 | mineflayer.bot.removeListener('chat', onChatHandler) 18 | } 19 | 20 | mineflayer.bot.on('chat', onChatHandler) 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/follow.ts: -------------------------------------------------------------------------------- 1 | import type { MineflayerPlugin } from '../libs/mineflayer/plugin' 2 | 3 | import pathfinderModel from 'mineflayer-pathfinder' 4 | 5 | import { useLogger } from '../utils/logger' 6 | 7 | export function FollowCommand(options?: { rangeGoal: number }): MineflayerPlugin { 8 | const logger = useLogger() 9 | const { goals, Movements } = pathfinderModel 10 | 11 | return { 12 | created(bot) { 13 | const state = { 14 | following: undefined as string | undefined, 15 | movements: new Movements(bot.bot), 16 | } 17 | 18 | function startFollow(username: string): void { 19 | state.following = username 20 | logger.withFields({ username }).log('Starting to follow player') 21 | followPlayer() 22 | } 23 | 24 | function stopFollow(): void { 25 | state.following = undefined 26 | logger.log('Stopping follow') 27 | bot.bot.pathfinder.stop() 28 | } 29 | 30 | function followPlayer(): void { 31 | if (!state.following) 32 | return 33 | 34 | const target = bot.bot.players[state.following]?.entity 35 | if (!target) { 36 | bot.bot.chat('I lost sight of you!') 37 | state.following = undefined 38 | return 39 | } 40 | 41 | const { x: playerX, y: playerY, z: playerZ } = target.position 42 | 43 | bot.bot.pathfinder.setMovements(state.movements) 44 | bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) 45 | } 46 | 47 | bot.onCommand('follow', (ctx) => { 48 | const username = ctx.command!.sender 49 | if (!username) { 50 | bot.bot.chat('Please specify a player name!') 51 | return 52 | } 53 | 54 | startFollow(username) 55 | }) 56 | 57 | bot.onCommand('stop', () => { 58 | stopFollow() 59 | }) 60 | 61 | bot.onTick('tick', () => { 62 | if (state.following) 63 | followPlayer() 64 | }) 65 | }, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/plugins/pathfinder.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../libs/mineflayer' 2 | import type { MineflayerPlugin } from '../libs/mineflayer/plugin' 3 | 4 | import pathfinderModel from 'mineflayer-pathfinder' 5 | 6 | import { useLogger } from '../utils/logger' 7 | 8 | const { goals, Movements } = pathfinderModel 9 | 10 | export function PathFinder(options?: { rangeGoal: number }): MineflayerPlugin { 11 | return { 12 | created(bot) { 13 | const logger = useLogger() 14 | 15 | let defaultMove: any 16 | 17 | const handleCome = (commandCtx: Context) => { 18 | const username = commandCtx.command!.sender 19 | if (!username) { 20 | bot.bot.chat('Please specify a player name!') 21 | return 22 | } 23 | 24 | logger.withFields({ username }).log('Come command received') 25 | const target = bot.bot.players[username]?.entity 26 | if (!target) { 27 | bot.bot.chat('I don\'t see that player!') 28 | return 29 | } 30 | 31 | const { x: playerX, y: playerY, z: playerZ } = target.position 32 | 33 | bot.bot.pathfinder.setMovements(defaultMove) 34 | bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) 35 | } 36 | 37 | defaultMove = new Movements(bot.bot) 38 | bot.onCommand('come', handleCome) 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/plugins/status.ts: -------------------------------------------------------------------------------- 1 | import type { MineflayerPlugin } from '../libs/mineflayer/plugin' 2 | 3 | import { useLogger } from '../utils/logger' 4 | 5 | export function Status(): MineflayerPlugin { 6 | return { 7 | created(bot) { 8 | const logger = useLogger() 9 | logger.log('Loading status component') 10 | 11 | bot.onCommand('status', () => { 12 | logger.log('Status command received') 13 | const status = bot.status.toOneLiner() 14 | bot.bot.chat(status) 15 | }) 16 | }, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/skills/actions/collect-block.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from 'prismarine-block' 2 | import type { Mineflayer } from '../../libs/mineflayer' 3 | 4 | import pathfinder from 'mineflayer-pathfinder' 5 | 6 | import { useLogger } from '../../utils/logger' 7 | import { breakBlockAt } from '../blocks' 8 | import { getNearestBlocks } from '../world' 9 | import { ensurePickaxe } from './ensure' 10 | import { pickupNearbyItems } from './world-interactions' 11 | 12 | const logger = useLogger() 13 | 14 | function isMessagable(err: unknown): err is { message: string } { 15 | return (err instanceof Error || (typeof err === 'object' && !!err && 'message' in err && typeof err.message === 'string')) 16 | } 17 | 18 | export async function collectBlock( 19 | mineflayer: Mineflayer, 20 | blockType: string, 21 | num = 1, 22 | range = 16, 23 | ): Promise { 24 | if (num < 1) { 25 | logger.log(`Invalid number of blocks to collect: ${num}.`) 26 | return false 27 | } 28 | 29 | const blockTypes = [blockType] 30 | 31 | // Add block variants 32 | if ( 33 | [ 34 | 'coal', 35 | 'diamond', 36 | 'emerald', 37 | 'iron', 38 | 'gold', 39 | 'lapis_lazuli', 40 | 'redstone', 41 | 'copper', 42 | ].includes(blockType) 43 | ) { 44 | blockTypes.push(`${blockType}_ore`, `deepslate_${blockType}_ore`) 45 | } 46 | if (blockType.endsWith('ore')) { 47 | blockTypes.push(`deepslate_${blockType}`) 48 | } 49 | if (blockType === 'dirt') { 50 | blockTypes.push('grass_block') 51 | } 52 | 53 | let collected = 0 54 | 55 | while (collected < num) { 56 | const blocks = getNearestBlocks(mineflayer, blockTypes, range) 57 | 58 | if (blocks.length === 0) { 59 | if (collected === 0) 60 | logger.log(`No ${blockType} nearby to collect.`) 61 | else logger.log(`No more ${blockType} nearby to collect.`) 62 | break 63 | } 64 | 65 | const block = blocks[0] 66 | 67 | try { 68 | // Equip appropriate tool 69 | if (mineflayer.bot.game.gameMode !== 'creative') { 70 | await mineflayer.bot.tool.equipForBlock(block) 71 | const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null 72 | if (!block.canHarvest(itemId)) { 73 | logger.log(`Don't have right tools to harvest ${block.name}.`) 74 | if (block.name.includes('ore') || block.name.includes('stone')) { 75 | await ensurePickaxe(mineflayer) 76 | } 77 | throw new Error('Don\'t have right tools to harvest block.') 78 | } 79 | } 80 | 81 | // Implement vein mining 82 | const veinBlocks = findVeinBlocks(mineflayer, block, 100, range, 1) 83 | 84 | for (const veinBlock of veinBlocks) { 85 | if (collected >= num) 86 | break 87 | 88 | // Move to the block using pathfinder 89 | const goal = new pathfinder.goals.GoalGetToBlock( 90 | veinBlock.position.x, 91 | veinBlock.position.y, 92 | veinBlock.position.z, 93 | ) 94 | await mineflayer.bot.pathfinder.goto(goal) 95 | 96 | // Break the block and collect drops 97 | await mineAndCollect(mineflayer, veinBlock) 98 | 99 | collected++ 100 | 101 | // Check if inventory is full 102 | if (mineflayer.bot.inventory.emptySlotCount() === 0) { 103 | logger.log('Inventory is full, cannot collect more items.') 104 | break 105 | } 106 | } 107 | } 108 | catch (err) { 109 | logger.log(`Failed to collect ${blockType}: ${err}.`) 110 | if (isMessagable(err) && err.message.includes('Digging aborted')) { 111 | break 112 | } 113 | 114 | continue 115 | } 116 | } 117 | 118 | logger.log(`Collected ${collected} ${blockType}(s).`) 119 | return collected > 0 120 | } 121 | 122 | // Helper function to mine a block and collect drops 123 | async function mineAndCollect(mineflayer: Mineflayer, block: Block): Promise { 124 | // Break the block 125 | await breakBlockAt(mineflayer, block.position.x, block.position.y, block.position.z) 126 | // Use your existing function to pick up nearby items 127 | await pickupNearbyItems(mineflayer, 5) 128 | } 129 | 130 | // Function to find connected blocks (vein mining) 131 | function findVeinBlocks( 132 | mineflayer: Mineflayer, 133 | startBlock: Block, 134 | maxBlocks = 100, 135 | maxDistance = 16, 136 | floodRadius = 1, 137 | ): Block[] { 138 | const veinBlocks: Block[] = [] 139 | const visited = new Set() 140 | const queue: Block[] = [startBlock] 141 | 142 | while (queue.length > 0 && veinBlocks.length < maxBlocks) { 143 | const block = queue.shift() 144 | if (!block) 145 | continue 146 | const key = block.position.toString() 147 | 148 | if (visited.has(key)) 149 | continue 150 | visited.add(key) 151 | 152 | if (block.name !== startBlock.name) 153 | continue 154 | if (block.position.distanceTo(startBlock.position) > maxDistance) 155 | continue 156 | 157 | veinBlocks.push(block) 158 | 159 | // Check neighboring blocks within floodRadius 160 | for (let dx = -floodRadius; dx <= floodRadius; dx++) { 161 | for (let dy = -floodRadius; dy <= floodRadius; dy++) { 162 | for (let dz = -floodRadius; dz <= floodRadius; dz++) { 163 | if (dx === 0 && dy === 0 && dz === 0) 164 | continue // Skip the current block 165 | const neighborPos = block.position.offset(dx, dy, dz) 166 | const neighborBlock = mineflayer.bot.blockAt(neighborPos) 167 | if (neighborBlock && !visited.has(neighborPos.toString())) { 168 | queue.push(neighborBlock) 169 | } 170 | } 171 | } 172 | } 173 | } 174 | 175 | return veinBlocks 176 | } 177 | -------------------------------------------------------------------------------- /src/skills/actions/ensure.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../../libs/mineflayer' 2 | 3 | import { useLogger } from '../../utils/logger' 4 | import { getItemId } from '../../utils/mcdata' 5 | import { craftRecipe } from '../crafting' 6 | import { moveAway } from '../movement' 7 | import { collectBlock } from './collect-block' 8 | import { gatherWood } from './gather-wood' 9 | import { getItemCount } from './inventory' 10 | 11 | // Constants for crafting and gathering 12 | const PLANKS_PER_LOG = 4 13 | const STICKS_PER_PLANK = 2 14 | const logger = useLogger() 15 | 16 | // Helper function to ensure a crafting table 17 | export async function ensureCraftingTable(mineflayer: Mineflayer): Promise { 18 | logger.log('Bot: Checking for a crafting table...') 19 | 20 | let hasCraftingTable = getItemCount(mineflayer, 'crafting_table') > 0 21 | 22 | if (hasCraftingTable) { 23 | logger.log('Bot: Crafting table is available.') 24 | return true 25 | } 26 | 27 | while (!hasCraftingTable) { 28 | const planksEnsured = await ensurePlanks(mineflayer, 4) 29 | if (!planksEnsured) { 30 | logger.error('Bot: Failed to ensure planks.') 31 | continue 32 | } 33 | 34 | // Craft crafting table 35 | hasCraftingTable = await craftRecipe(mineflayer, 'crafting_table', 1) 36 | if (hasCraftingTable) { 37 | mineflayer.bot.chat('I have made a crafting table.') 38 | logger.log('Bot: Crafting table crafted.') 39 | } 40 | else { 41 | logger.error('Bot: Failed to craft crafting table.') 42 | } 43 | } 44 | 45 | return hasCraftingTable 46 | } 47 | 48 | // Helper function to ensure a specific amount of planks 49 | export async function ensurePlanks(mineflayer: Mineflayer, neededAmount: number): Promise { 50 | logger.log('Bot: Checking for planks...') 51 | 52 | let planksCount = getItemCount(mineflayer, 'planks') 53 | 54 | if (neededAmount < planksCount) { 55 | logger.log('Bot: Have enough planks.') 56 | return true 57 | } 58 | 59 | while (neededAmount > planksCount) { 60 | const logsNeeded = Math.ceil((neededAmount - planksCount) / PLANKS_PER_LOG) 61 | 62 | // Get all available log types in inventory 63 | const availableLogs = mineflayer.bot.inventory 64 | .items() 65 | .filter(item => item.name.includes('log')) 66 | 67 | // If no logs available, gather more wood 68 | if (availableLogs.length === 0) { 69 | await gatherWood(mineflayer, logsNeeded, 80) 70 | logger.error('Bot: Not enough logs for planks.') 71 | continue 72 | } 73 | 74 | // Iterate over each log type to craft planks 75 | for (const log of availableLogs) { 76 | const logType = log.name.replace('_log', '') // Get log type without "_log" suffix 77 | const logsToCraft = Math.min(log.count, logsNeeded) 78 | 79 | logger.log( 80 | `Trying to make ${logsToCraft * PLANKS_PER_LOG} ${logType}_planks`, 81 | ) 82 | logger.log(`NeededAmount: ${neededAmount}, while I have ${planksCount}`) 83 | 84 | const crafted = await craftRecipe( 85 | mineflayer, 86 | `${logType}_planks`, 87 | logsToCraft * PLANKS_PER_LOG, 88 | ) 89 | if (crafted) { 90 | planksCount = getItemCount(mineflayer, 'planks') 91 | mineflayer.bot.chat( 92 | `I have crafted ${logsToCraft * PLANKS_PER_LOG} ${logType} planks.`, 93 | ) 94 | logger.log(`Bot: ${logType} planks crafted.`) 95 | } 96 | else { 97 | logger.error(`Bot: Failed to craft ${logType} planks.`) 98 | return false 99 | } 100 | 101 | // Check if we have enough planks after crafting 102 | if (planksCount >= neededAmount) 103 | break 104 | } 105 | } 106 | 107 | return planksCount >= neededAmount 108 | }; 109 | 110 | // Helper function to ensure a specific amount of sticks 111 | export async function ensureSticks(mineflayer: Mineflayer, neededAmount: number): Promise { 112 | logger.log('Bot: Checking for sticks...') 113 | 114 | let sticksCount = getItemCount(mineflayer, 'stick') 115 | 116 | if (neededAmount <= sticksCount) { 117 | logger.log('Bot: Have enough sticks.') 118 | return true 119 | } 120 | 121 | while (neededAmount >= sticksCount) { 122 | const planksCount = getItemCount(mineflayer, 'planks') 123 | const planksNeeded = Math.max( 124 | Math.ceil((neededAmount - sticksCount) / STICKS_PER_PLANK), 125 | 4, 126 | ) 127 | 128 | if (planksCount >= planksNeeded) { 129 | try { 130 | const sticksId = getItemId('stick') 131 | const recipe = await mineflayer.bot.recipesFor(sticksId, null, 1, null)[0] 132 | await mineflayer.bot.craft(recipe, neededAmount - sticksCount) 133 | sticksCount = getItemCount(mineflayer, 'stick') 134 | mineflayer.bot.chat(`I have made ${Math.abs(neededAmount - sticksCount)} sticks.`) 135 | logger.log(`Bot: Sticks crafted.`) 136 | } 137 | catch (err) { 138 | logger.withError(err).error('Bot: Failed to craft sticks.') 139 | return false 140 | } 141 | } 142 | else { 143 | await ensurePlanks(mineflayer, planksNeeded) 144 | logger.error('Bot: Not enough planks for sticks.') 145 | } 146 | } 147 | 148 | return sticksCount >= neededAmount 149 | } 150 | 151 | // Ensure a specific number of chests 152 | export async function ensureChests(mineflayer: Mineflayer, quantity: number = 1): Promise { 153 | logger.log(`Bot: Checking for ${quantity} chest(s)...`) 154 | 155 | // Count the number of chests the bot already has 156 | let chestCount = getItemCount(mineflayer, 'chest') 157 | 158 | if (chestCount >= quantity) { 159 | logger.log(`Bot: Already has ${quantity} or more chest(s).`) 160 | return true 161 | } 162 | 163 | while (chestCount < quantity) { 164 | const planksEnsured = await ensurePlanks(mineflayer, 8 * quantity) // 8 planks per chest 165 | if (!planksEnsured) { 166 | logger.error('Bot: Failed to ensure planks for chest(s).') 167 | continue 168 | } 169 | 170 | // Craft the chest(s) 171 | const crafted = await craftRecipe(mineflayer, 'chest', quantity - chestCount) 172 | if (crafted) { 173 | chestCount = getItemCount(mineflayer, 'chest') 174 | mineflayer.bot.chat(`I have crafted ${quantity} chest(s).`) 175 | logger.log(`Bot: ${quantity} chest(s) crafted.`) 176 | continue 177 | } 178 | else { 179 | logger.error('Bot: Failed to craft chest(s).') 180 | } 181 | } 182 | return chestCount >= quantity 183 | } 184 | 185 | // Ensure a specific number of furnaces 186 | export async function ensureFurnaces(mineflayer: Mineflayer, quantity: number = 1): Promise { 187 | logger.log(`Bot: Checking for ${quantity} furnace(s)...`) 188 | 189 | // Count the number of furnaces the bot already has 190 | let furnaceCount = getItemCount(mineflayer, 'furnace') 191 | 192 | if (furnaceCount >= quantity) { 193 | logger.log(`Bot: Already has ${quantity} or more furnace(s).`) 194 | return true 195 | } 196 | 197 | while (furnaceCount < quantity) { 198 | const stoneEnsured = await ensureCobblestone(mineflayer, 8 * (quantity - furnaceCount)) // 8 stone blocks per furnace 199 | if (!stoneEnsured) { 200 | logger.error('Bot: Failed to ensure stone for furnace(s).') 201 | continue 202 | } 203 | 204 | // Craft the furnace(s) 205 | const crafted = await craftRecipe(mineflayer, 'furnace', quantity - furnaceCount) 206 | if (crafted) { 207 | furnaceCount = getItemCount(mineflayer, 'furnace') 208 | mineflayer.bot.chat(`I have crafted ${quantity} furnace(s).`) 209 | logger.log(`Bot: ${quantity} furnace(s) crafted.`) 210 | continue 211 | } 212 | else { 213 | logger.error('Bot: Failed to craft furnace(s).') 214 | } 215 | } 216 | return furnaceCount >= quantity 217 | } 218 | 219 | // Ensure a specific number of torches 220 | export async function ensureTorches(mineflayer: Mineflayer, quantity: number = 1): Promise { 221 | logger.log(`Bot: Checking for ${quantity} torch(es)...`) 222 | 223 | // Count the number of torches the bot already has 224 | let torchCount = getItemCount(mineflayer, 'torch') 225 | 226 | if (torchCount >= quantity) { 227 | logger.log(`Bot: Already has ${quantity} or more torch(es).`) 228 | return true 229 | } 230 | 231 | while (torchCount < quantity) { 232 | const sticksEnsured = await ensureSticks(mineflayer, quantity - torchCount) // 1 stick per 4 torches 233 | const coalEnsured = await ensureCoal( 234 | mineflayer, 235 | Math.ceil((quantity - torchCount) / 4), 236 | ) // 1 coal per 4 torches 237 | 238 | if (!sticksEnsured || !coalEnsured) { 239 | logger.error('Bot: Failed to ensure sticks or coal for torch(es).') 240 | continue 241 | } 242 | 243 | // Craft the torch(es) 244 | const crafted = await craftRecipe(mineflayer, 'torch', quantity - torchCount) 245 | if (crafted) { 246 | torchCount = getItemCount(mineflayer, 'torch') 247 | mineflayer.bot.chat(`I have crafted ${quantity} torch(es).`) 248 | logger.log(`Bot: ${quantity} torch(es) crafted.`) 249 | continue 250 | } 251 | else { 252 | logger.error('Bot: Failed to craft torch(es).') 253 | } 254 | } 255 | return torchCount >= quantity 256 | } 257 | 258 | // Ensure a campfire 259 | // Todo: rework 260 | export async function ensureCampfire(mineflayer: Mineflayer): Promise { 261 | logger.log('Bot: Checking for a campfire...') 262 | 263 | const hasCampfire = getItemCount(mineflayer, 'campfire') > 0 264 | 265 | if (hasCampfire) { 266 | logger.log('Bot: Campfire is already available.') 267 | return true 268 | } 269 | 270 | const logsEnsured = await ensurePlanks(mineflayer, 3) // Need 3 logs for a campfire 271 | const sticksEnsured = await ensureSticks(mineflayer, 3) // Need 3 sticks for a campfire 272 | const coalEnsured = await ensureCoal(mineflayer, 1) // Need 1 coal or charcoal for a campfire 273 | 274 | if (!logsEnsured || !sticksEnsured || !coalEnsured) { 275 | logger.error('Bot: Failed to ensure resources for campfire.') 276 | } 277 | 278 | const crafted = await craftRecipe(mineflayer, 'campfire', 1) 279 | if (crafted) { 280 | mineflayer.bot.chat('I have crafted a campfire.') 281 | logger.log('Bot: Campfire crafted.') 282 | return true 283 | } 284 | else { 285 | logger.error('Bot: Failed to craft campfire.') 286 | } 287 | 288 | return hasCampfire 289 | } 290 | 291 | // Helper function to gather cobblestone 292 | export async function ensureCobblestone(mineflayer: Mineflayer, requiredCobblestone: number, maxDistance: number = 4): Promise { 293 | let cobblestoneCount = getItemCount(mineflayer, 'cobblestone') 294 | 295 | while (cobblestoneCount < requiredCobblestone) { 296 | logger.log('Bot: Gathering more cobblestone...') 297 | const cobblestoneShortage = requiredCobblestone - cobblestoneCount 298 | 299 | try { 300 | const success = await collectBlock( 301 | mineflayer, 302 | 'stone', 303 | cobblestoneShortage, 304 | maxDistance, 305 | ) 306 | if (!success) { 307 | await moveAway(mineflayer, 30) 308 | continue 309 | } 310 | } 311 | catch (err) { 312 | if (err instanceof Error && err.message.includes('right tools')) { 313 | await ensurePickaxe(mineflayer) 314 | continue 315 | } 316 | else { 317 | logger.withError(err).error('Error collecting cobblestone') 318 | await moveAway(mineflayer, 30) 319 | continue 320 | } 321 | } 322 | 323 | cobblestoneCount = getItemCount(mineflayer, 'cobblestone') 324 | } 325 | 326 | logger.log('Bot: Collected enough cobblestone.') 327 | return true 328 | } 329 | 330 | export async function ensureCoal(mineflayer: Mineflayer, neededAmount: number, maxDistance: number = 4): Promise { 331 | logger.log('Bot: Checking for coal...') 332 | let coalCount = getItemCount(mineflayer, 'coal') 333 | 334 | while (coalCount < neededAmount) { 335 | logger.log('Bot: Gathering more coal...') 336 | const coalShortage = neededAmount - coalCount 337 | 338 | try { 339 | await collectBlock(mineflayer, 'stone', coalShortage, maxDistance) 340 | } 341 | catch (err) { 342 | if (err instanceof Error && err.message.includes('right tools')) { 343 | await ensurePickaxe(mineflayer) 344 | continue 345 | } 346 | else { 347 | logger.withError(err).error('Error collecting cobblestone:') 348 | moveAway(mineflayer, 30) 349 | continue 350 | } 351 | } 352 | 353 | coalCount = getItemCount(mineflayer, 'cobblestone') 354 | } 355 | 356 | logger.log('Bot: Collected enough cobblestone.') 357 | return true 358 | } 359 | 360 | // Define the valid tool types as a union type 361 | type ToolType = 'pickaxe' | 'sword' | 'axe' | 'shovel' | 'hoe' 362 | 363 | // Define the valid materials as a union type 364 | type MaterialType = 'diamond' | 'golden' | 'iron' | 'stone' | 'wooden' 365 | 366 | // Constants for crafting tools 367 | const TOOLS_MATERIALS: MaterialType[] = [ 368 | 'diamond', 369 | 'golden', 370 | 'iron', 371 | 'stone', 372 | 'wooden', 373 | ] 374 | 375 | export function materialsForTool(tool: ToolType): number { 376 | switch (tool) { 377 | case 'pickaxe': 378 | case 'axe': 379 | return 3 380 | case 'sword': 381 | case 'hoe': 382 | return 2 383 | case 'shovel': 384 | return 1 385 | default: 386 | return 0 387 | } 388 | } 389 | 390 | // Helper function to ensure a specific tool, checking from best materials to wood 391 | async function ensureTool(mineflayer: Mineflayer, toolType: ToolType, quantity: number = 1): Promise { 392 | logger.log(`Bot: Checking for ${quantity} ${toolType}(s)...`) 393 | 394 | const neededMaterials = materialsForTool(toolType) 395 | 396 | // Check how many of the tool the bot currently has 397 | let toolCount = mineflayer.bot.inventory 398 | .items() 399 | .filter(item => item.name.includes(toolType)) 400 | .length 401 | 402 | if (toolCount >= quantity) { 403 | logger.log(`Bot: Already has ${quantity} or more ${toolType}(s).`) 404 | return true 405 | } 406 | 407 | while (toolCount < quantity) { 408 | // Iterate over the tool materials from best (diamond) to worst (wooden) 409 | for (const material of TOOLS_MATERIALS) { 410 | const toolRecipe = `${material}_${toolType}` // Craft tool name like diamond_pickaxe, iron_sword 411 | const hasResources = await hasResourcesForTool(mineflayer, material, neededMaterials) 412 | 413 | // Check if we have enough material for the current tool 414 | if (hasResources) { 415 | await ensureCraftingTable(mineflayer) 416 | 417 | const sticksEnsured = await ensureSticks(mineflayer, 2) 418 | 419 | if (!sticksEnsured) { 420 | logger.error( 421 | `Bot: Failed to ensure planks or sticks for wooden ${toolType}.`, 422 | ) 423 | continue 424 | } 425 | 426 | // Craft the tool 427 | const crafted = await craftRecipe(mineflayer, toolRecipe, 1) 428 | if (crafted) { 429 | toolCount++ 430 | mineflayer.bot.chat( 431 | `I have crafted a ${material} ${toolType}. Total ${toolType}(s): ${toolCount}/${quantity}`, 432 | ) 433 | logger.log( 434 | `Bot: ${material} ${toolType} crafted. Total ${toolCount}/${quantity}`, 435 | ) 436 | if (toolCount >= quantity) 437 | return true 438 | } 439 | else { 440 | logger.error(`Bot: Failed to craft ${material} ${toolType}.`) 441 | } 442 | } 443 | else if (material === 'wooden') { 444 | // Crafting planks if we don't have enough resources for wooden tools 445 | logger.log(`Bot: Crafting planks for ${material} ${toolType}...`) 446 | await ensurePlanks(mineflayer, 4) 447 | } 448 | } 449 | } 450 | 451 | return toolCount >= quantity 452 | } 453 | 454 | // Helper function to check if the bot has enough materials to craft a tool of a specific material 455 | export async function hasResourcesForTool( 456 | mineflayer: Mineflayer, 457 | material: MaterialType, 458 | num = 3, // Number of resources needed for most tools 459 | ): Promise { 460 | switch (material) { 461 | case 'diamond': 462 | return getItemCount(mineflayer, 'diamond') >= num 463 | case 'golden': 464 | return getItemCount(mineflayer, 'gold_ingot') >= num 465 | case 'iron': 466 | return getItemCount(mineflayer, 'iron_ingot') >= num 467 | case 'stone': 468 | return getItemCount(mineflayer, 'cobblestone') >= num 469 | case 'wooden': 470 | return getItemCount(mineflayer, 'planks') >= num 471 | default: 472 | return false 473 | } 474 | } 475 | 476 | // Helper functions for specific tools: 477 | 478 | // Ensure a pickaxe 479 | export async function ensurePickaxe(mineflayer: Mineflayer, quantity: number = 1): Promise { 480 | return await ensureTool(mineflayer, 'pickaxe', quantity) 481 | }; 482 | 483 | // Ensure a sword 484 | export async function ensureSword(mineflayer: Mineflayer, quantity: number = 1): Promise { 485 | return await ensureTool(mineflayer, 'sword', quantity) 486 | }; 487 | 488 | // Ensure an axe 489 | export async function ensureAxe(mineflayer: Mineflayer, quantity: number = 1): Promise { 490 | return await ensureTool(mineflayer, 'axe', quantity) 491 | }; 492 | 493 | // Ensure a shovel 494 | export async function ensureShovel(mineflayer: Mineflayer, quantity: number = 1): Promise { 495 | return await ensureTool(mineflayer, 'shovel', quantity) 496 | }; 497 | 498 | export async function ensureHoe(mineflayer: Mineflayer, quantity: number = 1): Promise { 499 | return await ensureTool(mineflayer, 'hoe', quantity) 500 | }; 501 | -------------------------------------------------------------------------------- /src/skills/actions/gather-wood.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../../libs/mineflayer' 2 | 3 | import { sleep } from '../../utils/helper' 4 | import { useLogger } from '../../utils/logger' 5 | import { breakBlockAt } from '../blocks' 6 | import { goToPosition, moveAway } from '../movement' 7 | import { getNearestBlocks } from '../world' 8 | import { pickupNearbyItems } from './world-interactions' 9 | 10 | const logger = useLogger() 11 | 12 | /** 13 | * Gather wood blocks nearby to collect logs. 14 | * 15 | * @param mineflayer The mineflayer instance. 16 | * @param num The number of wood logs to gather. 17 | * @param maxDistance The maximum distance to search for wood blocks. 18 | * @returns Whether the wood gathering was successful. 19 | */ 20 | export async function gatherWood( 21 | mineflayer: Mineflayer, 22 | num: number, 23 | maxDistance = 64, 24 | ): Promise { 25 | logger.log(`Gathering wood... I need to collect ${num} logs.`) 26 | mineflayer.bot.chat(`Gathering wood... I need to collect ${num} logs.`) 27 | 28 | try { 29 | let logsCount = getLogsCount(mineflayer) 30 | logger.log(`I currently have ${logsCount} logs.`) 31 | 32 | while (logsCount < num) { 33 | // Gather 1 extra log to account for any failures 34 | logger.log(`Looking for wood blocks nearby...`, logsCount, num) 35 | 36 | const woodBlock = mineflayer.bot.findBlock({ 37 | matching: block => block.name.includes('log'), 38 | maxDistance, 39 | }) 40 | 41 | if (!woodBlock) { 42 | logger.log('No wood blocks found nearby.') 43 | await moveAway(mineflayer, 50) 44 | continue 45 | } 46 | 47 | const destinationReached = await goToPosition( 48 | mineflayer, 49 | woodBlock.position.x, 50 | woodBlock.position.y, 51 | woodBlock.position.z, 52 | 2, 53 | ) 54 | 55 | if (!destinationReached) { 56 | logger.log('Unable to reach the wood block.') 57 | continue // Try finding another wood block 58 | } 59 | 60 | const aTree = await getNearestBlocks(mineflayer, woodBlock.name, 4, 4) 61 | if (aTree.length === 0) { 62 | logger.log('No wood blocks found nearby.') 63 | await moveAway(mineflayer, 15) 64 | continue 65 | } 66 | 67 | try { 68 | for (const aLog of aTree) { 69 | await breakBlockAt(mineflayer, aLog.position.x, aLog.position.y, aLog.position.z) 70 | await sleep(1200) // Simulate gathering delay 71 | } 72 | await pickupNearbyItems(mineflayer) 73 | await sleep(2500) 74 | logsCount = getLogsCount(mineflayer) 75 | logger.log(`Collected logs. Total logs now: ${logsCount}.`) 76 | } 77 | catch (digError) { 78 | console.error('Failed to break the wood block:', digError) 79 | continue // Attempt to find and break another wood block 80 | } 81 | } 82 | 83 | logger.log(`Wood gathering complete! Total logs collected: ${logsCount}.`) 84 | return true 85 | } 86 | catch (error) { 87 | console.error('Failed to gather wood:', error) 88 | return false 89 | } 90 | } 91 | 92 | /** 93 | * Helper function to count the number of logs in the inventory. 94 | * @returns The total number of logs. 95 | */ 96 | export function getLogsCount(mineflayer: Mineflayer): number { 97 | return mineflayer.bot.inventory 98 | .items() 99 | .filter(item => item.name.includes('log')) 100 | .reduce((acc, item) => acc + item.count, 0) 101 | } 102 | -------------------------------------------------------------------------------- /src/skills/actions/inventory.ts: -------------------------------------------------------------------------------- 1 | import type { Item } from 'prismarine-item' 2 | import type { Mineflayer } from '../../libs/mineflayer' 3 | 4 | import { useLogger } from '../../utils/logger' 5 | import { goToPlayer, goToPosition } from '../movement' 6 | import { getNearestBlock } from '../world' 7 | 8 | const logger = useLogger() 9 | 10 | /** 11 | * Equip an item from the bot's inventory. 12 | * @param mineflayer The mineflayer instance. 13 | * @param itemName The name of the item to equip. 14 | * @returns Whether the item was successfully equipped. 15 | */ 16 | export async function equip(mineflayer: Mineflayer, itemName: string): Promise { 17 | const item = mineflayer.bot.inventory 18 | .items() 19 | .find(item => item.name.includes(itemName)) 20 | if (!item) { 21 | logger.log(`You do not have any ${itemName} to equip.`) 22 | return false 23 | } 24 | let destination: 'hand' | 'head' | 'torso' | 'legs' | 'feet' = 'hand' 25 | if (itemName.includes('leggings')) 26 | destination = 'legs' 27 | else if (itemName.includes('boots')) 28 | destination = 'feet' 29 | else if (itemName.includes('helmet')) 30 | destination = 'head' 31 | else if (itemName.includes('chestplate')) 32 | destination = 'torso' 33 | 34 | await mineflayer.bot.equip(item, destination) 35 | return true 36 | } 37 | 38 | /** 39 | * Discard an item from the bot's inventory. 40 | * @param mineflayer The mineflayer instance. 41 | * @param itemName The name of the item to discard. 42 | * @param num The number of items to discard. Default is -1 for all. 43 | * @returns Whether the item was successfully discarded. 44 | */ 45 | export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { 46 | let discarded = 0 47 | while (true) { 48 | const item = mineflayer.bot.inventory 49 | .items() 50 | .find(item => item.name.includes(itemName)) 51 | if (!item) { 52 | break 53 | } 54 | const toDiscard 55 | = num === -1 ? item.count : Math.min(num - discarded, item.count) 56 | await mineflayer.bot.toss(item.type, null, toDiscard) 57 | discarded += toDiscard 58 | if (num !== -1 && discarded >= num) { 59 | break 60 | } 61 | } 62 | if (discarded === 0) { 63 | logger.log(`You do not have any ${itemName} to discard.`) 64 | return false 65 | } 66 | logger.log(`Successfully discarded ${discarded} ${itemName}.`) 67 | return true 68 | } 69 | 70 | export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { 71 | const chest = getNearestBlock(mineflayer, 'chest', 32) 72 | if (!chest) { 73 | logger.log(`Could not find a chest nearby.`) 74 | return false 75 | } 76 | const item = mineflayer.bot.inventory 77 | .items() 78 | .find(item => item.name.includes(itemName)) 79 | if (!item) { 80 | logger.log(`You do not have any ${itemName} to put in the chest.`) 81 | return false 82 | } 83 | const toPut = num === -1 ? item.count : Math.min(num, item.count) 84 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) 85 | const chestContainer = await mineflayer.bot.openContainer(chest) 86 | await chestContainer.deposit(item.type, null, toPut) 87 | await chestContainer.close() 88 | logger.log(`Successfully put ${toPut} ${itemName} in the chest.`) 89 | return true 90 | } 91 | 92 | export async function takeFromChest( 93 | mineflayer: Mineflayer, 94 | itemName: string, 95 | num = -1, 96 | ): Promise { 97 | const chest = getNearestBlock(mineflayer, 'chest', 32) 98 | if (!chest) { 99 | logger.log(`Could not find a chest nearby.`) 100 | return false 101 | } 102 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) 103 | const chestContainer = await mineflayer.bot.openContainer(chest) 104 | const item = chestContainer 105 | .containerItems() 106 | .find(item => item.name.includes(itemName)) 107 | if (!item) { 108 | logger.log(`Could not find any ${itemName} in the chest.`) 109 | await chestContainer.close() 110 | return false 111 | } 112 | const toTake = num === -1 ? item.count : Math.min(num, item.count) 113 | await chestContainer.withdraw(item.type, null, toTake) 114 | await chestContainer.close() 115 | logger.log(`Successfully took ${toTake} ${itemName} from the chest.`) 116 | return true 117 | } 118 | 119 | /** 120 | * View the contents of a chest near the bot. 121 | * @param mineflayer The mineflayer instance. 122 | * @returns Whether the chest was successfully viewed. 123 | */ 124 | export async function viewChest(mineflayer: Mineflayer): Promise { 125 | const chest = getNearestBlock(mineflayer, 'chest', 32) 126 | if (!chest) { 127 | logger.log(`Could not find a chest nearby.`) 128 | return false 129 | } 130 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) 131 | const chestContainer = await mineflayer.bot.openContainer(chest) 132 | const items = chestContainer.containerItems() 133 | if (items.length === 0) { 134 | logger.log(`The chest is empty.`) 135 | } 136 | else { 137 | logger.log(`The chest contains:`) 138 | for (const item of items) { 139 | logger.log(`${item.count} ${item.name}`) 140 | } 141 | } 142 | await chestContainer.close() 143 | return true 144 | } 145 | 146 | /** 147 | * Ask to bot to eat a food item from its inventory. 148 | * @param mineflayer The mineflayer instance. 149 | * @param foodName The name of the food item to eat. 150 | * @returns Whether the food was successfully eaten. 151 | */ 152 | export async function eat(mineflayer: Mineflayer, foodName = ''): Promise { 153 | let item: Item | undefined 154 | let name: string 155 | if (foodName) { 156 | item = mineflayer.bot.inventory.items().find(item => item.name.includes(foodName)) 157 | name = foodName 158 | } 159 | else { 160 | // @ts-expect-error -- ? 161 | item = mineflayer.bot.inventory.items().find(item => item.foodPoints > 0) 162 | name = 'food' 163 | } 164 | if (!item) { 165 | logger.log(`You do not have any ${name} to eat.`) 166 | return false 167 | } 168 | await mineflayer.bot.equip(item, 'hand') 169 | await mineflayer.bot.consume() 170 | logger.log(`Successfully ate ${item.name}.`) 171 | return true 172 | } 173 | 174 | /** 175 | * Give an item to a player. 176 | * @param mineflayer The mineflayer instance. 177 | * @param itemType The name of the item to give. 178 | * @param username The username of the player to give the item to. 179 | * @param num The number of items to give. 180 | * @returns Whether the item was successfully given. 181 | */ 182 | export async function giveToPlayer( 183 | mineflayer: Mineflayer, 184 | itemType: string, 185 | username: string, 186 | num = 1, 187 | ): Promise { 188 | const player = mineflayer.bot.players[username]?.entity 189 | if (!player) { 190 | logger.log(`Could not find a player with username: ${username}.`) 191 | return false 192 | } 193 | await goToPlayer(mineflayer, username) 194 | await mineflayer.bot.lookAt(player.position) 195 | await discard(mineflayer, itemType, num) 196 | return true 197 | } 198 | 199 | /** 200 | * List the items in the bot's inventory. 201 | * @param mineflayer The mineflayer instance. 202 | * @returns An array of items in the bot's inventory. 203 | */ 204 | export async function listInventory(mineflayer: Mineflayer): Promise<{ name: string, count: number }[]> { 205 | const items = await mineflayer.bot.inventory.items() 206 | // sayItems(mineflayer, items) 207 | 208 | return items.map(item => ({ 209 | name: item.name, 210 | count: item.count, 211 | })) 212 | } 213 | 214 | export async function checkForItem(mineflayer: Mineflayer, itemName: string): Promise { 215 | const items = await mineflayer.bot.inventory.items() 216 | const searchableItems = items.filter(item => item.name.includes(itemName)) 217 | sayItems(mineflayer, searchableItems) 218 | } 219 | 220 | export async function sayItems(mineflayer: Mineflayer, items: Array | null = null) { 221 | if (!items) { 222 | items = mineflayer.bot.inventory.items() 223 | if (mineflayer.bot.registry.isNewerOrEqualTo('1.9') && mineflayer.bot.inventory.slots[45]) 224 | items.push(mineflayer.bot.inventory.slots[45]) 225 | } 226 | const output = items.map(item => `${item.name} x ${item.count}`).join(', ') 227 | if (output) { 228 | mineflayer.bot.chat(`My inventory contains: ${output}`) 229 | } 230 | else { 231 | mineflayer.bot.chat('My inventory is empty.') 232 | } 233 | } 234 | 235 | /** 236 | * Find the number of free slots in the bot's inventory. 237 | * @param mineflayer The mineflayer instance. 238 | * @returns The number of free slots in the bot's inventory. 239 | */ 240 | export function checkFreeSpace(mineflayer: Mineflayer): number { 241 | const totalSlots = mineflayer.bot.inventory.slots.length 242 | const usedSlots = mineflayer.bot.inventory.items().length 243 | const freeSlots = totalSlots - usedSlots 244 | logger.log(`You have ${freeSlots} free slots in your inventory.`) 245 | return freeSlots 246 | } 247 | 248 | /** 249 | * Transfer all items from the bot's inventory to a chest. 250 | * @param mineflayer The mineflayer instance. 251 | * @returns Whether the items were successfully transferred. 252 | */ 253 | export async function transferAllToChest(mineflayer: Mineflayer): Promise { 254 | const chest = getNearestBlock(mineflayer, 'chest', 32) 255 | if (!chest) { 256 | logger.log(`Could not find a chest nearby.`) 257 | return false 258 | } 259 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) 260 | const chestContainer = await mineflayer.bot.openContainer(chest) 261 | 262 | for (const item of mineflayer.bot.inventory.items()) { 263 | await chestContainer.deposit(item.type, null, item.count) 264 | logger.log(`Put ${item.count} ${item.name} in the chest.`) 265 | } 266 | 267 | await chestContainer.close() 268 | return true 269 | } 270 | 271 | /** 272 | * Utility function to get item count in inventory 273 | * @param mineflayer The mineflayer instance. 274 | * @param itemName - The name of the item to count. 275 | * @returns number of items in inventory 276 | */ 277 | export function getItemCount(mineflayer: Mineflayer, itemName: string): number { 278 | return mineflayer.bot.inventory 279 | .items() 280 | .filter(item => item.name.includes(itemName)) 281 | .reduce((acc, item) => acc + item.count, 0) 282 | } 283 | 284 | /** 285 | * Organize the bot's inventory. 286 | * @param mineflayer The mineflayer instance. 287 | * @returns Whether the inventory was successfully organized. 288 | */ 289 | export async function organizeInventory(mineflayer: Mineflayer): Promise { 290 | const items = mineflayer.bot.inventory.items() 291 | if (items.length === 0) { 292 | logger.log(`Inventory is empty, nothing to organize.`) 293 | return 294 | } 295 | 296 | for (const item of items) { 297 | await mineflayer.bot.moveSlotItem( 298 | item.slot, 299 | mineflayer.bot.inventory.findInventoryItem(item.type, null, false)?.slot ?? item.slot, 300 | ) 301 | } 302 | logger.log(`Inventory has been organized.`) 303 | } 304 | -------------------------------------------------------------------------------- /src/skills/actions/world-interactions.ts: -------------------------------------------------------------------------------- 1 | import type { Bot } from 'mineflayer' 2 | import type { Block } from 'prismarine-block' 3 | import type { Mineflayer } from '../../libs/mineflayer' 4 | 5 | import pathfinder from 'mineflayer-pathfinder' 6 | import { Vec3 } from 'vec3' 7 | 8 | import { sleep } from '../../utils/helper' 9 | import { useLogger } from '../../utils/logger' 10 | import { getNearestBlock, makeItem } from '../../utils/mcdata' 11 | import { goToPosition } from '../movement' 12 | 13 | const logger = useLogger() 14 | 15 | export async function placeBlock( 16 | mineflayer: Mineflayer, 17 | blockType: string, 18 | x: number, 19 | y: number, 20 | z: number, 21 | placeOn: string = 'bottom', 22 | ): Promise { 23 | // if (!gameData.getBlockId(blockType)) { 24 | // logger.log(`Invalid block type: ${blockType}.`); 25 | // return false; 26 | // } 27 | 28 | const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) 29 | 30 | let block = mineflayer.bot.inventory 31 | .items() 32 | .find(item => item.name.includes(blockType)) 33 | if (!block && mineflayer.bot.game.gameMode === 'creative') { 34 | // TODO: Rework 35 | await mineflayer.bot.creative.setInventorySlot(36, makeItem(blockType, 1)) // 36 is first hotbar slot 36 | block = mineflayer.bot.inventory.items().find(item => item.name.includes(blockType)) 37 | } 38 | if (!block) { 39 | logger.log(`Don't have any ${blockType} to place.`) 40 | return false 41 | } 42 | 43 | const targetBlock = mineflayer.bot.blockAt(targetDest) 44 | if (!targetBlock) { 45 | logger.log(`No block found at ${targetDest}.`) 46 | return false 47 | } 48 | 49 | if (targetBlock.name === blockType) { 50 | logger.log(`${blockType} already at ${targetBlock.position}.`) 51 | return false 52 | } 53 | 54 | const emptyBlocks = [ 55 | 'air', 56 | 'water', 57 | 'lava', 58 | 'grass', 59 | 'tall_grass', 60 | 'snow', 61 | 'dead_bush', 62 | 'fern', 63 | ] 64 | if (!emptyBlocks.includes(targetBlock.name)) { 65 | logger.log( 66 | `${targetBlock.name} is in the way at ${targetBlock.position}.`, 67 | ) 68 | const removed = await breakBlockAt(mineflayer, x, y, z) 69 | if (!removed) { 70 | logger.log( 71 | `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`, 72 | ) 73 | return false 74 | } 75 | await new Promise(resolve => setTimeout(resolve, 200)) // Wait for block to break 76 | } 77 | 78 | // Determine the build-off block and face vector 79 | const dirMap: { [key: string]: Vec3 } = { 80 | top: new Vec3(0, 1, 0), 81 | bottom: new Vec3(0, -1, 0), 82 | north: new Vec3(0, 0, -1), 83 | south: new Vec3(0, 0, 1), 84 | east: new Vec3(1, 0, 0), 85 | west: new Vec3(-1, 0, 0), 86 | } 87 | 88 | const dirs: Vec3[] = [] 89 | if (placeOn === 'side') { 90 | dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) 91 | } 92 | else if (dirMap[placeOn]) { 93 | dirs.push(dirMap[placeOn]) 94 | } 95 | else { 96 | dirs.push(dirMap.bottom) 97 | logger.log(`Unknown placeOn value "${placeOn}". Defaulting to bottom.`) 98 | } 99 | 100 | // Add remaining directions 101 | dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) 102 | 103 | let buildOffBlock: Block | null = null 104 | let faceVec: Vec3 | null = null 105 | 106 | for (const d of dirs) { 107 | const adjacentBlock = mineflayer.bot.blockAt(targetDest.plus(d)) 108 | if (adjacentBlock && !emptyBlocks.includes(adjacentBlock.name)) { 109 | buildOffBlock = adjacentBlock 110 | faceVec = d.scaled(-1) // Invert direction 111 | break 112 | } 113 | } 114 | 115 | if (!buildOffBlock || !faceVec) { 116 | logger.log( 117 | `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`, 118 | ) 119 | return false 120 | } 121 | 122 | // Move away if too close 123 | const pos = mineflayer.bot.entity.position 124 | const posAbove = pos.offset(0, 1, 0) 125 | const dontMoveFor = [ 126 | 'torch', 127 | 'redstone_torch', 128 | 'redstone', 129 | 'lever', 130 | 'button', 131 | 'rail', 132 | 'detector_rail', 133 | 'powered_rail', 134 | 'activator_rail', 135 | 'tripwire_hook', 136 | 'tripwire', 137 | 'water_bucket', 138 | ] 139 | if ( 140 | !dontMoveFor.includes(blockType) 141 | && (pos.distanceTo(targetBlock.position) < 1 142 | || posAbove.distanceTo(targetBlock.position) < 1) 143 | ) { 144 | const goal = new pathfinder.goals.GoalInvert( 145 | new pathfinder.goals.GoalNear( 146 | targetBlock.position.x, 147 | targetBlock.position.y, 148 | targetBlock.position.z, 149 | 2, 150 | ), 151 | ) 152 | // bot.pathfinder.setMovements(new pf.Movements(bot)); 153 | await mineflayer.bot.pathfinder.goto(goal) 154 | } 155 | 156 | // Move closer if too far 157 | if (mineflayer.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { 158 | await goToPosition( 159 | mineflayer, 160 | targetBlock.position.x, 161 | targetBlock.position.y, 162 | targetBlock.position.z, 163 | 4, 164 | ) 165 | } 166 | 167 | await mineflayer.bot.equip(block, 'hand') 168 | await mineflayer.bot.lookAt(buildOffBlock.position) 169 | await sleep(500) 170 | 171 | try { 172 | await mineflayer.bot.placeBlock(buildOffBlock, faceVec) 173 | logger.log(`Placed ${blockType} at ${targetDest}.`) 174 | await new Promise(resolve => setTimeout(resolve, 200)) 175 | return true 176 | } 177 | catch (err) { 178 | if (err instanceof Error) { 179 | logger.log( 180 | `Failed to place ${blockType} at ${targetDest}: ${err.message}`, 181 | ) 182 | } 183 | else { 184 | logger.log( 185 | `Failed to place ${blockType} at ${targetDest}: ${String(err)}`, 186 | ) 187 | } 188 | return false 189 | } 190 | } 191 | 192 | export async function breakBlockAt( 193 | mineflayer: Mineflayer, 194 | x: number, 195 | y: number, 196 | z: number, 197 | ): Promise { 198 | if (x == null || y == null || z == null) { 199 | throw new Error('Invalid position to break block at.') 200 | } 201 | const blockPos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) 202 | const block = mineflayer.bot.blockAt(blockPos) 203 | if (!block) { 204 | logger.log(`No block found at position ${blockPos}.`) 205 | return false 206 | } 207 | if (block.name !== 'air' && block.name !== 'water' && block.name !== 'lava') { 208 | if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { 209 | await goToPosition(mineflayer, x, y, z) 210 | } 211 | if (mineflayer.bot.game.gameMode !== 'creative') { 212 | await mineflayer.bot.tool.equipForBlock(block) 213 | const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null 214 | if (!block.canHarvest(itemId)) { 215 | logger.log(`Don't have right tools to break ${block.name}.`) 216 | return false 217 | } 218 | } 219 | if (!mineflayer.bot.canDigBlock(block)) { 220 | logger.log(`Cannot break ${block.name} at ${blockPos}.`) 221 | return false 222 | } 223 | await mineflayer.bot.lookAt(block.position, true) // Ensure the bot has finished turning 224 | await sleep(500) 225 | try { 226 | await mineflayer.bot.dig(block, true) 227 | logger.log( 228 | `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed( 229 | 1, 230 | )}, z:${z.toFixed(1)}.`, 231 | ) 232 | return true 233 | } 234 | catch (err) { 235 | console.error(`Failed to dig the block: ${err}`) 236 | return false 237 | } 238 | } 239 | else { 240 | logger.log( 241 | `Skipping block at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed( 242 | 1, 243 | )} because it is ${block.name}.`, 244 | ) 245 | return false 246 | } 247 | } 248 | 249 | export async function activateNearestBlock(mineflayer: Mineflayer, type: string) { 250 | /** 251 | * Activate the nearest block of the given type. 252 | * @param {string} type, the type of block to activate. 253 | * @returns {Promise} true if the block was activated, false otherwise. 254 | * @example 255 | * await skills.activateNearestBlock( "lever"); 256 | * 257 | */ 258 | const block = getNearestBlock(mineflayer.bot, type, 16) 259 | if (!block) { 260 | logger.log(`Could not find any ${type} to activate.`) 261 | return false 262 | } 263 | if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { 264 | const pos = block.position 265 | // bot.pathfinder.setMovements(new pf.Movements(bot)); 266 | await mineflayer.bot.pathfinder.goto(new pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) 267 | } 268 | await mineflayer.bot.activateBlock(block) 269 | logger.log( 270 | `Activated ${type} at x:${block.position.x.toFixed( 271 | 1, 272 | )}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`, 273 | ) 274 | return true 275 | } 276 | 277 | export async function tillAndSow( 278 | mineflayer: Mineflayer, 279 | x: number, 280 | y: number, 281 | z: number, 282 | seedType: string | null = null, 283 | ): Promise { 284 | x = Math.round(x) 285 | y = Math.round(y) 286 | z = Math.round(z) 287 | const blockPos = new Vec3(x, y, z) 288 | const block = mineflayer.bot.blockAt(blockPos) 289 | if (!block) { 290 | logger.log(`No block found at ${blockPos}.`) 291 | return false 292 | } 293 | if ( 294 | block.name !== 'grass_block' 295 | && block.name !== 'dirt' 296 | && block.name !== 'farmland' 297 | ) { 298 | logger.log(`Cannot till ${block.name}, must be grass_block or dirt.`) 299 | return false 300 | } 301 | const above = mineflayer.bot.blockAt(blockPos.offset(0, 1, 0)) 302 | if (above && above.name !== 'air') { 303 | logger.log(`Cannot till, there is ${above.name} above the block.`) 304 | return false 305 | } 306 | // Move closer if too far 307 | if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { 308 | await goToPosition(mineflayer, x, y, z, 4) 309 | } 310 | if (block.name !== 'farmland') { 311 | const hoe = mineflayer.bot.inventory.items().find(item => item.name.includes('hoe')) 312 | if (!hoe) { 313 | logger.log(`Cannot till, no hoes.`) 314 | return false 315 | } 316 | await mineflayer.bot.equip(hoe, 'hand') 317 | await mineflayer.bot.activateBlock(block) 318 | logger.log( 319 | `Tilled block x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`, 320 | ) 321 | } 322 | 323 | if (seedType) { 324 | if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) 325 | seedType += 's' // Fixes common mistake 326 | const seeds = mineflayer.bot.inventory 327 | .items() 328 | .find(item => item.name.includes(seedType || 'seed')) 329 | if (!seeds) { 330 | logger.log(`No ${seedType} to plant.`) 331 | return false 332 | } 333 | await mineflayer.bot.equip(seeds, 'hand') 334 | await mineflayer.bot.placeBlock(block, new Vec3(0, -1, 0)) 335 | logger.log( 336 | `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed( 337 | 1, 338 | )}, z:${z.toFixed(1)}.`, 339 | ) 340 | } 341 | return true 342 | } 343 | 344 | export async function pickupNearbyItems( 345 | mineflayer: Mineflayer, 346 | distance = 8, 347 | ): Promise { 348 | const getNearestItem = (bot: Bot) => 349 | bot.nearestEntity( 350 | entity => 351 | entity.name === 'item' 352 | && entity.onGround 353 | && bot.entity.position.distanceTo(entity.position) < distance, 354 | ) 355 | let nearestItem = getNearestItem(mineflayer.bot) 356 | 357 | let pickedUp = 0 358 | while (nearestItem) { 359 | // bot.pathfinder.setMovements(new pf.Movements(bot)); 360 | await mineflayer.bot.pathfinder.goto( 361 | new pathfinder.goals.GoalFollow(nearestItem, 0.8), 362 | () => {}, 363 | ) 364 | await sleep(500) 365 | const prev = nearestItem 366 | nearestItem = getNearestItem(mineflayer.bot) 367 | if (prev === nearestItem) { 368 | break 369 | } 370 | pickedUp++ 371 | } 372 | logger.log(`Picked up ${pickedUp} items.`) 373 | return true 374 | } 375 | -------------------------------------------------------------------------------- /src/skills/base.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../libs/mineflayer' 2 | 3 | import { useLogger } from '../utils/logger' 4 | 5 | const logger = useLogger() 6 | 7 | /** 8 | * Log a message to the context's output buffer 9 | */ 10 | export function log(mineflayer: Mineflayer, message: string): void { 11 | logger.log(message) 12 | mineflayer.bot.chat(message) 13 | } 14 | 15 | /** 16 | * Position in the world 17 | */ 18 | export interface Position { 19 | x: number 20 | y: number 21 | z: number 22 | } 23 | 24 | /** 25 | * Block face direction 26 | */ 27 | export type BlockFace = 'top' | 'bottom' | 'north' | 'south' | 'east' | 'west' | 'side' 28 | -------------------------------------------------------------------------------- /src/skills/combat.ts: -------------------------------------------------------------------------------- 1 | import type { Entity } from 'prismarine-entity' 2 | import type { Item } from 'prismarine-item' 3 | import type { Mineflayer } from '../libs/mineflayer' 4 | 5 | import pathfinderModel from 'mineflayer-pathfinder' 6 | 7 | import { sleep } from '../utils/helper' 8 | import { isHostile } from '../utils/mcdata' 9 | import { log } from './base' 10 | import { getNearbyEntities, getNearestEntityWhere } from './world' 11 | 12 | const { goals } = pathfinderModel 13 | 14 | interface WeaponItem extends Item { 15 | attackDamage: number 16 | } 17 | 18 | async function equipHighestAttack(mineflayer: Mineflayer): Promise { 19 | const weapons = mineflayer.bot.inventory.items().filter(item => 20 | item.name.includes('sword') 21 | || (item.name.includes('axe') && !item.name.includes('pickaxe')), 22 | ) as WeaponItem[] 23 | 24 | if (weapons.length === 0) { 25 | const tools = mineflayer.bot.inventory.items().filter(item => 26 | item.name.includes('pickaxe') 27 | || item.name.includes('shovel'), 28 | ) as WeaponItem[] 29 | 30 | if (tools.length === 0) 31 | return 32 | 33 | tools.sort((a, b) => b.attackDamage - a.attackDamage) 34 | const tool = tools[0] 35 | if (tool) 36 | await mineflayer.bot.equip(tool, 'hand') 37 | return 38 | } 39 | 40 | weapons.sort((a, b) => b.attackDamage - a.attackDamage) 41 | const weapon = weapons[0] 42 | if (weapon) 43 | await mineflayer.bot.equip(weapon, 'hand') 44 | } 45 | 46 | export async function attackNearest( 47 | mineflayer: Mineflayer, 48 | mobType: string, 49 | kill = true, 50 | ): Promise { 51 | const mob = getNearbyEntities(mineflayer, 24).find(entity => entity.name === mobType) 52 | 53 | if (mob) { 54 | return await attackEntity(mineflayer, mob, kill) 55 | } 56 | 57 | log(mineflayer, `Could not find any ${mobType} to attack.`) 58 | return false 59 | } 60 | 61 | export async function attackEntity( 62 | mineflayer: Mineflayer, 63 | entity: Entity, 64 | kill = true, 65 | ): Promise { 66 | const pos = entity.position 67 | await equipHighestAttack(mineflayer) 68 | 69 | if (!kill) { 70 | if (mineflayer.bot.entity.position.distanceTo(pos) > 5) { 71 | const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4) 72 | await mineflayer.bot.pathfinder.goto(goal) 73 | } 74 | await mineflayer.bot.attack(entity) 75 | return true 76 | } 77 | 78 | mineflayer.once('interrupt', () => { 79 | mineflayer.bot.pvp.stop() 80 | }) 81 | 82 | mineflayer.bot.pvp.attack(entity) 83 | while (getNearbyEntities(mineflayer, 24).includes(entity)) { 84 | await new Promise(resolve => setTimeout(resolve, 1000)) 85 | } 86 | 87 | log(mineflayer, `Successfully killed ${entity.name}.`) 88 | return true 89 | } 90 | 91 | export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise { 92 | let attacked = false 93 | let enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) 94 | 95 | while (enemy) { 96 | await equipHighestAttack(mineflayer) 97 | 98 | if (mineflayer.bot.entity.position.distanceTo(enemy.position) >= 4 99 | && enemy.name !== 'creeper' && enemy.name !== 'phantom') { 100 | try { 101 | const goal = new goals.GoalFollow(enemy, 3.5) 102 | await mineflayer.bot.pathfinder.goto(goal) 103 | } 104 | catch { /* might error if entity dies, ignore */ } 105 | } 106 | 107 | if (mineflayer.bot.entity.position.distanceTo(enemy.position) <= 2) { 108 | try { 109 | const followGoal = new goals.GoalFollow(enemy, 2) 110 | const invertedGoal = new goals.GoalInvert(followGoal) 111 | await mineflayer.bot.pathfinder.goto(invertedGoal) 112 | } 113 | catch { /* might error if entity dies, ignore */ } 114 | } 115 | 116 | mineflayer.bot.pvp.attack(enemy) 117 | attacked = true 118 | await sleep(500) 119 | enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) 120 | 121 | mineflayer.once('interrupt', () => { 122 | mineflayer.bot.pvp.stop() 123 | return false 124 | }) 125 | } 126 | 127 | mineflayer.bot.pvp.stop() 128 | if (attacked) { 129 | log(mineflayer, 'Successfully defended self.') 130 | } 131 | else { 132 | log(mineflayer, 'No enemies nearby to defend self from.') 133 | } 134 | return attacked 135 | } 136 | -------------------------------------------------------------------------------- /src/skills/crafting.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from 'prismarine-block' 2 | import type { Item } from 'prismarine-item' 3 | import type { Recipe } from 'prismarine-recipe' 4 | import type { Mineflayer } from '../libs/mineflayer' 5 | 6 | import { useLogger } from '../utils/logger' 7 | import { getItemId, getItemName } from '../utils/mcdata' 8 | import { ensureCraftingTable } from './actions/ensure' 9 | import { collectBlock, placeBlock } from './blocks' 10 | import { goToNearestBlock, goToPosition, moveAway } from './movement' 11 | import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './world' 12 | 13 | const logger = useLogger() 14 | 15 | /* 16 | Possible Scenarios: 17 | 18 | 1. **Successful Craft Without Crafting Table**: 19 | - The bot attempts to craft the item without a crafting table and succeeds. The function returns `true`. 20 | 21 | 2. **Crafting Table Nearby**: 22 | - The bot tries to craft without a crafting table but fails. 23 | - The bot then checks for a nearby crafting table. 24 | - If a crafting table is found, the bot moves to it and successfully crafts the item, returning `true`. 25 | 26 | 3. **No Crafting Table Nearby, Place Crafting Table**: 27 | - The bot fails to craft without a crafting table and does not find a nearby crafting table. 28 | - The bot checks inventory for a crafting table, places it at a suitable location, and attempts crafting again. 29 | - If successful, the function returns `true`. If the bot cannot find a suitable position or fails to craft, it returns `false`. 30 | 31 | 4. **Insufficient Resources**: 32 | - At any point, if the bot does not have the required resources to craft the item, it logs an appropriate message and returns `false`. 33 | 34 | 5. **No Crafting Table and No Suitable Position**: 35 | - If the bot does not find a crafting table and cannot find a suitable position to place one, it moves away and returns `false`. 36 | 37 | 6. **Invalid Item Name**: 38 | - If the provided item name is invalid, the function logs the error and returns `false`. 39 | */ 40 | export async function craftRecipe( 41 | mineflayer: Mineflayer, 42 | incomingItemName: string, 43 | num = 1, 44 | ): Promise { 45 | let itemName = incomingItemName.replace(' ', '_').toLowerCase() 46 | 47 | if (itemName.endsWith('plank')) 48 | itemName += 's' // Correct common mistakes 49 | 50 | const itemId = getItemId(itemName) 51 | if (itemId === null) { 52 | logger.log(`Invalid item name: ${itemName}`) 53 | return false 54 | } 55 | 56 | // Helper function to attempt crafting 57 | async function attemptCraft( 58 | recipes: Recipe[] | null, 59 | craftingTable: Block | null = null, 60 | ): Promise { 61 | if (recipes && recipes.length > 0) { 62 | const recipe = recipes[0] 63 | try { 64 | await mineflayer.bot.craft(recipe, num, craftingTable ?? undefined) 65 | logger.log( 66 | `Successfully crafted ${num} ${itemName}${ 67 | craftingTable ? ' using crafting table' : '' 68 | }.`, 69 | ) 70 | return true 71 | } 72 | catch (err) { 73 | logger.log(`Failed to craft ${itemName}: ${(err as Error).message}`) 74 | return false 75 | } 76 | } 77 | return false 78 | } 79 | 80 | // Helper function to move to a crafting table and attempt crafting with retry logic 81 | async function moveToAndCraft(craftingTable: Block): Promise { 82 | logger.log(`Crafting table found, moving to it.`) 83 | const maxRetries = 2 84 | let attempts = 0 85 | let success = false 86 | 87 | while (attempts < maxRetries && !success) { 88 | try { 89 | await goToPosition( 90 | mineflayer, 91 | craftingTable.position.x, 92 | craftingTable.position.y, 93 | craftingTable.position.z, 94 | 1, 95 | ) 96 | const recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) 97 | success = await attemptCraft(recipes, craftingTable) 98 | } 99 | catch (err) { 100 | logger.log( 101 | `Attempt ${attempts + 1} to move to crafting table failed: ${ 102 | (err as Error).message 103 | }`, 104 | ) 105 | } 106 | attempts++ 107 | } 108 | 109 | return success 110 | } 111 | 112 | // Helper function to find and use or place a crafting table 113 | async function findAndUseCraftingTable( 114 | craftingTableRange: number, 115 | ): Promise { 116 | let craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) 117 | if (craftingTable) { 118 | return await moveToAndCraft(craftingTable) 119 | } 120 | 121 | logger.log(`No crafting table nearby, attempting to place one.`) 122 | const hasCraftingTable = await ensureCraftingTable(mineflayer) 123 | if (!hasCraftingTable) { 124 | logger.log(`Failed to ensure a crafting table to craft ${itemName}.`) 125 | return false 126 | } 127 | 128 | const pos = getNearestFreeSpace(mineflayer, 1, 10) 129 | if (pos) { 130 | moveAway(mineflayer, 4) 131 | logger.log( 132 | `Placing crafting table at position (${pos.x}, ${pos.y}, ${pos.z}).`, 133 | ) 134 | await placeBlock(mineflayer, 'crafting_table', pos.x, pos.y, pos.z) 135 | craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) 136 | if (craftingTable) { 137 | return await moveToAndCraft(craftingTable) 138 | } 139 | } 140 | else { 141 | logger.log('No suitable position found to place the crafting table.') 142 | moveAway(mineflayer, 5) 143 | return false 144 | } 145 | 146 | return false 147 | } 148 | 149 | // Step 1: Try to craft without a crafting table 150 | logger.log(`Step 1: Try to craft without a crafting table`) 151 | const recipes = mineflayer.bot.recipesFor(itemId, null, 1, null) 152 | if (recipes && (await attemptCraft(recipes))) { 153 | return true 154 | } 155 | 156 | // Step 2: Find and use a crafting table 157 | logger.log(`Step 2: Find and use a crafting table`) 158 | const craftingTableRange = 32 159 | if (await findAndUseCraftingTable(craftingTableRange)) { 160 | return true 161 | } 162 | 163 | return false 164 | } 165 | 166 | export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = 1): Promise { 167 | const foods = [ 168 | 'beef', 169 | 'chicken', 170 | 'cod', 171 | 'mutton', 172 | 'porkchop', 173 | 'rabbit', 174 | 'salmon', 175 | 'tropical_fish', 176 | ] 177 | if (!itemName.includes('raw') && !foods.includes(itemName)) { 178 | logger.log( 179 | `Cannot smelt ${itemName}, must be a "raw" item, like "raw_iron".`, 180 | ) 181 | return false 182 | } // TODO: allow cobblestone, sand, clay, etc. 183 | 184 | let placedFurnace = false 185 | let furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) 186 | if (!furnaceBlock) { 187 | // Try to place furnace 188 | const hasFurnace = getInventoryCounts(mineflayer).furnace > 0 189 | if (hasFurnace) { 190 | const pos = getNearestFreeSpace(mineflayer, 1, 32) 191 | if (pos) { 192 | await placeBlock(mineflayer, 'furnace', pos.x, pos.y, pos.z) 193 | } 194 | else { 195 | logger.log('No suitable position found to place the furnace.') 196 | return false 197 | } 198 | furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) 199 | placedFurnace = true 200 | } 201 | } 202 | if (!furnaceBlock) { 203 | logger.log(`There is no furnace nearby and I have no furnace.`) 204 | return false 205 | } 206 | if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { 207 | await goToNearestBlock(mineflayer, 'furnace', 4, 32) 208 | } 209 | await mineflayer.bot.lookAt(furnaceBlock.position) 210 | 211 | logger.log('smelting...') 212 | const furnace = await mineflayer.bot.openFurnace(furnaceBlock) 213 | // Check if the furnace is already smelting something 214 | const inputItem = furnace.inputItem() 215 | if ( 216 | inputItem 217 | && inputItem.type !== getItemId(itemName) 218 | && inputItem.count > 0 219 | ) { 220 | logger.log( 221 | `The furnace is currently smelting ${getItemName( 222 | inputItem.type, 223 | )}.`, 224 | ) 225 | if (placedFurnace) 226 | await collectBlock(mineflayer, 'furnace', 1) 227 | return false 228 | } 229 | // Check if the bot has enough items to smelt 230 | const invCounts = getInventoryCounts(mineflayer) 231 | if (!invCounts[itemName] || invCounts[itemName] < num) { 232 | logger.log(`I do not have enough ${itemName} to smelt.`) 233 | if (placedFurnace) 234 | await collectBlock(mineflayer, 'furnace', 1) 235 | return false 236 | } 237 | 238 | // Fuel the furnace 239 | if (!furnace.fuelItem()) { 240 | const fuel = mineflayer.bot.inventory 241 | .items() 242 | .find(item => item.name === 'coal' || item.name === 'charcoal') 243 | const putFuel = Math.ceil(num / 8) 244 | if (!fuel || fuel.count < putFuel) { 245 | logger.log( 246 | `I do not have enough coal or charcoal to smelt ${num} ${itemName}, I need ${putFuel} coal or charcoal`, 247 | ) 248 | if (placedFurnace) 249 | await collectBlock(mineflayer, 'furnace', 1) 250 | return false 251 | } 252 | await furnace.putFuel(fuel.type, null, putFuel) 253 | logger.log( 254 | `Added ${putFuel} ${getItemName(fuel.type)} to furnace fuel.`, 255 | ) 256 | } 257 | // Put the items in the furnace 258 | const itemId = getItemId(itemName) 259 | if (itemId === null) { 260 | logger.log(`Invalid item name: ${itemName}`) 261 | return false 262 | } 263 | await furnace.putInput(itemId, null, num) 264 | // Wait for the items to smelt 265 | let total = 0 266 | let collectedLast = true 267 | let smeltedItem: Item | null = null 268 | await new Promise(resolve => setTimeout(resolve, 200)) 269 | while (total < num) { 270 | await new Promise(resolve => setTimeout(resolve, 10000)) 271 | logger.log('checking...') 272 | let collected = false 273 | if (furnace.outputItem()) { 274 | smeltedItem = await furnace.takeOutput() 275 | if (smeltedItem) { 276 | total += smeltedItem.count 277 | collected = true 278 | } 279 | } 280 | if (!collected && !collectedLast) { 281 | break // if nothing was collected this time or last time 282 | } 283 | collectedLast = collected 284 | } 285 | await mineflayer.bot.closeWindow(furnace) 286 | 287 | if (placedFurnace) { 288 | await collectBlock(mineflayer, 'furnace', 1) 289 | } 290 | if (total === 0) { 291 | logger.log(`Failed to smelt ${itemName}.`) 292 | return false 293 | } 294 | if (total < num) { 295 | logger.log( 296 | `Only smelted ${total} ${getItemName(smeltedItem?.type || 0)}.`, 297 | ) 298 | return false 299 | } 300 | logger.log( 301 | `Successfully smelted ${itemName}, got ${total} ${getItemName( 302 | smeltedItem?.type || 0, 303 | )}.`, 304 | ) 305 | return true 306 | } 307 | 308 | export async function clearNearestFurnace(mineflayer: Mineflayer): Promise { 309 | const furnaceBlock = getNearestBlock(mineflayer, 'furnace', 6) 310 | if (!furnaceBlock) { 311 | logger.log(`There is no furnace nearby.`) 312 | return false 313 | } 314 | 315 | logger.log('clearing furnace...') 316 | const furnace = await mineflayer.bot.openFurnace(furnaceBlock) 317 | logger.log('opened furnace...') 318 | // Take the items out of the furnace 319 | let smeltedItem: Item | null = null 320 | let inputItem: Item | null = null 321 | let fuelItem: Item | null = null 322 | if (furnace.outputItem()) 323 | smeltedItem = await furnace.takeOutput() 324 | if (furnace.inputItem()) 325 | inputItem = await furnace.takeInput() 326 | if (furnace.fuelItem()) 327 | fuelItem = await furnace.takeFuel() 328 | logger.log(smeltedItem, inputItem, fuelItem) 329 | const smeltedName = smeltedItem 330 | ? `${smeltedItem.count} ${smeltedItem.name}` 331 | : `0 smelted items` 332 | const inputName = inputItem 333 | ? `${inputItem.count} ${inputItem.name}` 334 | : `0 input items` 335 | const fuelName = fuelItem 336 | ? `${fuelItem.count} ${fuelItem.name}` 337 | : `0 fuel items` 338 | logger.log( 339 | `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`, 340 | ) 341 | await mineflayer.bot.closeWindow(furnace) 342 | return true 343 | } 344 | -------------------------------------------------------------------------------- /src/skills/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base' 2 | export * from './blocks' 3 | export * from './combat' 4 | export * from './crafting' 5 | export * from './inventory' 6 | export * from './movement' 7 | -------------------------------------------------------------------------------- /src/skills/inventory.ts: -------------------------------------------------------------------------------- 1 | import type { Mineflayer } from '../libs/mineflayer' 2 | 3 | import { log } from './base' 4 | import { goToPlayer, goToPosition } from './movement' 5 | import { getNearestBlock } from './world' 6 | 7 | export async function equip(mineflayer: Mineflayer, itemName: string): Promise { 8 | const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName) 9 | if (!item) { 10 | log(mineflayer, `You do not have any ${itemName} to equip.`) 11 | return false 12 | } 13 | 14 | if (itemName.includes('leggings')) { 15 | await mineflayer.bot.equip(item, 'legs') 16 | } 17 | else if (itemName.includes('boots')) { 18 | await mineflayer.bot.equip(item, 'feet') 19 | } 20 | else if (itemName.includes('helmet')) { 21 | await mineflayer.bot.equip(item, 'head') 22 | } 23 | else if (itemName.includes('chestplate') || itemName.includes('elytra')) { 24 | await mineflayer.bot.equip(item, 'torso') 25 | } 26 | else if (itemName.includes('shield')) { 27 | await mineflayer.bot.equip(item, 'off-hand') 28 | } 29 | else { 30 | await mineflayer.bot.equip(item, 'hand') 31 | } 32 | 33 | log(mineflayer, `Equipped ${itemName}.`) 34 | return true 35 | } 36 | 37 | export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { 38 | let discarded = 0 39 | 40 | while (true) { 41 | const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) 42 | if (!item) { 43 | break 44 | } 45 | 46 | const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) 47 | await mineflayer.bot.toss(item.type, null, toDiscard) 48 | discarded += toDiscard 49 | 50 | if (num !== -1 && discarded >= num) { 51 | break 52 | } 53 | } 54 | 55 | if (discarded === 0) { 56 | log(mineflayer, `You do not have any ${itemName} to discard.`) 57 | return false 58 | } 59 | 60 | log(mineflayer, `Discarded ${discarded} ${itemName}.`) 61 | return true 62 | } 63 | 64 | export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { 65 | const chest = getNearestBlock(mineflayer, 'chest', 32) 66 | if (!chest) { 67 | log(mineflayer, 'Could not find a chest nearby.') 68 | return false 69 | } 70 | 71 | const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) 72 | if (!item) { 73 | log(mineflayer, `You do not have any ${itemName} to put in the chest.`) 74 | return false 75 | } 76 | 77 | const toPut = num === -1 ? item.count : Math.min(num, item.count) 78 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) 79 | 80 | const chestContainer = await mineflayer.bot.openContainer(chest) 81 | await chestContainer.deposit(item.type, null, toPut) 82 | await chestContainer.close() 83 | 84 | log(mineflayer, `Successfully put ${toPut} ${itemName} in the chest.`) 85 | return true 86 | } 87 | 88 | export async function takeFromChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { 89 | const chest = getNearestBlock(mineflayer, 'chest', 32) 90 | if (!chest) { 91 | log(mineflayer, 'Could not find a chest nearby.') 92 | return false 93 | } 94 | 95 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) 96 | const chestContainer = await mineflayer.bot.openContainer(chest) 97 | 98 | const item = chestContainer.containerItems().find(item => item.name === itemName) 99 | if (!item) { 100 | log(mineflayer, `Could not find any ${itemName} in the chest.`) 101 | await chestContainer.close() 102 | return false 103 | } 104 | 105 | const toTake = num === -1 ? item.count : Math.min(num, item.count) 106 | await chestContainer.withdraw(item.type, null, toTake) 107 | await chestContainer.close() 108 | 109 | log(mineflayer, `Successfully took ${toTake} ${itemName} from the chest.`) 110 | return true 111 | } 112 | 113 | export async function viewChest(mineflayer: Mineflayer): Promise { 114 | const chest = getNearestBlock(mineflayer, 'chest', 32) 115 | if (!chest) { 116 | log(mineflayer, 'Could not find a chest nearby.') 117 | return false 118 | } 119 | 120 | await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) 121 | const chestContainer = await mineflayer.bot.openContainer(chest) 122 | const items = chestContainer.containerItems() 123 | 124 | if (items.length === 0) { 125 | log(mineflayer, 'The chest is empty.') 126 | } 127 | else { 128 | log(mineflayer, 'The chest contains:') 129 | for (const item of items) { 130 | log(mineflayer, `${item.count} ${item.name}`) 131 | } 132 | } 133 | 134 | await chestContainer.close() 135 | return true 136 | } 137 | 138 | export async function consume(mineflayer: Mineflayer, itemName = ''): Promise { 139 | let item 140 | let name 141 | 142 | if (itemName) { 143 | item = mineflayer.bot.inventory.items().find(item => item.name === itemName) 144 | name = itemName 145 | } 146 | 147 | if (!item) { 148 | log(mineflayer, `You do not have any ${name} to eat.`) 149 | return false 150 | } 151 | 152 | await mineflayer.bot.equip(item, 'hand') 153 | await mineflayer.bot.consume() 154 | log(mineflayer, `Consumed ${item.name}.`) 155 | return true 156 | } 157 | 158 | export async function giveToPlayer( 159 | mineflayer: Mineflayer, 160 | itemType: string, 161 | username: string, 162 | num = 1, 163 | ): Promise { 164 | const player = mineflayer.bot.players[username]?.entity 165 | if (!player) { 166 | log(mineflayer, `Could not find ${username}.`) 167 | return false 168 | } 169 | 170 | // Move to player position 171 | await goToPlayer(mineflayer, username, 3) 172 | 173 | // Look at player before dropping items 174 | await mineflayer.bot.lookAt(player.position) 175 | 176 | // Drop items and wait for collection 177 | const success = await dropItemsAndWaitForCollection(mineflayer, itemType, username, num) 178 | if (!success) { 179 | log(mineflayer, `Failed to give ${itemType} to ${username}, it was never received.`) 180 | return false 181 | } 182 | 183 | return true 184 | } 185 | 186 | async function dropItemsAndWaitForCollection( 187 | mineflayer: Mineflayer, 188 | itemType: string, 189 | username: string, 190 | num: number, 191 | ): Promise { 192 | if (!await discard(mineflayer, itemType, num)) { 193 | return false 194 | } 195 | 196 | return new Promise((resolve) => { 197 | const timeout = setTimeout(() => { 198 | // Clean up playerCollect listener when timeout occurs 199 | // eslint-disable-next-line ts/no-use-before-define 200 | mineflayer.bot.removeListener('playerCollect', onCollect) 201 | resolve(false) 202 | }, 3000) 203 | 204 | const onCollect = (collector: any, _collected: any) => { 205 | if (collector.username === username) { 206 | log(mineflayer, `${username} received ${itemType}.`) 207 | clearTimeout(timeout) 208 | resolve(true) 209 | } 210 | } 211 | 212 | const onInterrupt = () => { 213 | clearTimeout(timeout) 214 | // Clean up playerCollect listener when interrupted 215 | mineflayer.bot.removeListener('playerCollect', onCollect) 216 | resolve(false) 217 | } 218 | 219 | mineflayer.bot.once('playerCollect', onCollect) 220 | mineflayer.once('interrupt', onInterrupt) 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /src/skills/movement.ts: -------------------------------------------------------------------------------- 1 | import type { Entity } from 'prismarine-entity' 2 | import type { Mineflayer } from '../libs/mineflayer' 3 | 4 | import { randomInt } from 'es-toolkit' 5 | import pathfinder from 'mineflayer-pathfinder' 6 | import { Vec3 } from 'vec3' 7 | 8 | import { sleep } from '../utils/helper' 9 | import { useLogger } from '../utils/logger' 10 | import { log } from './base' 11 | import { getNearestBlock, getNearestEntityWhere } from './world' 12 | 13 | const logger = useLogger() 14 | const { goals, Movements } = pathfinder 15 | 16 | export async function goToPosition( 17 | mineflayer: Mineflayer, 18 | x: number, 19 | y: number, 20 | z: number, 21 | minDistance = 2, 22 | ): Promise { 23 | if (x == null || y == null || z == null) { 24 | log(mineflayer, `Missing coordinates, given x:${x} y:${y} z:${z}`) 25 | return false 26 | } 27 | 28 | if (mineflayer.allowCheats) { 29 | mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) 30 | log(mineflayer, `Teleported to ${x}, ${y}, ${z}.`) 31 | return true 32 | } 33 | 34 | await mineflayer.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) 35 | log(mineflayer, `You have reached ${x}, ${y}, ${z}.`) 36 | return true 37 | } 38 | 39 | export async function goToNearestBlock( 40 | mineflayer: Mineflayer, 41 | blockType: string, 42 | minDistance = 2, 43 | range = 64, 44 | ): Promise { 45 | const MAX_RANGE = 512 46 | if (range > MAX_RANGE) { 47 | log(mineflayer, `Maximum search range capped at ${MAX_RANGE}.`) 48 | range = MAX_RANGE 49 | } 50 | 51 | const block = getNearestBlock(mineflayer, blockType, range) 52 | if (!block) { 53 | log(mineflayer, `Could not find any ${blockType} in ${range} blocks.`) 54 | return false 55 | } 56 | 57 | log(mineflayer, `Found ${blockType} at ${block.position}.`) 58 | await goToPosition(mineflayer, block.position.x, block.position.y, block.position.z, minDistance) 59 | return true 60 | } 61 | 62 | export async function goToNearestEntity( 63 | mineflayer: Mineflayer, 64 | entityType: string, 65 | minDistance = 2, 66 | range = 64, 67 | ): Promise { 68 | const entity = getNearestEntityWhere( 69 | mineflayer, 70 | entity => entity.name === entityType, 71 | range, 72 | ) 73 | 74 | if (!entity) { 75 | log(mineflayer, `Could not find any ${entityType} in ${range} blocks.`) 76 | return false 77 | } 78 | 79 | const distance = mineflayer.bot.entity.position.distanceTo(entity.position) 80 | log(mineflayer, `Found ${entityType} ${distance} blocks away.`) 81 | await goToPosition( 82 | mineflayer, 83 | entity.position.x, 84 | entity.position.y, 85 | entity.position.z, 86 | minDistance, 87 | ) 88 | return true 89 | } 90 | 91 | export async function goToPlayer( 92 | mineflayer: Mineflayer, 93 | username: string, 94 | distance = 3, 95 | ): Promise { 96 | if (mineflayer.allowCheats) { 97 | mineflayer.bot.chat(`/tp @s ${username}`) 98 | log(mineflayer, `Teleported to ${username}.`) 99 | return true 100 | } 101 | 102 | const player = mineflayer.bot.players[username]?.entity 103 | if (!player) { 104 | log(mineflayer, `Could not find ${username}.`) 105 | return false 106 | } 107 | 108 | await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(player, distance)) 109 | log(mineflayer, `You have reached ${username}.`) 110 | return true 111 | } 112 | 113 | export async function followPlayer( 114 | mineflayer: Mineflayer, 115 | username: string, 116 | distance = 4, 117 | ): Promise { 118 | const player = mineflayer.bot.players[username]?.entity 119 | if (!player) { 120 | return false 121 | } 122 | 123 | log(mineflayer, `I am now actively following player ${username}.`) 124 | 125 | const movements = new Movements(mineflayer.bot) 126 | mineflayer.bot.pathfinder.setMovements(movements) 127 | mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) 128 | 129 | mineflayer.once('interrupt', () => { 130 | mineflayer.bot.pathfinder.stop() 131 | }) 132 | 133 | return true 134 | } 135 | 136 | export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { 137 | try { 138 | const pos = mineflayer.bot.entity.position 139 | let newX: number = 0 140 | let newZ: number = 0 141 | let suitableGoal = false 142 | 143 | while (!suitableGoal) { 144 | const rand1 = randomInt(0, 2) 145 | const rand2 = randomInt(0, 2) 146 | const bigRand1 = randomInt(0, 101) 147 | const bigRand2 = randomInt(0, 101) 148 | 149 | newX = Math.floor( 150 | pos.x + ((distance * bigRand1) / 100) * (rand1 ? 1 : -1), 151 | ) 152 | newZ = Math.floor( 153 | pos.z + ((distance * bigRand2) / 100) * (rand2 ? 1 : -1), 154 | ) 155 | 156 | const block = mineflayer.bot.blockAt(new Vec3(newX, pos.y - 1, newZ)) 157 | 158 | if (block?.name !== 'water' && block?.name !== 'lava') { 159 | suitableGoal = true 160 | } 161 | } 162 | 163 | const farGoal = new pathfinder.goals.GoalXZ(newX, newZ) 164 | 165 | await mineflayer.bot.pathfinder.goto(farGoal) 166 | const newPos = mineflayer.bot.entity.position 167 | logger.log(`I moved away from nearest entity to ${newPos}.`) 168 | await sleep(500) 169 | return true 170 | } 171 | catch (err) { 172 | logger.log(`I failed to move away: ${(err as Error).message}`) 173 | return false 174 | } 175 | } 176 | 177 | export async function moveAwayFromEntity( 178 | mineflayer: Mineflayer, 179 | entity: Entity, 180 | distance = 16, 181 | ): Promise { 182 | const goal = new goals.GoalFollow(entity, distance) 183 | const invertedGoal = new goals.GoalInvert(goal) 184 | await mineflayer.bot.pathfinder.goto(invertedGoal) 185 | return true 186 | } 187 | 188 | export async function stay(mineflayer: Mineflayer, seconds = 30): Promise { 189 | const start = Date.now() 190 | const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 191 | 192 | while (Date.now() < targetTime) { 193 | await sleep(500) 194 | } 195 | 196 | log(mineflayer, `I stayed for ${(Date.now() - start) / 1000} seconds.`) 197 | return true 198 | } 199 | 200 | export async function goToBed(mineflayer: Mineflayer): Promise { 201 | const beds = mineflayer.bot.findBlocks({ 202 | matching: block => block.name.includes('bed'), 203 | maxDistance: 32, 204 | count: 1, 205 | }) 206 | 207 | if (beds.length === 0) { 208 | log(mineflayer, 'I could not find a bed to sleep in.') 209 | return false 210 | } 211 | 212 | const loc = beds[0] 213 | await goToPosition(mineflayer, loc.x, loc.y, loc.z) 214 | 215 | const bed = mineflayer.bot.blockAt(loc) 216 | if (!bed) { 217 | log(mineflayer, 'I could not find a bed to sleep in.') 218 | return false 219 | } 220 | 221 | await mineflayer.bot.sleep(bed) 222 | log(mineflayer, 'I am in bed.') 223 | 224 | while (mineflayer.bot.isSleeping) { 225 | await sleep(500) 226 | } 227 | 228 | log(mineflayer, 'I have woken up.') 229 | return true 230 | } 231 | -------------------------------------------------------------------------------- /src/skills/world.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from 'prismarine-block' 2 | import type { Entity } from 'prismarine-entity' 3 | import type { Item } from 'prismarine-item' 4 | import type { Vec3 } from 'vec3' 5 | import type { Mineflayer } from '../libs/mineflayer' 6 | 7 | import pf from 'mineflayer-pathfinder' 8 | 9 | import * as mc from '../utils/mcdata' 10 | 11 | export function getNearestFreeSpace( 12 | mineflayer: Mineflayer, 13 | size: number = 1, 14 | distance: number = 8, 15 | ): Vec3 | undefined { 16 | /** 17 | * Get the nearest empty space with solid blocks beneath it of the given size. 18 | * @param {number} size - The (size x size) of the space to find, default 1. 19 | * @param {number} distance - The maximum distance to search, default 8. 20 | * @returns {Vec3} - The south west corner position of the nearest free space. 21 | * @example 22 | * let position = world.getNearestFreeSpace( 1, 8); 23 | */ 24 | const empty_pos = mineflayer.bot.findBlocks({ 25 | matching: (block: Block | null) => { 26 | return block !== null && block.name === 'air' 27 | }, 28 | maxDistance: distance, 29 | count: 1000, 30 | }) 31 | 32 | for (let i = 0; i < empty_pos.length; i++) { 33 | let empty = true 34 | for (let x = 0; x < size; x++) { 35 | for (let z = 0; z < size; z++) { 36 | const top = mineflayer.bot.blockAt(empty_pos[i].offset(x, 0, z)) 37 | const bottom = mineflayer.bot.blockAt(empty_pos[i].offset(x, -1, z)) 38 | if ( 39 | !top 40 | || top.name !== 'air' 41 | || !bottom 42 | || (bottom.drops?.length ?? 0) === 0 43 | || !bottom.diggable 44 | ) { 45 | empty = false 46 | break 47 | } 48 | } 49 | if (!empty) 50 | break 51 | } 52 | if (empty) { 53 | return empty_pos[i] 54 | } 55 | } 56 | return undefined 57 | } 58 | 59 | export function getNearestBlocks(mineflayer: Mineflayer, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { 60 | const blockIds = blockTypes === null 61 | ? mc.getAllBlockIds(['air']) 62 | : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId).filter((id): id is number => id !== null) 63 | 64 | const positions = mineflayer.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) 65 | 66 | return positions 67 | .map((pos) => { 68 | const block = mineflayer.bot.blockAt(pos) 69 | const dist = pos.distanceTo(mineflayer.bot.entity.position) 70 | return block ? { block, distance: dist } : null 71 | }) 72 | .filter((item): item is { block: Block, distance: number } => item !== null) 73 | .sort((a, b) => a.distance - b.distance) 74 | .map(item => item.block) 75 | } 76 | 77 | export function getNearestBlock(mineflayer: Mineflayer, blockType: string, distance: number = 16): Block | null { 78 | const blocks = getNearestBlocks(mineflayer, blockType, distance, 1) 79 | return blocks[0] || null 80 | } 81 | 82 | export function getNearbyEntities(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { 83 | return Object.values(mineflayer.bot.entities) 84 | .filter((entity): entity is Entity => 85 | entity !== null 86 | && entity.position.distanceTo(mineflayer.bot.entity.position) <= maxDistance, 87 | ) 88 | .sort((a, b) => 89 | a.position.distanceTo(mineflayer.bot.entity.position) 90 | - b.position.distanceTo(mineflayer.bot.entity.position), 91 | ) 92 | } 93 | 94 | export function getNearestEntityWhere(mineflayer: Mineflayer, predicate: (entity: Entity) => boolean, maxDistance: number = 16): Entity | null { 95 | return mineflayer.bot.nearestEntity(entity => 96 | predicate(entity) 97 | && mineflayer.bot.entity.position.distanceTo(entity.position) < maxDistance, 98 | ) 99 | } 100 | 101 | export function getNearbyPlayers(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { 102 | return getNearbyEntities(mineflayer, maxDistance) 103 | .filter(entity => 104 | entity.type === 'player' 105 | && entity.username !== mineflayer.bot.username, 106 | ) 107 | } 108 | 109 | export function getInventoryStacks(mineflayer: Mineflayer): Item[] { 110 | return mineflayer.bot.inventory.items().filter((item): item is Item => item !== null) 111 | } 112 | 113 | export function getInventoryCounts(mineflayer: Mineflayer): Record { 114 | return getInventoryStacks(mineflayer).reduce((counts, item) => { 115 | counts[item.name] = (counts[item.name] || 0) + item.count 116 | return counts 117 | }, {} as Record) 118 | } 119 | 120 | export function getCraftableItems(mineflayer: Mineflayer): string[] { 121 | const table = getNearestBlock(mineflayer, 'crafting_table') 122 | || getInventoryStacks(mineflayer).find(item => item.name === 'crafting_table') 123 | return mc.getAllItems() 124 | .filter(item => mineflayer.bot.recipesFor(item.id, null, 1, table as Block | null).length > 0) 125 | .map(item => item.name) 126 | } 127 | 128 | export function getPosition(mineflayer: Mineflayer): Vec3 { 129 | return mineflayer.bot.entity.position 130 | } 131 | 132 | export function getNearbyEntityTypes(mineflayer: Mineflayer): string[] { 133 | return [...new Set( 134 | getNearbyEntities(mineflayer, 16) 135 | .map(mob => mob.name) 136 | .filter((name): name is string => name !== undefined), 137 | )] 138 | } 139 | 140 | export function getNearbyPlayerNames(mineflayer: Mineflayer): string[] { 141 | return [...new Set( 142 | getNearbyPlayers(mineflayer, 64) 143 | .map(player => player.username) 144 | .filter((name): name is string => 145 | name !== undefined 146 | && name !== mineflayer.bot.username, 147 | ), 148 | )] 149 | } 150 | 151 | export function getNearbyBlockTypes(mineflayer: Mineflayer, distance: number = 16): string[] { 152 | return [...new Set( 153 | getNearestBlocks(mineflayer, null, distance) 154 | .map(block => block.name), 155 | )] 156 | } 157 | 158 | export async function isClearPath(mineflayer: Mineflayer, target: Entity): Promise { 159 | const movements = new pf.Movements(mineflayer.bot) 160 | movements.canDig = false 161 | // movements.canPlaceOn = false // TODO: fix this 162 | 163 | const goal = new pf.goals.GoalNear( 164 | target.position.x, 165 | target.position.y, 166 | target.position.z, 167 | 1, 168 | ) 169 | 170 | const path = await mineflayer.bot.pathfinder.getPathTo(movements, goal, 100) 171 | return path.status === 'success' 172 | } 173 | 174 | export function shouldPlaceTorch(mineflayer: Mineflayer): boolean { 175 | // if (!mineflayer.bot.modes.isOn('torch_placing') || mineflayer.bot.interrupt_code) { 176 | // return false 177 | // } 178 | 179 | const pos = getPosition(mineflayer) 180 | const nearestTorch = getNearestBlock(mineflayer, 'torch', 6) 181 | || getNearestBlock(mineflayer, 'wall_torch', 6) 182 | 183 | if (nearestTorch) { 184 | return false 185 | } 186 | 187 | const block = mineflayer.bot.blockAt(pos) 188 | const hasTorch = mineflayer.bot.inventory.items().some(item => item?.name === 'torch') 189 | 190 | return Boolean(hasTorch && block?.name === 'air') 191 | } 192 | 193 | export function getBiomeName(mineflayer: Mineflayer): string { 194 | const biomeId = mineflayer.bot.world.getBiome(mineflayer.bot.entity.position) 195 | return mc.getAllBiomes()[biomeId].name 196 | } 197 | -------------------------------------------------------------------------------- /src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 2 | 3 | /** 4 | * Returns a retirable anonymous function with configured retryLimit and delayInterval 5 | * 6 | * @param retryLimit Number of retry attempts 7 | * @param delayInterval Delay between retries in milliseconds 8 | * @param func Function to be called 9 | * @returns A wrapped function with the same signature as func 10 | */ 11 | export function toRetriable( 12 | retryLimit: number, 13 | delayInterval: number, 14 | func: (...args: A[]) => Promise, 15 | hooks?: { 16 | onError?: (err: unknown) => void 17 | }, 18 | ): (...args: A[]) => Promise { 19 | let retryCount = 0 20 | return async function (args: A): Promise { 21 | try { 22 | return await func(args) 23 | } 24 | catch (err) { 25 | if (hooks?.onError) { 26 | hooks.onError(err) 27 | } 28 | 29 | if (retryCount < retryLimit) { 30 | retryCount++ 31 | await sleep(delayInterval) 32 | return await toRetriable(retryLimit - retryCount, delayInterval, func)(args) 33 | } 34 | else { 35 | throw err 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' 2 | 3 | export type Logger = ReturnType 4 | 5 | export function initLogger() { 6 | setGlobalLogLevel(LogLevel.Debug) 7 | setGlobalFormat(Format.Pretty) 8 | 9 | const logger = useLogg('logger').useGlobalConfig() 10 | logger.log('Logger initialized') 11 | } 12 | 13 | /** 14 | * Get logger instance with directory name and filename 15 | * @returns logger instance configured with "directoryName/filename" 16 | */ 17 | export function useLogger() { 18 | const stack = new Error('logger').stack 19 | const caller = stack?.split('\n')[2] 20 | 21 | // Match the parent directory and filename without extension 22 | const match = caller?.match(/\/([^/]+)\/([^/]+?)\.[jt]s/) 23 | const dirName = match?.[1] || 'unknown' 24 | const fileName = match?.[2] || 'unknown' 25 | 26 | return useLogg(`${dirName}/${fileName}`).useGlobalConfig() 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/mcdata.ts: -------------------------------------------------------------------------------- 1 | import type { Biome, ShapedRecipe, ShapelessRecipe } from 'minecraft-data' 2 | import type { Bot } from 'mineflayer' 3 | import type { Entity } from 'prismarine-entity' 4 | 5 | import minecraftData from 'minecraft-data' 6 | import prismarineItem from 'prismarine-item' 7 | 8 | const GAME_VERSION = '1.20' 9 | 10 | export const gameData = minecraftData(GAME_VERSION) 11 | export const Item = prismarineItem(GAME_VERSION) 12 | 13 | export const WOOD_TYPES: string[] = [ 14 | 'oak', 15 | 'spruce', 16 | 'birch', 17 | 'jungle', 18 | 'acacia', 19 | 'dark_oak', 20 | ] 21 | 22 | export const MATCHING_WOOD_BLOCKS: string[] = [ 23 | 'log', 24 | 'planks', 25 | 'sign', 26 | 'boat', 27 | 'fence_gate', 28 | 'door', 29 | 'fence', 30 | 'slab', 31 | 'stairs', 32 | 'button', 33 | 'pressure_plate', 34 | 'trapdoor', 35 | ] 36 | 37 | export const WOOL_COLORS: string[] = [ 38 | 'white', 39 | 'orange', 40 | 'magenta', 41 | 'light_blue', 42 | 'yellow', 43 | 'lime', 44 | 'pink', 45 | 'gray', 46 | 'light_gray', 47 | 'cyan', 48 | 'purple', 49 | 'blue', 50 | 'brown', 51 | 'green', 52 | 'red', 53 | 'black', 54 | ] 55 | 56 | export function isHuntable(mob: Entity): boolean { 57 | if (!mob || !mob.name) 58 | return false 59 | const animals: string[] = [ 60 | 'chicken', 61 | 'cow', 62 | 'llama', 63 | 'mooshroom', 64 | 'pig', 65 | 'rabbit', 66 | 'sheep', 67 | ] 68 | return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16] // metadata[16] indicates baby status 69 | } 70 | 71 | export function isHostile(mob: Entity): boolean { 72 | if (!mob || !mob.name) 73 | return false 74 | return ( 75 | (mob.type === 'mob' || mob.type === 'hostile') 76 | && mob.name !== 'iron_golem' 77 | && mob.name !== 'snow_golem' 78 | ) 79 | } 80 | 81 | export function getItemId(itemName: string): number { 82 | const item = gameData.itemsByName[itemName] 83 | 84 | return item?.id || 0 85 | } 86 | 87 | export function getItemName(itemId: number): string { 88 | const item = gameData.items[itemId] 89 | return item.name || '' 90 | } 91 | 92 | export function getBlockId(blockName: string): number { 93 | const block = gameData.blocksByName?.[blockName] 94 | return block?.id || 0 95 | } 96 | 97 | export function getBlockName(blockId: number): string { 98 | const block = gameData.blocks[blockId] 99 | return block.name || '' 100 | } 101 | 102 | export function getAllItems(ignore: string[] = []): any[] { 103 | const items: any[] = [] 104 | for (const itemId in gameData.items) { 105 | const item = gameData.items[itemId] 106 | if (!ignore.includes(item.name)) { 107 | items.push(item) 108 | } 109 | } 110 | return items 111 | } 112 | 113 | export function getAllItemIds(ignore: string[] = []): number[] { 114 | const items = getAllItems(ignore) 115 | const itemIds: number[] = [] 116 | for (const item of items) { 117 | itemIds.push(item.id) 118 | } 119 | return itemIds 120 | } 121 | 122 | export function getAllBlocks(ignore: string[] = []): any[] { 123 | const blocks: any[] = [] 124 | for (const blockId in gameData.blocks) { 125 | const block = gameData.blocks[blockId] 126 | if (!ignore.includes(block.name)) { 127 | blocks.push(block) 128 | } 129 | } 130 | return blocks 131 | } 132 | 133 | export function getAllBlockIds(ignore: string[] = []): number[] { 134 | const blocks = getAllBlocks(ignore) 135 | const blockIds: number[] = [] 136 | for (const block of blocks) { 137 | blockIds.push(block.id) 138 | } 139 | return blockIds 140 | } 141 | 142 | export function getAllBiomes(): Record { 143 | return gameData.biomes 144 | } 145 | 146 | export function getItemCraftingRecipes(itemName: string): any[] | null { 147 | const itemId = getItemId(itemName) 148 | if (!itemId || !gameData.recipes[itemId]) { 149 | return null 150 | } 151 | 152 | const recipes: Record[] = [] 153 | for (const r of gameData.recipes[itemId]) { 154 | const recipe: Record = {} 155 | let ingredients: number[] = [] 156 | 157 | if (isShapelessRecipe(r)) { 158 | // Handle shapeless recipe 159 | ingredients = r.ingredients.map((ing: any) => ing.id) 160 | } 161 | else if (isShapedRecipe(r)) { 162 | // Handle shaped recipe 163 | ingredients = r.inShape 164 | .flat() 165 | .map((ing: any) => ing?.id) 166 | .filter(Boolean) 167 | } 168 | 169 | for (const ingredientId of ingredients) { 170 | const ingredientName = getItemName(ingredientId) 171 | if (ingredientName === null) 172 | continue 173 | if (!recipe[ingredientName]) 174 | recipe[ingredientName] = 0 175 | recipe[ingredientName]++ 176 | } 177 | 178 | recipes.push(recipe) 179 | } 180 | 181 | return recipes 182 | } 183 | 184 | // Type guards 185 | function isShapelessRecipe(recipe: any): recipe is ShapelessRecipe { 186 | return 'ingredients' in recipe 187 | } 188 | 189 | function isShapedRecipe(recipe: any): recipe is ShapedRecipe { 190 | return 'inShape' in recipe 191 | } 192 | 193 | export function getItemSmeltingIngredient( 194 | itemName: string, 195 | ): string | undefined { 196 | return { 197 | baked_potato: 'potato', 198 | steak: 'raw_beef', 199 | cooked_chicken: 'raw_chicken', 200 | cooked_cod: 'raw_cod', 201 | cooked_mutton: 'raw_mutton', 202 | cooked_porkchop: 'raw_porkchop', 203 | cooked_rabbit: 'raw_rabbit', 204 | cooked_salmon: 'raw_salmon', 205 | dried_kelp: 'kelp', 206 | iron_ingot: 'raw_iron', 207 | gold_ingot: 'raw_gold', 208 | copper_ingot: 'raw_copper', 209 | glass: 'sand', 210 | }[itemName] 211 | } 212 | 213 | export function getItemBlockSources(itemName: string): string[] { 214 | const itemId = getItemId(itemName) 215 | const sources: string[] = [] 216 | if (!itemId) 217 | return sources 218 | for (const block of getAllBlocks()) { 219 | if (block.drops && block.drops.includes(itemId)) { 220 | sources.push(block.name) 221 | } 222 | } 223 | return sources 224 | } 225 | 226 | export function getItemAnimalSource(itemName: string): string | undefined { 227 | return { 228 | raw_beef: 'cow', 229 | raw_chicken: 'chicken', 230 | raw_cod: 'cod', 231 | raw_mutton: 'sheep', 232 | raw_porkchop: 'pig', 233 | raw_rabbit: 'rabbit', 234 | raw_salmon: 'salmon', 235 | leather: 'cow', 236 | wool: 'sheep', 237 | }[itemName] 238 | } 239 | 240 | export function getBlockTool(blockName: string): string | null { 241 | const block = gameData.blocksByName[blockName] 242 | if (!block || !block.harvestTools) { 243 | return null 244 | } 245 | const toolIds = Object.keys(block.harvestTools).map(id => Number.parseInt(id)) 246 | const toolName = getItemName(toolIds[0]) 247 | return toolName || null // Assuming the first tool is the simplest 248 | } 249 | 250 | export function makeItem(name: string, amount = 1): InstanceType { 251 | const itemId = getItemId(name) 252 | if (itemId === null) 253 | throw new Error(`Item ${name} not found.`) 254 | return new Item(itemId, amount) 255 | } 256 | 257 | // Function to get the nearest block of a specific type using Mineflayer 258 | export function getNearestBlock( 259 | bot: Bot, 260 | blockType: string, 261 | maxDistance: number, 262 | ) { 263 | const blocks = bot.findBlocks({ 264 | matching: block => block.name === blockType, 265 | maxDistance, 266 | count: 1, 267 | }) 268 | 269 | if (blocks.length === 0) 270 | return null 271 | 272 | const nearestBlockPosition = blocks[0] 273 | return bot.blockAt(nearestBlockPosition) 274 | } 275 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "moduleDetection": "auto", 8 | "module": "ESNext", 9 | "moduleResolution": "bundler", 10 | "resolveJsonModule": true, 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "isolatedModules": true, 19 | "verbatimModuleSyntax": true, 20 | "skipLibCheck": true 21 | }, 22 | "include": [ 23 | "src/**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['src/**/*.test.ts'], 6 | }, 7 | }) 8 | --------------------------------------------------------------------------------