From 6f0f17521aec2da459cf7340be784425ce892bb9 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 4 Jan 2025 22:38:46 +0800 Subject: [PATCH 01/77] init --- services/minecraft/src/main.ts | 550 +++++++++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 services/minecraft/src/main.ts diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts new file mode 100644 index 000000000..3e5f6691c --- /dev/null +++ b/services/minecraft/src/main.ts @@ -0,0 +1,550 @@ +import mineflayer from 'mineflayer' +import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' + +async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function main() { + await sleep(5000) + + const bot = mineflayer.createBot({ + host: '10.0.0.100', // minecraft 服务器的 IP 地址 + username: 'airi', // minecraft 用户名 + // password: '12345678', // minecraft 密码, 如果你玩的是不需要正版验证的服务器,请注释掉。 + port: 56304, // 默认使用 25565,如果你的服务器端口不是这个请取消注释并填写。 + // version: false, // 如果需要指定re使用一个版本或快照时,请取消注释并手动填写(如:"1.8.9" 或 "1.16.5"),否则会自动设置。 + // auth: 'mojang' // 如果需要使用微软账号登录时,请取消注释,然后将值设置为 'microsoft',否则会自动设置为 'mojang'。 + }) + + bot.on('chat', (username, message) => { + if (username === bot.username) + return + bot.chat(message) + }) + + // 记录错误和被踢出服务器的原因: + bot.on('kicked', console.log) + bot.on('error', console.log) + + bot.loadPlugin(pathfinder) + + const RANGE_GOAL = 1 + bot.once('spawn', () => { + const defaultMove = new Movements(bot) + + bot.on('chat', (username, message) => { + if (username === bot.username) + return + if (message !== 'come') + return + const target = bot.players[username]?.entity + if (!target) { + bot.chat('I don\'t see you !') + return + } + const { x: playerX, y: playerY, z: playerZ } = target.position + + bot.pathfinder.setMovements(defaultMove) + bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + }) + }) + + /** TP */ + let target: Entity = null + + bot.on('chat', (username, message) => { + if (username === bot.username) + return + target = bot.players[username].entity + let entity + switch (message) { + case 'forward': + bot.setControlState('forward', true) + break + case 'back': + bot.setControlState('back', true) + break + case 'left': + bot.setControlState('left', true) + break + case 'right': + bot.setControlState('right', true) + break + case 'sprint': + bot.setControlState('sprint', true) + break + case 'stop': + bot.clearControlStates() + break + case 'jump': + bot.setControlState('jump', true) + bot.setControlState('jump', false) + break + case 'jump a lot': + bot.setControlState('jump', true) + break + case 'stop jumping': + bot.setControlState('jump', false) + break + case 'attack': + entity = bot.nearestEntity() + if (entity) { + bot.attack(entity, true) + } + else { + bot.chat('no nearby entities') + } + break + case 'mount': + entity = bot.nearestEntity((entity) => { return entity.name === 'minecart' }) + if (entity) { + bot.mount(entity) + } + else { + bot.chat('no nearby objects') + } + break + case 'dismount': + bot.dismount() + break + case 'move vehicle forward': + bot.moveVehicle(0.0, 1.0) + break + case 'move vehicle backward': + bot.moveVehicle(0.0, -1.0) + break + case 'move vehicle left': + bot.moveVehicle(1.0, 0.0) + break + case 'move vehicle right': + bot.moveVehicle(-1.0, 0.0) + break + case 'tp': + bot.entity.position.y += 10 + break + case 'pos': + bot.chat(bot.entity.position.toString()) + break + case 'yp': + bot.chat(`Yaw ${bot.entity.yaw}, pitch: ${bot.entity.pitch}`) + break + } + }) + + bot.once('spawn', () => { + // keep your eyes on the target, so creepy! + setInterval(watchTarget, 50) + + function watchTarget() { + if (!target) + return + bot.lookAt(target.position.offset(0, target.height, 0)) + } + }) + + bot.on('mount', () => { + bot.chat(`mounted ${bot.vehicle.displayName}`) + }) + + bot.on('dismount', (vehicle) => { + bot.chat(`dismounted ${vehicle.displayName}`) + }) + + /** + * Chest + */ + bot.on('experience', () => { + bot.chat(`I am level ${bot.experience.level}`) + }) + + bot.on('chat', (username, message) => { + if (username === bot.username) + return + switch (true) { + case /^list$/.test(message): + sayItems() + break + case /^chest$/.test(message): + watchChest(false, ['chest', 'ender_chest', 'trapped_chest']) + break + case /^furnace$/.test(message): + watchFurnace() + break + case /^dispenser$/.test(message): + watchChest(false, ['dispenser']) + break + case /^enchant$/.test(message): + watchEnchantmentTable() + break + case /^chestminecart$/.test(message): + watchChest(true) + break + case /^invsee \w+( \d)?$/.test(message): { + // invsee Herobrine [or] + // invsee Herobrine 1 + const command = message.split(' ') + useInvsee(command[0], command[1]) + break + } + } + }) + + function sayItems(items = bot.inventory.items()) { + const output = items.map(itemToString).join(', ') + if (output) { + bot.chat(output) + } + else { + bot.chat('empty') + } + } + + async function watchChest(minecart, blocks = []) { + let chestToOpen + if (minecart) { + chestToOpen = Object.keys(bot.entities) + .map(id => bot.entities[id]) + .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart + && e.objectData.intField === 1 + && bot.entity.position.distanceTo(e.position) < 3) + if (!chestToOpen) { + bot.chat('no chest minecart found') + return + } + } + else { + chestToOpen = bot.findBlock({ + matching: blocks.map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!chestToOpen) { + bot.chat('no chest found') + return + } + } + const chest = await bot.openContainer(chestToOpen) + sayItems(chest.containerItems()) + chest.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`chest update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + chest.on('close', () => { + bot.chat('chest closed') + }) + + bot.on('chat', onChat) + + function onChat(username, message) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeChest() + break + case /^withdraw \d+ \w+$/.test(message): + // withdraw amount name + // ex: withdraw 16 stick + withdrawItem(command[2], command[1]) + break + case /^deposit \d+ \w+$/.test(message): + // deposit amount name + // ex: deposit 16 stick + depositItem(command[2], command[1]) + break + } + } + + function closeChest() { + chest.close() + bot.removeListener('chat', onChat) + } + + async function withdrawItem(name, amount) { + const item = itemByName(chest.containerItems(), name) + if (item) { + try { + await chest.withdraw(item.type, null, amount) + bot.chat(`withdrew ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to withdraw ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function depositItem(name, amount) { + const item = itemByName(chest.items(), name) + if (item) { + try { + await chest.deposit(item.type, null, amount) + bot.chat(`deposited ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to deposit ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + } + + async function watchFurnace() { + const furnaceBlock = bot.findBlock({ + matching: ['furnace', 'lit_furnace'].filter(name => bot.registry.blocksByName[name] !== undefined).map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!furnaceBlock) { + bot.chat('no furnace found') + return + } + const furnace = await bot.openFurnace(furnaceBlock) + let output = '' + output += `input: ${itemToString(furnace.inputItem())}, ` + output += `fuel: ${itemToString(furnace.fuelItem())}, ` + output += `output: ${itemToString(furnace.outputItem())}` + bot.chat(output) + + furnace.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`furnace update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + furnace.on('close', () => { + bot.chat('furnace closed') + }) + furnace.on('update', () => { + console.log(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) + }) + + bot.on('chat', onChat) + + function onChat(username, message) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeFurnace() + break + case /^(input|fuel) \d+ \w+$/.test(message): + // input amount name + // ex: input 32 coal + putInFurnace(command[0], command[2], command[1]) + break + case /^take (input|fuel|output)$/.test(message): + // take what + // ex: take output + takeFromFurnace(command[0]) + break + } + + function closeFurnace() { + furnace.close() + bot.removeListener('chat', onChat) + } + + async function putInFurnace(where, name, amount) { + const item = itemByName(furnace.items(), name) + if (item) { + const fn = { + input: furnace.putInput, + fuel: furnace.putFuel, + }[where] + try { + await fn.call(furnace, item.type, null, amount) + bot.chat(`put ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to put ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function takeFromFurnace(what) { + const fn = { + input: furnace.takeInput, + fuel: furnace.takeFuel, + output: furnace.takeOutput, + }[what] + try { + const item = await fn.call(furnace) + bot.chat(`took ${item.name}`) + } + catch (err) { + bot.chat('unable to take') + } + } + } + } + + async function watchEnchantmentTable() { + const enchantTableBlock = bot.findBlock({ + matching: ['enchanting_table'].map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!enchantTableBlock) { + bot.chat('no enchantment table found') + return + } + const table = await bot.openEnchantmentTable(enchantTableBlock) + bot.chat(itemToString(table.targetItem())) + + table.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`enchantment table update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + table.on('close', () => { + bot.chat('enchantment table closed') + }) + table.on('ready', () => { + bot.chat(`ready to enchant. choices are ${table.enchantments.map(o => o.level).join(', ')}`) + }) + + bot.on('chat', onChat) + + function onChat(username, message) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeEnchantmentTable() + break + case /^put \w+$/.test(message): + // put name + // ex: put diamondsword + putItem(command[1]) + break + case /^add lapis$/.test(message): + addLapis() + break + case /^enchant \d+$/.test(message): + // enchant choice + // ex: enchant 2 + enchantItem(command[1]) + break + case /^take$/.test(message): + takeEnchantedItem() + break + } + + function closeEnchantmentTable() { + table.close() + } + + async function putItem(name) { + const item = itemByName(table.window.items(), name) + if (item) { + try { + await table.putTargetItem(item) + bot.chat(`I put ${itemToString(item)}`) + } + catch (err) { + bot.chat(`error putting ${itemToString(item)}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function addLapis() { + const item = itemByType(table.window.items(), ['dye', 'purple_dye', 'lapis_lazuli'].filter(name => bot.registry.itemByName[name] !== undefined) + .map(name => bot.registry.itemByName[name].id)) + if (item) { + try { + await table.putLapis(item) + bot.chat(`I put ${itemToString(item)}`) + } + catch (err) { + bot.chat(`error putting ${itemToString(item)}`) + } + } + else { + bot.chat('I don\'t have any lapis') + } + } + + async function enchantItem(choice) { + choice = Number.parseInt(choice, 10) + try { + const item = await table.enchant(choice) + bot.chat(`enchanted ${itemToString(item)}`) + } + catch (err) { + bot.chat('error enchanting') + } + } + + async function takeEnchantedItem() { + try { + const item = await table.takeTargetItem() + bot.chat(`got ${itemToString(item)}`) + } + catch (err) { + bot.chat('error getting item') + } + } + } + } + + function useInvsee(username, showEquipment) { + bot.once('windowOpen', (window) => { + const count = window.containerItems().length + const what = showEquipment ? 'equipment' : 'inventory items' + if (count) { + bot.chat(`${username}'s ${what}:`) + sayItems(window.containerItems()) + } + else { + bot.chat(`${username} has no ${what}`) + } + }) + if (showEquipment) { + // any extra parameter triggers the easter egg + // and shows the other player's equipment + bot.chat(`/invsee ${username} 1`) + } + else { + bot.chat(`/invsee ${username}`) + } + } + + function itemToString(item) { + if (item) { + return `${item.name} x ${item.count}` + } + else { + return '(nothing)' + } + } + + function itemByType(items, type) { + let item + let i + for (i = 0; i < items.length; ++i) { + item = items[i] + if (item && item.type === type) + return item + } + return null + } + + function itemByName(items, name) { + let item + let i + for (i = 0; i < items.length; ++i) { + item = items[i] + if (item && item.name === name) + return item + } + return null + } +} + +main().catch(console.error) From d4e8a001329c926a06be89e6a237a11971295454 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 5 Jan 2025 18:31:15 +0800 Subject: [PATCH 02/77] refactor: component and register --- services/minecraft/src/bot.ts | 78 +++ services/minecraft/src/components/chest.ts | 417 +++++++++++++ services/minecraft/src/components/echo.ts | 23 + .../minecraft/src/components/patchfinder.ts | 48 ++ services/minecraft/src/config.ts | 7 + services/minecraft/src/helper.ts | 3 + services/minecraft/src/main.ts | 566 +----------------- 7 files changed, 599 insertions(+), 543 deletions(-) create mode 100644 services/minecraft/src/bot.ts create mode 100644 services/minecraft/src/components/chest.ts create mode 100644 services/minecraft/src/components/echo.ts create mode 100644 services/minecraft/src/components/patchfinder.ts create mode 100644 services/minecraft/src/config.ts create mode 100644 services/minecraft/src/helper.ts diff --git a/services/minecraft/src/bot.ts b/services/minecraft/src/bot.ts new file mode 100644 index 000000000..52ba76dd0 --- /dev/null +++ b/services/minecraft/src/bot.ts @@ -0,0 +1,78 @@ +import { useLogg } from '@guiiai/logg' +import mineflayer, { type Bot, type BotOptions } from 'mineflayer' + +export interface Component { + (bot: Bot): void +} + +export interface ComponentContext { + cleanup: () => void +} + +const logger = useLogg('bot').useGlobalConfig() + +export function useBot() { + let botInstance: Bot | null = null + const contexts = new Map() + + const createBot = (options: BotOptions): Bot => { + logger.withFields({ options }).log('Creating bot') + botInstance = mineflayer.createBot({ + host: options.host, + port: options.port, + username: options.username, + password: options.password, + }) + + botInstance.on('error', (err: Error) => { + logger.errorWithError('Bot error:', err) + }) + + botInstance.on('kicked', (reason: string) => { + logger.withFields({ reason }).error('Bot was kicked') + }) + + logger.log('Bot created') + return botInstance + } + + const cleanup = () => { + logger.log('Cleaning up bot and components') + contexts.forEach((context: ComponentContext) => context.cleanup?.()) + contexts.clear() + botInstance?.end() + } + + const ensureBot = (): Bot => { + if (!botInstance) + throw new Error('Bot is not initialized') + return botInstance + } + + const registerComponent = (componentName: string, component: Component) => { + logger.withFields({ componentName }).log('Registering new component') + const bot = ensureBot() + const context = component(bot) + + if (context != null) + contexts.set(componentName, context) + return context + } + + const listComponents = () => { + return Array.from(contexts.keys()) + } + + const getComponent = (componentName: string) => { + return contexts.get(componentName) + } + + return { + createBot, + cleanup, + registerComponent, + listComponents, + getComponent, + getBot: ensureBot, + } +} diff --git a/services/minecraft/src/components/chest.ts b/services/minecraft/src/components/chest.ts new file mode 100644 index 000000000..10f780a66 --- /dev/null +++ b/services/minecraft/src/components/chest.ts @@ -0,0 +1,417 @@ +import type { Bot } from 'mineflayer' + +/* + * Watch out, this is a big one! + * + * This is a demonstration to show you how you can interact with: + * - Chests + * - Furnaces + * - Dispensers + * - Enchantment Tables + * + * and of course with your own inventory. + * + * Each of the main commands makes the bot interact with the block and open + * its window. From there you can send another set of commands to actually + * interact with the window and make awesome stuff. + * + * There's also a bonus example which shows you how to use the /invsee command + * to see what items another user has in his inventory and what items he has + * equipped. + * This last one is usually reserved to Server Ops so make sure you have the + * appropriate permission to do it or it won't work. + */ + +export function createChestComponent(bot: Bot) { + bot.on('experience', () => { + bot.chat(`I am level ${bot.experience.level}`) + }) + + bot.on('chat', (username, message) => { + if (username === bot.username) + return + switch (true) { + case /^list$/.test(message): + sayItems() + break + case /^chest$/.test(message): + watchChest(false, ['chest', 'ender_chest', 'trapped_chest']) + break + case /^furnace$/.test(message): + watchFurnace() + break + case /^dispenser$/.test(message): + watchChest(false, ['dispenser']) + break + case /^enchant$/.test(message): + watchEnchantmentTable() + break + case /^chestminecart$/.test(message): + watchChest(true) + break + case /^invsee \w+( \d)?$/.test(message): { + // invsee Herobrine [or] + // invsee Herobrine 1 + const command = message.split(' ') + useInvsee(command[0], command[1]) + break + } + } + }) + + function sayItems(items = bot.inventory.items()) { + const output = items.map(itemToString).join(', ') + if (output) { + bot.chat(output) + } + else { + bot.chat('empty') + } + } + + async function watchChest(minecart: boolean, blocks: string[] = []) { + let chestToOpen + if (minecart) { + chestToOpen = Object.keys(bot.entities) + .map(id => bot.entities[id]) + .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart + && e.objectData.intField === 1 + && bot.entity.position.distanceTo(e.position) < 3) + if (!chestToOpen) { + bot.chat('no chest minecart found') + return + } + } + else { + chestToOpen = bot.findBlock({ + matching: blocks.map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!chestToOpen) { + bot.chat('no chest found') + return + } + } + const chest = await bot.openContainer(chestToOpen) + sayItems(chest.containerItems()) + chest.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`chest update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + chest.on('close', () => { + bot.chat('chest closed') + }) + + bot.on('chat', onChat) + + function onChat(username: string, message: string) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeChest() + break + case /^withdraw \d+ \w+$/.test(message): + // withdraw amount name + // ex: withdraw 16 stick + withdrawItem(command[2], command[1]) + break + case /^deposit \d+ \w+$/.test(message): + // deposit amount name + // ex: deposit 16 stick + depositItem(command[2], command[1]) + break + } + } + + function closeChest() { + chest.close() + bot.removeListener('chat', onChat) + } + + async function withdrawItem(name: string, amount: number) { + const item = itemByName(chest.containerItems(), name) + if (item) { + try { + await chest.withdraw(item.type, null, amount) + bot.chat(`withdrew ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to withdraw ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function depositItem(name: string, amount: number) { + const item = itemByName(chest.items(), name) + if (item) { + try { + await chest.deposit(item.type, null, amount) + bot.chat(`deposited ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to deposit ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + } + + async function watchFurnace() { + const furnaceBlock = bot.findBlock({ + matching: ['furnace', 'lit_furnace'].filter(name => bot.registry.blocksByName[name] !== undefined).map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!furnaceBlock) { + bot.chat('no furnace found') + return + } + const furnace = await bot.openFurnace(furnaceBlock) + let output = '' + output += `input: ${itemToString(furnace.inputItem())}, ` + output += `fuel: ${itemToString(furnace.fuelItem())}, ` + output += `output: ${itemToString(furnace.outputItem())}` + bot.chat(output) + + furnace.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`furnace update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + furnace.on('close', () => { + bot.chat('furnace closed') + }) + furnace.on('update', () => { + console.log(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) + }) + + bot.on('chat', onChat) + + function onChat(username: string, message: string) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeFurnace() + break + case /^(input|fuel) \d+ \w+$/.test(message): + // input amount name + // ex: input 32 coal + putInFurnace(command[0], command[2], command[1]) + break + case /^take (input|fuel|output)$/.test(message): + // take what + // ex: take output + takeFromFurnace(command[0]) + break + } + + function closeFurnace() { + furnace.close() + bot.removeListener('chat', onChat) + } + + async function putInFurnace(where: string, name: string, amount: number) { + const item = itemByName(furnace.items(), name) + if (item) { + const fn = { + input: furnace.putInput, + fuel: furnace.putFuel, + }[where] + try { + await fn.call(furnace, item.type, null, amount) + bot.chat(`put ${amount} ${item.name}`) + } + catch (err) { + bot.chat(`unable to put ${amount} ${item.name}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function takeFromFurnace(what: string) { + const fn = { + input: furnace.takeInput, + fuel: furnace.takeFuel, + output: furnace.takeOutput, + }[what] + try { + const item = await fn.call(furnace) + bot.chat(`took ${item.name}`) + } + catch (err) { + bot.chat('unable to take') + } + } + } + } + + async function watchEnchantmentTable() { + const enchantTableBlock = bot.findBlock({ + matching: ['enchanting_table'].map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + if (!enchantTableBlock) { + bot.chat('no enchantment table found') + return + } + const table = await bot.openEnchantmentTable(enchantTableBlock) + bot.chat(itemToString(table.targetItem())) + + table.on('updateSlot', (slot, oldItem, newItem) => { + bot.chat(`enchantment table update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) + }) + table.on('close', () => { + bot.chat('enchantment table closed') + }) + table.on('ready', () => { + bot.chat(`ready to enchant. choices are ${table.enchantments.map(o => o.level).join(', ')}`) + }) + + bot.on('chat', onChat) + + function onChat(username: string, message: string) { + if (username === bot.username) + return + const command = message.split(' ') + switch (true) { + case /^close$/.test(message): + closeEnchantmentTable() + break + case /^put \w+$/.test(message): + // put name + // ex: put diamondsword + putItem(command[1]) + break + case /^add lapis$/.test(message): + addLapis() + break + case /^enchant \d+$/.test(message): + // enchant choice + // ex: enchant 2 + enchantItem(command[1]) + break + case /^take$/.test(message): + takeEnchantedItem() + break + } + + function closeEnchantmentTable() { + table.close() + } + + async function putItem(name: string) { + const item = itemByName(table.window.items(), name) + if (item) { + try { + await table.putTargetItem(item) + bot.chat(`I put ${itemToString(item)}`) + } + catch (err) { + bot.chat(`error putting ${itemToString(item)}`) + } + } + else { + bot.chat(`unknown item ${name}`) + } + } + + async function addLapis() { + const item = itemByType(table.window.items(), ['dye', 'purple_dye', 'lapis_lazuli'].filter(name => bot.registry.itemByName[name] !== undefined) + .map(name => bot.registry.itemByName[name].id)) + if (item) { + try { + await table.putLapis(item) + bot.chat(`I put ${itemToString(item)}`) + } + catch (err) { + bot.chat(`error putting ${itemToString(item)}`) + } + } + else { + bot.chat('I don\'t have any lapis') + } + } + + async function enchantItem(choice: string) { + choice = Number.parseInt(choice, 10) + try { + const item = await table.enchant(choice) + bot.chat(`enchanted ${itemToString(item)}`) + } + catch (err) { + bot.chat('error enchanting') + } + } + + async function takeEnchantedItem() { + try { + const item = await table.takeTargetItem() + bot.chat(`got ${itemToString(item)}`) + } + catch (err) { + bot.chat('error getting item') + } + } + } + } + + function useInvsee(username: string, showEquipment: boolean) { + bot.once('windowOpen', (window) => { + const count = window.containerItems().length + const what = showEquipment ? 'equipment' : 'inventory items' + if (count) { + bot.chat(`${username}'s ${what}:`) + sayItems(window.containerItems()) + } + else { + bot.chat(`${username} has no ${what}`) + } + }) + if (showEquipment) { + // any extra parameter triggers the easter egg + // and shows the other player's equipment + bot.chat(`/invsee ${username} 1`) + } + else { + bot.chat(`/invsee ${username}`) + } + } + + function itemToString(item: Item | null): string { + if (item) { + return `${item.name} x ${item.count}` + } + else { + return '(nothing)' + } + } + + function itemByType(items: Item[], type: number): Item | null { + let item: Item | null = null + let i: number + for (i = 0; i < items.length; ++i) { + item = items[i] + if (item && item.type === type) + return item + } + return null + } + + function itemByName(items: Item[], name: string): Item | null { + let item: Item | null = null + let i: number + for (i = 0; i < items.length; ++i) { + item = items[i] + if (item && item.name === name) + return item + } + return null + } +} diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts new file mode 100644 index 000000000..2c46015c3 --- /dev/null +++ b/services/minecraft/src/components/echo.ts @@ -0,0 +1,23 @@ +import type { Bot } from 'mineflayer' +import type { ComponentContext } from '../bot' +import { useLogg } from '@guiiai/logg' + +const logger = useLogg('echo').useGlobalConfig() + +export function createEchoComponent(botInstance: Bot): ComponentContext { + const onChat = (username: string, message: string) => { + if (username === botInstance.username) + return + + logger.withFields({ username, message }).log('Chat message received') + botInstance.chat(message) + } + + botInstance.on('chat', onChat) + + return { + cleanup: () => { + botInstance.removeListener('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/patchfinder.ts new file mode 100644 index 000000000..be509e129 --- /dev/null +++ b/services/minecraft/src/components/patchfinder.ts @@ -0,0 +1,48 @@ +// This is an example that uses mineflayer-pathfinder to showcase how simple it is to walk to goals + +import type { Bot } from 'mineflayer' +import type { ComponentContext } from '../bot' +import { useLogg } from '@guiiai/logg' +import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' + +export function createPathFinderComponent(botInstance: Bot): ComponentContext { + const RANGE_GOAL = 1 // get within this radius of the player + + const logger = useLogg('pathfinder').useGlobalConfig() + logger.log('Loading pathfinder plugin') + + botInstance.loadPlugin(pathfinder) + + let defaultMove: Movements + + const onChat = (username: string, message: string) => { + if (username === botInstance.username) + return + if (message !== 'come') + return + + logger.withFields({ username, message }).log('Chat message received') + const target = botInstance.players[username]?.entity + if (!target) { + botInstance.chat('I don\'t see you !') + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + botInstance.pathfinder.setMovements(defaultMove) + botInstance.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + } + + botInstance.once('spawn', () => { + logger.log('Spawning bot') + defaultMove = new Movements(botInstance) + botInstance.on('chat', onChat) + }) + + return { + cleanup: () => { + botInstance.removeListener('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/config.ts b/services/minecraft/src/config.ts new file mode 100644 index 000000000..f867216c0 --- /dev/null +++ b/services/minecraft/src/config.ts @@ -0,0 +1,7 @@ +import type { BotOptions } from 'mineflayer' + +export const defaultConfig: BotOptions = { + host: 'localhost', + username: 'airi', + port: 49415, +} diff --git a/services/minecraft/src/helper.ts b/services/minecraft/src/helper.ts new file mode 100644 index 000000000..cbacad03e --- /dev/null +++ b/services/minecraft/src/helper.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 3e5f6691c..330691550 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,550 +1,30 @@ -import mineflayer from 'mineflayer' -import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' +import process from 'node:process' +import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +import { useBot } from './bot' +import { createEchoComponent } from './components/echo' +import { createPathFinderComponent } from './components/patchfinder' +import { defaultConfig } from './config' -async function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)) -} +const logger = useLogg('main').useGlobalConfig() async function main() { - await sleep(5000) + // await sleep(5000) + setGlobalLogLevel(LogLevel.Debug) + setGlobalFormat(Format.Pretty) - const bot = mineflayer.createBot({ - host: '10.0.0.100', // minecraft 服务器的 IP 地址 - username: 'airi', // minecraft 用户名 - // password: '12345678', // minecraft 密码, 如果你玩的是不需要正版验证的服务器,请注释掉。 - port: 56304, // 默认使用 25565,如果你的服务器端口不是这个请取消注释并填写。 - // version: false, // 如果需要指定re使用一个版本或快照时,请取消注释并手动填写(如:"1.8.9" 或 "1.16.5"),否则会自动设置。 - // auth: 'mojang' // 如果需要使用微软账号登录时,请取消注释,然后将值设置为 'microsoft',否则会自动设置为 'mojang'。 + const bot = useBot() + bot.createBot(defaultConfig) + + bot.registerComponent('echo', createEchoComponent) + bot.registerComponent('pathfinder', createPathFinderComponent) + + process.on('SIGINT', () => { + bot.cleanup() + process.exit(0) }) - - bot.on('chat', (username, message) => { - if (username === bot.username) - return - bot.chat(message) - }) - - // 记录错误和被踢出服务器的原因: - bot.on('kicked', console.log) - bot.on('error', console.log) - - bot.loadPlugin(pathfinder) - - const RANGE_GOAL = 1 - bot.once('spawn', () => { - const defaultMove = new Movements(bot) - - bot.on('chat', (username, message) => { - if (username === bot.username) - return - if (message !== 'come') - return - const target = bot.players[username]?.entity - if (!target) { - bot.chat('I don\'t see you !') - return - } - const { x: playerX, y: playerY, z: playerZ } = target.position - - bot.pathfinder.setMovements(defaultMove) - bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) - }) - }) - - /** TP */ - let target: Entity = null - - bot.on('chat', (username, message) => { - if (username === bot.username) - return - target = bot.players[username].entity - let entity - switch (message) { - case 'forward': - bot.setControlState('forward', true) - break - case 'back': - bot.setControlState('back', true) - break - case 'left': - bot.setControlState('left', true) - break - case 'right': - bot.setControlState('right', true) - break - case 'sprint': - bot.setControlState('sprint', true) - break - case 'stop': - bot.clearControlStates() - break - case 'jump': - bot.setControlState('jump', true) - bot.setControlState('jump', false) - break - case 'jump a lot': - bot.setControlState('jump', true) - break - case 'stop jumping': - bot.setControlState('jump', false) - break - case 'attack': - entity = bot.nearestEntity() - if (entity) { - bot.attack(entity, true) - } - else { - bot.chat('no nearby entities') - } - break - case 'mount': - entity = bot.nearestEntity((entity) => { return entity.name === 'minecart' }) - if (entity) { - bot.mount(entity) - } - else { - bot.chat('no nearby objects') - } - break - case 'dismount': - bot.dismount() - break - case 'move vehicle forward': - bot.moveVehicle(0.0, 1.0) - break - case 'move vehicle backward': - bot.moveVehicle(0.0, -1.0) - break - case 'move vehicle left': - bot.moveVehicle(1.0, 0.0) - break - case 'move vehicle right': - bot.moveVehicle(-1.0, 0.0) - break - case 'tp': - bot.entity.position.y += 10 - break - case 'pos': - bot.chat(bot.entity.position.toString()) - break - case 'yp': - bot.chat(`Yaw ${bot.entity.yaw}, pitch: ${bot.entity.pitch}`) - break - } - }) - - bot.once('spawn', () => { - // keep your eyes on the target, so creepy! - setInterval(watchTarget, 50) - - function watchTarget() { - if (!target) - return - bot.lookAt(target.position.offset(0, target.height, 0)) - } - }) - - bot.on('mount', () => { - bot.chat(`mounted ${bot.vehicle.displayName}`) - }) - - bot.on('dismount', (vehicle) => { - bot.chat(`dismounted ${vehicle.displayName}`) - }) - - /** - * Chest - */ - bot.on('experience', () => { - bot.chat(`I am level ${bot.experience.level}`) - }) - - bot.on('chat', (username, message) => { - if (username === bot.username) - return - switch (true) { - case /^list$/.test(message): - sayItems() - break - case /^chest$/.test(message): - watchChest(false, ['chest', 'ender_chest', 'trapped_chest']) - break - case /^furnace$/.test(message): - watchFurnace() - break - case /^dispenser$/.test(message): - watchChest(false, ['dispenser']) - break - case /^enchant$/.test(message): - watchEnchantmentTable() - break - case /^chestminecart$/.test(message): - watchChest(true) - break - case /^invsee \w+( \d)?$/.test(message): { - // invsee Herobrine [or] - // invsee Herobrine 1 - const command = message.split(' ') - useInvsee(command[0], command[1]) - break - } - } - }) - - function sayItems(items = bot.inventory.items()) { - const output = items.map(itemToString).join(', ') - if (output) { - bot.chat(output) - } - else { - bot.chat('empty') - } - } - - async function watchChest(minecart, blocks = []) { - let chestToOpen - if (minecart) { - chestToOpen = Object.keys(bot.entities) - .map(id => bot.entities[id]) - .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart - && e.objectData.intField === 1 - && bot.entity.position.distanceTo(e.position) < 3) - if (!chestToOpen) { - bot.chat('no chest minecart found') - return - } - } - else { - chestToOpen = bot.findBlock({ - matching: blocks.map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - if (!chestToOpen) { - bot.chat('no chest found') - return - } - } - const chest = await bot.openContainer(chestToOpen) - sayItems(chest.containerItems()) - chest.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`chest update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - chest.on('close', () => { - bot.chat('chest closed') - }) - - bot.on('chat', onChat) - - function onChat(username, message) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeChest() - break - case /^withdraw \d+ \w+$/.test(message): - // withdraw amount name - // ex: withdraw 16 stick - withdrawItem(command[2], command[1]) - break - case /^deposit \d+ \w+$/.test(message): - // deposit amount name - // ex: deposit 16 stick - depositItem(command[2], command[1]) - break - } - } - - function closeChest() { - chest.close() - bot.removeListener('chat', onChat) - } - - async function withdrawItem(name, amount) { - const item = itemByName(chest.containerItems(), name) - if (item) { - try { - await chest.withdraw(item.type, null, amount) - bot.chat(`withdrew ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to withdraw ${amount} ${item.name}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - - async function depositItem(name, amount) { - const item = itemByName(chest.items(), name) - if (item) { - try { - await chest.deposit(item.type, null, amount) - bot.chat(`deposited ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to deposit ${amount} ${item.name}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - } - - async function watchFurnace() { - const furnaceBlock = bot.findBlock({ - matching: ['furnace', 'lit_furnace'].filter(name => bot.registry.blocksByName[name] !== undefined).map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - if (!furnaceBlock) { - bot.chat('no furnace found') - return - } - const furnace = await bot.openFurnace(furnaceBlock) - let output = '' - output += `input: ${itemToString(furnace.inputItem())}, ` - output += `fuel: ${itemToString(furnace.fuelItem())}, ` - output += `output: ${itemToString(furnace.outputItem())}` - bot.chat(output) - - furnace.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`furnace update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - furnace.on('close', () => { - bot.chat('furnace closed') - }) - furnace.on('update', () => { - console.log(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) - }) - - bot.on('chat', onChat) - - function onChat(username, message) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeFurnace() - break - case /^(input|fuel) \d+ \w+$/.test(message): - // input amount name - // ex: input 32 coal - putInFurnace(command[0], command[2], command[1]) - break - case /^take (input|fuel|output)$/.test(message): - // take what - // ex: take output - takeFromFurnace(command[0]) - break - } - - function closeFurnace() { - furnace.close() - bot.removeListener('chat', onChat) - } - - async function putInFurnace(where, name, amount) { - const item = itemByName(furnace.items(), name) - if (item) { - const fn = { - input: furnace.putInput, - fuel: furnace.putFuel, - }[where] - try { - await fn.call(furnace, item.type, null, amount) - bot.chat(`put ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to put ${amount} ${item.name}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - - async function takeFromFurnace(what) { - const fn = { - input: furnace.takeInput, - fuel: furnace.takeFuel, - output: furnace.takeOutput, - }[what] - try { - const item = await fn.call(furnace) - bot.chat(`took ${item.name}`) - } - catch (err) { - bot.chat('unable to take') - } - } - } - } - - async function watchEnchantmentTable() { - const enchantTableBlock = bot.findBlock({ - matching: ['enchanting_table'].map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - if (!enchantTableBlock) { - bot.chat('no enchantment table found') - return - } - const table = await bot.openEnchantmentTable(enchantTableBlock) - bot.chat(itemToString(table.targetItem())) - - table.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`enchantment table update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - table.on('close', () => { - bot.chat('enchantment table closed') - }) - table.on('ready', () => { - bot.chat(`ready to enchant. choices are ${table.enchantments.map(o => o.level).join(', ')}`) - }) - - bot.on('chat', onChat) - - function onChat(username, message) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeEnchantmentTable() - break - case /^put \w+$/.test(message): - // put name - // ex: put diamondsword - putItem(command[1]) - break - case /^add lapis$/.test(message): - addLapis() - break - case /^enchant \d+$/.test(message): - // enchant choice - // ex: enchant 2 - enchantItem(command[1]) - break - case /^take$/.test(message): - takeEnchantedItem() - break - } - - function closeEnchantmentTable() { - table.close() - } - - async function putItem(name) { - const item = itemByName(table.window.items(), name) - if (item) { - try { - await table.putTargetItem(item) - bot.chat(`I put ${itemToString(item)}`) - } - catch (err) { - bot.chat(`error putting ${itemToString(item)}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - - async function addLapis() { - const item = itemByType(table.window.items(), ['dye', 'purple_dye', 'lapis_lazuli'].filter(name => bot.registry.itemByName[name] !== undefined) - .map(name => bot.registry.itemByName[name].id)) - if (item) { - try { - await table.putLapis(item) - bot.chat(`I put ${itemToString(item)}`) - } - catch (err) { - bot.chat(`error putting ${itemToString(item)}`) - } - } - else { - bot.chat('I don\'t have any lapis') - } - } - - async function enchantItem(choice) { - choice = Number.parseInt(choice, 10) - try { - const item = await table.enchant(choice) - bot.chat(`enchanted ${itemToString(item)}`) - } - catch (err) { - bot.chat('error enchanting') - } - } - - async function takeEnchantedItem() { - try { - const item = await table.takeTargetItem() - bot.chat(`got ${itemToString(item)}`) - } - catch (err) { - bot.chat('error getting item') - } - } - } - } - - function useInvsee(username, showEquipment) { - bot.once('windowOpen', (window) => { - const count = window.containerItems().length - const what = showEquipment ? 'equipment' : 'inventory items' - if (count) { - bot.chat(`${username}'s ${what}:`) - sayItems(window.containerItems()) - } - else { - bot.chat(`${username} has no ${what}`) - } - }) - if (showEquipment) { - // any extra parameter triggers the easter egg - // and shows the other player's equipment - bot.chat(`/invsee ${username} 1`) - } - else { - bot.chat(`/invsee ${username}`) - } - } - - function itemToString(item) { - if (item) { - return `${item.name} x ${item.count}` - } - else { - return '(nothing)' - } - } - - function itemByType(items, type) { - let item - let i - for (i = 0; i < items.length; ++i) { - item = items[i] - if (item && item.type === type) - return item - } - return null - } - - function itemByName(items, name) { - let item - let i - for (i = 0; i < items.length; ++i) { - item = items[i] - if (item && item.name === name) - return item - } - return null - } } -main().catch(console.error) +main().catch((err: Error) => { + logger.errorWithError('Fatal error', err) + process.exit(1) +}) From 469abd58e90007d8c2ab84c781caaa79921071d4 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 5 Jan 2025 19:04:57 +0800 Subject: [PATCH 03/77] refactor: life cycle --- services/minecraft/src/bot.ts | 72 ++- services/minecraft/src/components/chest.ts | 574 +++++++----------- services/minecraft/src/components/echo.ts | 4 +- services/minecraft/src/components/follow.ts | 87 +++ .../minecraft/src/components/patchfinder.ts | 4 +- services/minecraft/src/main.ts | 9 +- 6 files changed, 343 insertions(+), 407 deletions(-) create mode 100644 services/minecraft/src/components/follow.ts diff --git a/services/minecraft/src/bot.ts b/services/minecraft/src/bot.ts index 52ba76dd0..022e16ccc 100644 --- a/services/minecraft/src/bot.ts +++ b/services/minecraft/src/bot.ts @@ -1,61 +1,56 @@ import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' +const logger = useLogg('bot').useGlobalConfig() + +let botInstance: Bot | undefined + export interface Component { (bot: Bot): void } -export interface ComponentContext { +export interface ComponentLifecycle { cleanup: () => void } -const logger = useLogg('bot').useGlobalConfig() +export function createBot(options: BotOptions): Bot { + logger.withFields({ options }).log('Creating bot') + botInstance = mineflayer.createBot({ + host: options.host, + port: options.port, + username: options.username, + password: options.password, + }) + + botInstance.on('error', (err: Error) => { + logger.errorWithError('Bot error:', err) + }) + + botInstance.on('kicked', (reason: string) => { + logger.withFields({ reason }).error('Bot was kicked') + }) + + logger.log('Bot created') + return botInstance +} export function useBot() { - let botInstance: Bot | null = null - const contexts = new Map() - - const createBot = (options: BotOptions): Bot => { - logger.withFields({ options }).log('Creating bot') - botInstance = mineflayer.createBot({ - host: options.host, - port: options.port, - username: options.username, - password: options.password, - }) - - botInstance.on('error', (err: Error) => { - logger.errorWithError('Bot error:', err) - }) - - botInstance.on('kicked', (reason: string) => { - logger.withFields({ reason }).error('Bot was kicked') - }) - - logger.log('Bot created') - return botInstance - } + const contexts = new Map() const cleanup = () => { logger.log('Cleaning up bot and components') - contexts.forEach((context: ComponentContext) => context.cleanup?.()) + contexts.forEach((context: ComponentLifecycle) => context.cleanup?.()) contexts.clear() botInstance?.end() } - const ensureBot = (): Bot => { - if (!botInstance) - throw new Error('Bot is not initialized') - return botInstance - } - const registerComponent = (componentName: string, component: Component) => { logger.withFields({ componentName }).log('Registering new component') - const bot = ensureBot() - const context = component(bot) + const context = component(botInstance!) if (context != null) contexts.set(componentName, context) + return context } @@ -67,12 +62,15 @@ export function useBot() { return contexts.get(componentName) } + if (!botInstance) { + throw new Error('Bot instance not found') + } + return { - createBot, - cleanup, registerComponent, listComponents, getComponent, - getBot: ensureBot, + cleanup, + bot: botInstance, } } diff --git a/services/minecraft/src/components/chest.ts b/services/minecraft/src/components/chest.ts index 10f780a66..4ae3b5ad6 100644 --- a/services/minecraft/src/components/chest.ts +++ b/services/minecraft/src/components/chest.ts @@ -1,417 +1,263 @@ import type { Bot } from 'mineflayer' +import type { Item } from 'prismarine-item' +import type { Window } from 'prismarine-windows' +import type { ComponentLifecycle } from '../bot' +import { useLogg } from '@guiiai/logg' -/* - * Watch out, this is a big one! - * - * This is a demonstration to show you how you can interact with: - * - Chests - * - Furnaces - * - Dispensers - * - Enchantment Tables - * - * and of course with your own inventory. - * - * Each of the main commands makes the bot interact with the block and open - * its window. From there you can send another set of commands to actually - * interact with the window and make awesome stuff. - * - * There's also a bonus example which shows you how to use the /invsee command - * to see what items another user has in his inventory and what items he has - * equipped. - * This last one is usually reserved to Server Ops so make sure you have the - * appropriate permission to do it or it won't work. - */ +const VALID_CHEST_TYPES = ['chest', 'ender_chest', 'trapped_chest'] as const +const VALID_FURNACE_TYPES = ['furnace', 'lit_furnace'] as const +const VALID_ENCHANT_TYPES = ['enchanting_table'] as const -export function createChestComponent(bot: Bot) { - bot.on('experience', () => { - bot.chat(`I am level ${bot.experience.level}`) - }) +interface ContainerContext { + window: Window + removeListeners: () => void +} - bot.on('chat', (username, message) => { - if (username === bot.username) - return - switch (true) { - case /^list$/.test(message): - sayItems() - break - case /^chest$/.test(message): - watchChest(false, ['chest', 'ender_chest', 'trapped_chest']) - break - case /^furnace$/.test(message): - watchFurnace() - break - case /^dispenser$/.test(message): - watchChest(false, ['dispenser']) - break - case /^enchant$/.test(message): - watchEnchantmentTable() - break - case /^chestminecart$/.test(message): - watchChest(true) - break - case /^invsee \w+( \d)?$/.test(message): { - // invsee Herobrine [or] - // invsee Herobrine 1 - const command = message.split(' ') - useInvsee(command[0], command[1]) - break - } - } - }) +// Utility functions +function createItemUtils(bot: Bot) { + const itemToString = (item: Item | null): string => { + if (item) + return `${item.name} x ${item.count}` + return '(nothing)' + } - function sayItems(items = bot.inventory.items()) { + const findItemByName = (items: Item[], name: string): Item | null => + items.find(item => item?.name === name) || null + + const findItemByType = (items: Item[], type: number): Item | null => + items.find(item => item?.type === type) || null + + const listItems = (items = bot.inventory.items()): void => { const output = items.map(itemToString).join(', ') - if (output) { - bot.chat(output) + bot.chat(output || 'empty') + } + + return { + itemToString, + findItemByName, + findItemByType, + listItems, + } +} + +// Container management +function createContainerManager(bot: Bot, logger: ReturnType) { + const setupContainerListeners = ( + window: Window, + onChat: (username: string, message: string) => void, + type = 'container', + ): () => void => { + const updateHandler = (slot: number, oldItem: Item, newItem: Item) => { + bot.chat(`${type} update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) } - else { - bot.chat('empty') + + const closeHandler = () => { + bot.chat(`${type} closed`) + } + + window.on('updateSlot', updateHandler) + window.on('close', closeHandler) + bot.on('chat', onChat) + + return () => { + window.removeListener('updateSlot', updateHandler) + window.removeListener('close', closeHandler) + bot.removeListener('chat', onChat) } } - async function watchChest(minecart: boolean, blocks: string[] = []) { - let chestToOpen - if (minecart) { - chestToOpen = Object.keys(bot.entities) - .map(id => bot.entities[id]) - .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart - && e.objectData.intField === 1 - && bot.entity.position.distanceTo(e.position) < 3) - if (!chestToOpen) { - bot.chat('no chest minecart found') - return - } - } - else { - chestToOpen = bot.findBlock({ - matching: blocks.map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - if (!chestToOpen) { - bot.chat('no chest found') - return - } - } - const chest = await bot.openContainer(chestToOpen) - sayItems(chest.containerItems()) - chest.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`chest update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - chest.on('close', () => { - bot.chat('chest closed') + const findNearbyBlock = (types: readonly string[], maxDistance = 6): ReturnType => { + return bot.findBlock({ + matching: types + .filter(name => bot.registry.blocksByName[name] !== undefined) + .map(name => bot.registry.blocksByName[name].id), + maxDistance, }) + } - bot.on('chat', onChat) + return { + setupContainerListeners, + findNearbyBlock, + } +} - function onChat(username: string, message: string) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeChest() - break - case /^withdraw \d+ \w+$/.test(message): - // withdraw amount name - // ex: withdraw 16 stick - withdrawItem(command[2], command[1]) - break - case /^deposit \d+ \w+$/.test(message): - // deposit amount name - // ex: deposit 16 stick - depositItem(command[2], command[1]) - break +// Chest functions +function createChestManager(bot: Bot, logger: ReturnType, utils: ReturnType) { + const { itemToString, findItemByName, listItems } = utils + + const handleChestCommands = async (window: Window, command: string[]): Promise => { + switch (true) { + case /^close$/.test(command[0]): { + window.close() + break } - } + case /^withdraw \d+ \w+$/.test(command.join(' ')): { + const amount = Number.parseInt(command[1], 10) + const name = command[2] + const item = findItemByName(window.containerItems(), name) - function closeChest() { - chest.close() - bot.removeListener('chat', onChat) - } + if (!item) { + bot.chat(`unknown item ${name}`) + return + } - async function withdrawItem(name: string, amount: number) { - const item = itemByName(chest.containerItems(), name) - if (item) { try { - await chest.withdraw(item.type, null, amount) + await window.withdraw(item.type, null, amount) bot.chat(`withdrew ${amount} ${item.name}`) } catch (err) { bot.chat(`unable to withdraw ${amount} ${item.name}`) } + break } - else { - bot.chat(`unknown item ${name}`) - } - } + case /^deposit \d+ \w+$/.test(command.join(' ')): { + const amount = Number.parseInt(command[1], 10) + const name = command[2] + const item = findItemByName(window.items(), name) + + if (!item) { + bot.chat(`unknown item ${name}`) + return + } - async function depositItem(name: string, amount: number) { - const item = itemByName(chest.items(), name) - if (item) { try { - await chest.deposit(item.type, null, amount) + await window.deposit(item.type, null, amount) bot.chat(`deposited ${amount} ${item.name}`) } catch (err) { bot.chat(`unable to deposit ${amount} ${item.name}`) } - } - else { - bot.chat(`unknown item ${name}`) + break } } } - async function watchFurnace() { - const furnaceBlock = bot.findBlock({ - matching: ['furnace', 'lit_furnace'].filter(name => bot.registry.blocksByName[name] !== undefined).map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) + const openChest = async (minecart = false): Promise => { + let target + if (minecart) { + target = Object.values(bot.entities) + .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart + && bot.entity.position.distanceTo(e.position) < 3) + + if (!target) { + bot.chat('no chest minecart found') + return null + } + } + else { + target = bot.findBlock({ + matching: VALID_CHEST_TYPES.map(name => bot.registry.blocksByName[name].id), + maxDistance: 6, + }) + + if (!target) { + bot.chat('no chest found') + return null + } + } + + try { + const window = await bot.openContainer(target) + const onChat = (username: string, message: string) => { + if (username === bot.username) + return + handleChestCommands(window, message.split(' ')) + } + + const removeListeners = setupContainerListeners(window, onChat, 'chest') + listItems(window.containerItems()) + + return { window, removeListeners } + } + catch (err) { + logger.error('Failed to open chest', err) + bot.chat('Failed to open chest') + return null + } + } + + return { + openChest, + } +} + +// Furnace functions +function createFurnaceManager(bot: Bot, logger: ReturnType, containerManager: ReturnType) { + const { findNearbyBlock } = containerManager + + const openFurnace = async (): Promise => { + const furnaceBlock = findNearbyBlock(VALID_FURNACE_TYPES) if (!furnaceBlock) { bot.chat('no furnace found') return } - const furnace = await bot.openFurnace(furnaceBlock) - let output = '' - output += `input: ${itemToString(furnace.inputItem())}, ` - output += `fuel: ${itemToString(furnace.fuelItem())}, ` - output += `output: ${itemToString(furnace.outputItem())}` - bot.chat(output) - furnace.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`furnace update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - furnace.on('close', () => { - bot.chat('furnace closed') - }) - furnace.on('update', () => { - console.log(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) - }) + try { + const furnace = await bot.openFurnace(furnaceBlock) + let output = '' + output += `input: ${itemToString(furnace.inputItem())}, ` + output += `fuel: ${itemToString(furnace.fuelItem())}, ` + output += `output: ${itemToString(furnace.outputItem())}` + bot.chat(output) - bot.on('chat', onChat) + furnace.on('update', () => { + logger.debug(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) + }) - function onChat(username: string, message: string) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeFurnace() - break - case /^(input|fuel) \d+ \w+$/.test(message): - // input amount name - // ex: input 32 coal - putInFurnace(command[0], command[2], command[1]) - break - case /^take (input|fuel|output)$/.test(message): - // take what - // ex: take output - takeFromFurnace(command[0]) - break - } - - function closeFurnace() { - furnace.close() - bot.removeListener('chat', onChat) - } - - async function putInFurnace(where: string, name: string, amount: number) { - const item = itemByName(furnace.items(), name) - if (item) { - const fn = { - input: furnace.putInput, - fuel: furnace.putFuel, - }[where] - try { - await fn.call(furnace, item.type, null, amount) - bot.chat(`put ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to put ${amount} ${item.name}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - - async function takeFromFurnace(what: string) { - const fn = { - input: furnace.takeInput, - fuel: furnace.takeFuel, - output: furnace.takeOutput, - }[what] - try { - const item = await fn.call(furnace) - bot.chat(`took ${item.name}`) - } - catch (err) { - bot.chat('unable to take') - } - } + // Setup furnace command handlers... + } + catch (err) { + logger.error('Failed to open furnace', err) + bot.chat('Failed to open furnace') } } - async function watchEnchantmentTable() { - const enchantTableBlock = bot.findBlock({ - matching: ['enchanting_table'].map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - if (!enchantTableBlock) { - bot.chat('no enchantment table found') - return - } - const table = await bot.openEnchantmentTable(enchantTableBlock) - bot.chat(itemToString(table.targetItem())) + return { + openFurnace, + } +} - table.on('updateSlot', (slot, oldItem, newItem) => { - bot.chat(`enchantment table update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - }) - table.on('close', () => { - bot.chat('enchantment table closed') - }) - table.on('ready', () => { - bot.chat(`ready to enchant. choices are ${table.enchantments.map(o => o.level).join(', ')}`) - }) +export function createChestComponent(bot: Bot): ComponentLifecycle { + const logger = useLogg('chest').useGlobalConfig() + logger.log('Loading chest component') - bot.on('chat', onChat) + const utils = createItemUtils(bot) + const containerManager = createContainerManager(bot, logger) + const chestManager = createChestManager(bot, logger, utils) + const furnaceManager = createFurnaceManager(bot, logger, containerManager) - function onChat(username: string, message: string) { - if (username === bot.username) - return - const command = message.split(' ') - switch (true) { - case /^close$/.test(message): - closeEnchantmentTable() - break - case /^put \w+$/.test(message): - // put name - // ex: put diamondsword - putItem(command[1]) - break - case /^add lapis$/.test(message): - addLapis() - break - case /^enchant \d+$/.test(message): - // enchant choice - // ex: enchant 2 - enchantItem(command[1]) - break - case /^take$/.test(message): - takeEnchantedItem() - break - } - - function closeEnchantmentTable() { - table.close() - } - - async function putItem(name: string) { - const item = itemByName(table.window.items(), name) - if (item) { - try { - await table.putTargetItem(item) - bot.chat(`I put ${itemToString(item)}`) - } - catch (err) { - bot.chat(`error putting ${itemToString(item)}`) - } - } - else { - bot.chat(`unknown item ${name}`) - } - } - - async function addLapis() { - const item = itemByType(table.window.items(), ['dye', 'purple_dye', 'lapis_lazuli'].filter(name => bot.registry.itemByName[name] !== undefined) - .map(name => bot.registry.itemByName[name].id)) - if (item) { - try { - await table.putLapis(item) - bot.chat(`I put ${itemToString(item)}`) - } - catch (err) { - bot.chat(`error putting ${itemToString(item)}`) - } - } - else { - bot.chat('I don\'t have any lapis') - } - } - - async function enchantItem(choice: string) { - choice = Number.parseInt(choice, 10) - try { - const item = await table.enchant(choice) - bot.chat(`enchanted ${itemToString(item)}`) - } - catch (err) { - bot.chat('error enchanting') - } - } - - async function takeEnchantedItem() { - try { - const item = await table.takeTargetItem() - bot.chat(`got ${itemToString(item)}`) - } - catch (err) { - bot.chat('error getting item') - } - } - } - } - - function useInvsee(username: string, showEquipment: boolean) { - bot.once('windowOpen', (window) => { - const count = window.containerItems().length - const what = showEquipment ? 'equipment' : 'inventory items' - if (count) { - bot.chat(`${username}'s ${what}:`) - sayItems(window.containerItems()) - } - else { - bot.chat(`${username} has no ${what}`) - } - }) - if (showEquipment) { - // any extra parameter triggers the easter egg - // and shows the other player's equipment - bot.chat(`/invsee ${username} 1`) - } - else { - bot.chat(`/invsee ${username}`) - } - } + // Main chat handler + const onChat = async (username: string, message: string): Promise => { + if (username === bot.username) + return - function itemToString(item: Item | null): string { - if (item) { - return `${item.name} x ${item.count}` + switch (true) { + case /^list$/.test(message): + utils.listItems() + break + case /^chest$/.test(message): + await chestManager.openChest(false) + break + case /^chestminecart$/.test(message): + await chestManager.openChest(true) + break + case /^furnace$/.test(message): + await furnaceManager.openFurnace() + break } - else { - return '(nothing)' - } } - function itemByType(items: Item[], type: number): Item | null { - let item: Item | null = null - let i: number - for (i = 0; i < items.length; ++i) { - item = items[i] - if (item && item.type === type) - return item - } - return null - } - - function itemByName(items: Item[], name: string): Item | null { - let item: Item | null = null - let i: number - for (i = 0; i < items.length; ++i) { - item = items[i] - if (item && item.name === name) - return item - } - return null + // Setup event listeners + bot.on('chat', onChat) + bot.on('experience', () => { + bot.chat(`I am level ${bot.experience.level}`) + }) + + // Cleanup function + return { + cleanup: () => { + bot.removeListener('chat', onChat) + logger.log('Chest component cleaned up') + }, } } diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index 2c46015c3..7d8f5aba5 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -1,10 +1,10 @@ import type { Bot } from 'mineflayer' -import type { ComponentContext } from '../bot' +import type { ComponentLifecycle } from '../bot' import { useLogg } from '@guiiai/logg' const logger = useLogg('echo').useGlobalConfig() -export function createEchoComponent(botInstance: Bot): ComponentContext { +export function createEchoComponent(botInstance: Bot): ComponentLifecycle { const onChat = (username: string, message: string) => { if (username === botInstance.username) return diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts new file mode 100644 index 000000000..063697782 --- /dev/null +++ b/services/minecraft/src/components/follow.ts @@ -0,0 +1,87 @@ +import type { Bot } from 'mineflayer' +import type { ComponentLifecycle } from '../bot' +import { useLogg } from '@guiiai/logg' +import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' + +interface Context { + fromUsername?: string + fromEntity?: any + fromMessage?: string + + isBot: () => boolean + isCommand: () => boolean +} + +function newContext(botInstance: Bot, message: string, sender: string, entity: any): Context { + return { + isBot: () => sender === botInstance.username, + isCommand: () => message.startsWith('#'), + } +} + +export function createFollowComponent(botInstance: Bot): ComponentLifecycle { + const RANGE_GOAL = 2 // get within this radius of the player + + const logger = useLogg('follow').useGlobalConfig() + logger.log('Loading follow plugin') + + botInstance.loadPlugin(pathfinder) + + let defaultMove: Movements + let following: string | null = null + + const followPlayer = () => { + if (!following) + return + + const target = botInstance.players[following]?.entity + if (!target) { + botInstance.chat('I lost sight of you!') + following = null + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + botInstance.pathfinder.setMovements(defaultMove) + botInstance.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + } + + const onChat = (username: string, message: string) => { + if (username === botInstance.username) + return + + if (message === 'follow') { + following = username + logger.withFields({ username }).log('Starting to follow player') + followPlayer() + } + else if (message === 'stop') { + following = null + logger.log('Stopping follow') + botInstance.pathfinder.stop() + } + } + + botInstance.once('spawn', () => { + logger.log('Spawning bot') + defaultMove = new Movements(botInstance) + botInstance.on('chat', onChat) + + // Continuously update path to follow player + const followInterval = setInterval(() => { + if (following) + followPlayer() + }, 1000) + + botInstance.once('end', () => { + clearInterval(followInterval) + }) + }) + + return { + cleanup: () => { + botInstance.removeListener('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/patchfinder.ts index be509e129..6468da80b 100644 --- a/services/minecraft/src/components/patchfinder.ts +++ b/services/minecraft/src/components/patchfinder.ts @@ -1,11 +1,11 @@ // This is an example that uses mineflayer-pathfinder to showcase how simple it is to walk to goals import type { Bot } from 'mineflayer' -import type { ComponentContext } from '../bot' +import type { ComponentLifecycle } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -export function createPathFinderComponent(botInstance: Bot): ComponentContext { +export function createPathFinderComponent(botInstance: Bot): ComponentLifecycle { const RANGE_GOAL = 1 // get within this radius of the player const logger = useLogg('pathfinder').useGlobalConfig() diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 330691550..737fc5778 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,7 +1,10 @@ import process from 'node:process' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' -import { useBot } from './bot' + +import { createBot, useBot } from './bot' +import { createChestComponent } from './components/chest' import { createEchoComponent } from './components/echo' +import { createFollowComponent } from './components/follow' import { createPathFinderComponent } from './components/patchfinder' import { defaultConfig } from './config' @@ -12,11 +15,13 @@ async function main() { setGlobalLogLevel(LogLevel.Debug) setGlobalFormat(Format.Pretty) + createBot(defaultConfig) const bot = useBot() - bot.createBot(defaultConfig) bot.registerComponent('echo', createEchoComponent) bot.registerComponent('pathfinder', createPathFinderComponent) + bot.registerComponent('chest', createChestComponent) + bot.registerComponent('follow', createFollowComponent) process.on('SIGINT', () => { bot.cleanup() From abee6dac20a1b5aa74971700adf9315ed45ea6b6 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 5 Jan 2025 19:29:45 +0800 Subject: [PATCH 04/77] feat: load env --- services/minecraft/src/config.ts | 39 ++++++++++++++++++++++++++++---- services/minecraft/src/main.ts | 18 ++++++++------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/services/minecraft/src/config.ts b/services/minecraft/src/config.ts index f867216c0..ec95daa8c 100644 --- a/services/minecraft/src/config.ts +++ b/services/minecraft/src/config.ts @@ -1,7 +1,38 @@ import type { BotOptions } from 'mineflayer' +import process from 'node:process' +import { useLogg } from '@guiiai/logg' +import { configDotenv } from 'dotenv' -export const defaultConfig: BotOptions = { - host: 'localhost', - username: 'airi', - port: 49415, +const logger = useLogg('config').useGlobalConfig() + +interface OpenAIConfig { + apiKey: string + baseUrl: string +} + +export const botConfig: BotOptions = { + username: '', + host: '', + port: 0, + password: '', +} + +export const openaiConfig: OpenAIConfig = { + apiKey: '', + baseUrl: '', +} + +export function initEnv() { + logger.log('Initializing environment variables') + + configDotenv({ path: '.env.local' }) + openaiConfig.apiKey = process.env.OPENAI_API_KEY || '' + openaiConfig.baseUrl = process.env.OPENAI_API_BASEURL || '' + + botConfig.username = process.env.BOT_USERNAME || '' + botConfig.host = process.env.BOT_HOSTNAME || '' + botConfig.port = Number.parseInt(process.env.BOT_PORT || '49415') + botConfig.password = process.env.BOT_PASSWORD || '' + + logger.withFields({ openaiConfig }).log('Environment variables initialized') } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 737fc5778..981ec6fc1 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -6,7 +6,7 @@ import { createChestComponent } from './components/chest' import { createEchoComponent } from './components/echo' import { createFollowComponent } from './components/follow' import { createPathFinderComponent } from './components/patchfinder' -import { defaultConfig } from './config' +import { botConfig, initEnv } from './config' const logger = useLogg('main').useGlobalConfig() @@ -15,16 +15,18 @@ async function main() { setGlobalLogLevel(LogLevel.Debug) setGlobalFormat(Format.Pretty) - createBot(defaultConfig) - const bot = useBot() + initEnv() - bot.registerComponent('echo', createEchoComponent) - bot.registerComponent('pathfinder', createPathFinderComponent) - bot.registerComponent('chest', createChestComponent) - bot.registerComponent('follow', createFollowComponent) + createBot(botConfig) + const { cleanup, registerComponent } = useBot() + + registerComponent('echo', createEchoComponent) + registerComponent('pathfinder', createPathFinderComponent) + registerComponent('chest', createChestComponent) + registerComponent('follow', createFollowComponent) process.on('SIGINT', () => { - bot.cleanup() + cleanup() process.exit(0) }) } From 52508aba5ffcd83d85db5353ad1519f50cb5175d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 5 Jan 2025 23:04:17 +0800 Subject: [PATCH 05/77] chore: rename bot --- services/minecraft/src/components/echo.ts | 10 ++--- services/minecraft/src/components/follow.ts | 43 ++++++------------- .../minecraft/src/components/patchfinder.ts | 23 +++++----- 3 files changed, 29 insertions(+), 47 deletions(-) diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index 7d8f5aba5..c66c77206 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -4,20 +4,20 @@ import { useLogg } from '@guiiai/logg' const logger = useLogg('echo').useGlobalConfig() -export function createEchoComponent(botInstance: Bot): ComponentLifecycle { +export function createEchoComponent(bot: Bot): ComponentLifecycle { const onChat = (username: string, message: string) => { - if (username === botInstance.username) + if (username === bot.username) return logger.withFields({ username, message }).log('Chat message received') - botInstance.chat(message) + bot.chat(message) } - botInstance.on('chat', onChat) + bot.on('chat', onChat) return { cleanup: () => { - botInstance.removeListener('chat', onChat) + bot.removeListener('chat', onChat) }, } } diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 063697782..8b6315e8f 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -3,29 +3,13 @@ import type { ComponentLifecycle } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -interface Context { - fromUsername?: string - fromEntity?: any - fromMessage?: string - - isBot: () => boolean - isCommand: () => boolean -} - -function newContext(botInstance: Bot, message: string, sender: string, entity: any): Context { - return { - isBot: () => sender === botInstance.username, - isCommand: () => message.startsWith('#'), - } -} - -export function createFollowComponent(botInstance: Bot): ComponentLifecycle { +export function createFollowComponent(bot: Bot): ComponentLifecycle { const RANGE_GOAL = 2 // get within this radius of the player const logger = useLogg('follow').useGlobalConfig() logger.log('Loading follow plugin') - botInstance.loadPlugin(pathfinder) + bot.loadPlugin(pathfinder) let defaultMove: Movements let following: string | null = null @@ -34,21 +18,21 @@ export function createFollowComponent(botInstance: Bot): ComponentLifecycle { if (!following) return - const target = botInstance.players[following]?.entity + const target = bot.players[following]?.entity if (!target) { - botInstance.chat('I lost sight of you!') + bot.chat('I lost sight of you!') following = null return } const { x: playerX, y: playerY, z: playerZ } = target.position - botInstance.pathfinder.setMovements(defaultMove) - botInstance.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + bot.pathfinder.setMovements(defaultMove) + bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } const onChat = (username: string, message: string) => { - if (username === botInstance.username) + if (username === bot.username) return if (message === 'follow') { @@ -59,14 +43,13 @@ export function createFollowComponent(botInstance: Bot): ComponentLifecycle { else if (message === 'stop') { following = null logger.log('Stopping follow') - botInstance.pathfinder.stop() + bot.pathfinder.stop() } } - botInstance.once('spawn', () => { - logger.log('Spawning bot') - defaultMove = new Movements(botInstance) - botInstance.on('chat', onChat) + bot.once('spawn', () => { + defaultMove = new Movements(bot) + bot.on('chat', onChat) // Continuously update path to follow player const followInterval = setInterval(() => { @@ -74,14 +57,14 @@ export function createFollowComponent(botInstance: Bot): ComponentLifecycle { followPlayer() }, 1000) - botInstance.once('end', () => { + bot.once('end', () => { clearInterval(followInterval) }) }) return { cleanup: () => { - botInstance.removeListener('chat', onChat) + bot.removeListener('chat', onChat) }, } } diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/patchfinder.ts index 6468da80b..f65d4bf75 100644 --- a/services/minecraft/src/components/patchfinder.ts +++ b/services/minecraft/src/components/patchfinder.ts @@ -5,44 +5,43 @@ import type { ComponentLifecycle } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -export function createPathFinderComponent(botInstance: Bot): ComponentLifecycle { +export function createPathFinderComponent(bot: Bot): ComponentLifecycle { const RANGE_GOAL = 1 // get within this radius of the player const logger = useLogg('pathfinder').useGlobalConfig() logger.log('Loading pathfinder plugin') - botInstance.loadPlugin(pathfinder) + bot.loadPlugin(pathfinder) let defaultMove: Movements const onChat = (username: string, message: string) => { - if (username === botInstance.username) + if (username === bot.username) return if (message !== 'come') return logger.withFields({ username, message }).log('Chat message received') - const target = botInstance.players[username]?.entity + const target = bot.players[username]?.entity if (!target) { - botInstance.chat('I don\'t see you !') + bot.chat('I don\'t see you !') return } const { x: playerX, y: playerY, z: playerZ } = target.position - botInstance.pathfinder.setMovements(defaultMove) - botInstance.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + bot.pathfinder.setMovements(defaultMove) + bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } - botInstance.once('spawn', () => { - logger.log('Spawning bot') - defaultMove = new Movements(botInstance) - botInstance.on('chat', onChat) + bot.once('spawn', () => { + defaultMove = new Movements(bot) + bot.on('chat', onChat) }) return { cleanup: () => { - botInstance.removeListener('chat', onChat) + bot.removeListener('chat', onChat) }, } } From c505055d50fd327bd9ebce76bf810025b5af4253 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 5 Jan 2025 23:20:45 +0800 Subject: [PATCH 06/77] refactor: ctx --- services/minecraft/src/bot.ts | 54 ++++++++++--------- services/minecraft/src/components/echo.ts | 13 +++-- services/minecraft/src/components/follow.ts | 29 +++++----- .../minecraft/src/components/patchfinder.ts | 25 +++++---- services/minecraft/src/main.ts | 3 +- 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/services/minecraft/src/bot.ts b/services/minecraft/src/bot.ts index 022e16ccc..61997c435 100644 --- a/services/minecraft/src/bot.ts +++ b/services/minecraft/src/bot.ts @@ -3,10 +3,15 @@ import mineflayer, { type Bot, type BotOptions } from 'mineflayer' const logger = useLogg('bot').useGlobalConfig() -let botInstance: Bot | undefined +let ctx: Context | undefined + +export interface Context { + bot: Bot + components: Map +} export interface Component { - (bot: Bot): void + (ctx: Context): ComponentLifecycle } export interface ComponentLifecycle { @@ -15,62 +20,63 @@ export interface ComponentLifecycle { export function createBot(options: BotOptions): Bot { logger.withFields({ options }).log('Creating bot') - botInstance = mineflayer.createBot({ - host: options.host, - port: options.port, - username: options.username, - password: options.password, - }) + ctx = { + bot: mineflayer.createBot({ + host: options.host, + port: options.port, + username: options.username, + password: options.password, + }), + components: new Map(), + } - botInstance.on('error', (err: Error) => { + ctx.bot.on('error', (err: Error) => { logger.errorWithError('Bot error:', err) }) - botInstance.on('kicked', (reason: string) => { + ctx.bot.on('kicked', (reason: string) => { logger.withFields({ reason }).error('Bot was kicked') }) logger.log('Bot created') - return botInstance + return ctx.bot } export function useBot() { - const contexts = new Map() + if (ctx == null || ctx.bot == null) { + throw new Error('Bot instance not found') + } const cleanup = () => { logger.log('Cleaning up bot and components') - contexts.forEach((context: ComponentLifecycle) => context.cleanup?.()) - contexts.clear() - botInstance?.end() + ctx!.components.forEach((context: ComponentLifecycle) => context.cleanup?.()) + ctx!.components.clear() + ctx!.bot.end() } const registerComponent = (componentName: string, component: Component) => { logger.withFields({ componentName }).log('Registering new component') - const context = component(botInstance!) + const context = component(ctx!) if (context != null) - contexts.set(componentName, context) + ctx!.components.set(componentName, context) return context } const listComponents = () => { - return Array.from(contexts.keys()) + return Array.from(ctx!.components.keys()) } const getComponent = (componentName: string) => { - return contexts.get(componentName) - } - - if (!botInstance) { - throw new Error('Bot instance not found') + return ctx!.components.get(componentName) } return { + ctx, registerComponent, listComponents, getComponent, cleanup, - bot: botInstance, } } diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index c66c77206..a6c134d23 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -1,23 +1,22 @@ -import type { Bot } from 'mineflayer' -import type { ComponentLifecycle } from '../bot' +import type { ComponentLifecycle, Context } from '../bot' import { useLogg } from '@guiiai/logg' const logger = useLogg('echo').useGlobalConfig() -export function createEchoComponent(bot: Bot): ComponentLifecycle { +export function createEchoComponent(ctx: Context): ComponentLifecycle { const onChat = (username: string, message: string) => { - if (username === bot.username) + if (username === ctx.bot.username) return logger.withFields({ username, message }).log('Chat message received') - bot.chat(message) + ctx.bot.chat(message) } - bot.on('chat', onChat) + ctx.bot.on('chat', onChat) return { cleanup: () => { - bot.removeListener('chat', onChat) + ctx.bot.removeListener('chat', onChat) }, } } diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 8b6315e8f..d1ffebc22 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -1,15 +1,14 @@ -import type { Bot } from 'mineflayer' -import type { ComponentLifecycle } from '../bot' +import type { ComponentLifecycle, Context } from '../ctx.bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -export function createFollowComponent(bot: Bot): ComponentLifecycle { +export function createFollowComponent(ctx: Context): ComponentLifecycle { const RANGE_GOAL = 2 // get within this radius of the player const logger = useLogg('follow').useGlobalConfig() logger.log('Loading follow plugin') - bot.loadPlugin(pathfinder) + ctx.bot.loadPlugin(pathfinder) let defaultMove: Movements let following: string | null = null @@ -18,21 +17,21 @@ export function createFollowComponent(bot: Bot): ComponentLifecycle { if (!following) return - const target = bot.players[following]?.entity + const target = ctx.bot.players[following]?.entity if (!target) { - bot.chat('I lost sight of you!') + ctx.bot.chat('I lost sight of you!') following = null return } const { x: playerX, y: playerY, z: playerZ } = target.position - bot.pathfinder.setMovements(defaultMove) - bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + ctx.bot.pathfinder.setMovements(defaultMove) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } const onChat = (username: string, message: string) => { - if (username === bot.username) + if (username === ctx.bot.username) return if (message === 'follow') { @@ -43,13 +42,13 @@ export function createFollowComponent(bot: Bot): ComponentLifecycle { else if (message === 'stop') { following = null logger.log('Stopping follow') - bot.pathfinder.stop() + ctx.bot.pathfinder.stop() } } - bot.once('spawn', () => { - defaultMove = new Movements(bot) - bot.on('chat', onChat) + ctx.bot.once('spawn', () => { + defaultMove = new Movements(ctx.bot) + ctx.bot.on('chat', onChat) // Continuously update path to follow player const followInterval = setInterval(() => { @@ -57,14 +56,14 @@ export function createFollowComponent(bot: Bot): ComponentLifecycle { followPlayer() }, 1000) - bot.once('end', () => { + ctx.bot.once('end', () => { clearInterval(followInterval) }) }) return { cleanup: () => { - bot.removeListener('chat', onChat) + ctx.bot.removeListener('chat', onChat) }, } } diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/patchfinder.ts index f65d4bf75..d5d46027f 100644 --- a/services/minecraft/src/components/patchfinder.ts +++ b/services/minecraft/src/components/patchfinder.ts @@ -1,47 +1,46 @@ // This is an example that uses mineflayer-pathfinder to showcase how simple it is to walk to goals -import type { Bot } from 'mineflayer' -import type { ComponentLifecycle } from '../bot' +import type { ComponentLifecycle, Context } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -export function createPathFinderComponent(bot: Bot): ComponentLifecycle { +export function createPathFinderComponent(ctx: Context): ComponentLifecycle { const RANGE_GOAL = 1 // get within this radius of the player const logger = useLogg('pathfinder').useGlobalConfig() logger.log('Loading pathfinder plugin') - bot.loadPlugin(pathfinder) + ctx.bot.loadPlugin(pathfinder) let defaultMove: Movements const onChat = (username: string, message: string) => { - if (username === bot.username) + if (username === ctx.bot.username) return if (message !== 'come') return logger.withFields({ username, message }).log('Chat message received') - const target = bot.players[username]?.entity + const target = ctx.bot.players[username]?.entity if (!target) { - bot.chat('I don\'t see you !') + ctx.bot.chat('I don\'t see you !') return } const { x: playerX, y: playerY, z: playerZ } = target.position - bot.pathfinder.setMovements(defaultMove) - bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + ctx.bot.pathfinder.setMovements(defaultMove) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } - bot.once('spawn', () => { - defaultMove = new Movements(bot) - bot.on('chat', onChat) + ctx.bot.once('spawn', () => { + defaultMove = new Movements(ctx.bot) + ctx.bot.on('chat', onChat) }) return { cleanup: () => { - bot.removeListener('chat', onChat) + ctx.bot.removeListener('chat', onChat) }, } } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 981ec6fc1..57d99dfb6 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -2,7 +2,6 @@ import process from 'node:process' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' import { createBot, useBot } from './bot' -import { createChestComponent } from './components/chest' import { createEchoComponent } from './components/echo' import { createFollowComponent } from './components/follow' import { createPathFinderComponent } from './components/patchfinder' @@ -22,7 +21,7 @@ async function main() { registerComponent('echo', createEchoComponent) registerComponent('pathfinder', createPathFinderComponent) - registerComponent('chest', createChestComponent) + // registerComponent('chest', createChestComponent) registerComponent('follow', createFollowComponent) process.on('SIGINT', () => { From 8995c8108300dd7da8f76b0df424b2c801c36605 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 15:34:43 +0800 Subject: [PATCH 07/77] feat: mcdata --- services/minecraft/src/config.ts | 2 + services/minecraft/src/helper.ts | 3 - services/minecraft/src/main.ts | 1 - services/minecraft/src/utils/mcdata.ts | 327 +++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 4 deletions(-) delete mode 100644 services/minecraft/src/helper.ts create mode 100644 services/minecraft/src/utils/mcdata.ts diff --git a/services/minecraft/src/config.ts b/services/minecraft/src/config.ts index ec95daa8c..0b02f0acb 100644 --- a/services/minecraft/src/config.ts +++ b/services/minecraft/src/config.ts @@ -15,6 +15,7 @@ export const botConfig: BotOptions = { host: '', port: 0, password: '', + version: '1.20', } export const openaiConfig: OpenAIConfig = { @@ -33,6 +34,7 @@ export function initEnv() { botConfig.host = process.env.BOT_HOSTNAME || '' botConfig.port = Number.parseInt(process.env.BOT_PORT || '49415') botConfig.password = process.env.BOT_PASSWORD || '' + botConfig.version = process.env.BOT_VERSION || '1.20' logger.withFields({ openaiConfig }).log('Environment variables initialized') } diff --git a/services/minecraft/src/helper.ts b/services/minecraft/src/helper.ts deleted file mode 100644 index cbacad03e..000000000 --- a/services/minecraft/src/helper.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 57d99dfb6..1236e9b45 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -10,7 +10,6 @@ import { botConfig, initEnv } from './config' const logger = useLogg('main').useGlobalConfig() async function main() { - // await sleep(5000) setGlobalLogLevel(LogLevel.Debug) setGlobalFormat(Format.Pretty) diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts new file mode 100644 index 000000000..8128ec7a1 --- /dev/null +++ b/services/minecraft/src/utils/mcdata.ts @@ -0,0 +1,327 @@ +/** + * @source https://github.com/kolbytn/mindcraft + */ +import type { Bot } from 'mineflayer' +import minecraftData from 'minecraft-data' +import { createBot } from 'mineflayer' +import armorManager from 'mineflayer-armor-manager' +import { loader as autoEat } from 'mineflayer-auto-eat' +import { plugin as collectblock } from 'mineflayer-collectblock' +import { pathfinder } from 'mineflayer-pathfinder' +import { plugin as pvp } from 'mineflayer-pvp' +import prismarine_items from 'prismarine-item' +import { botConfig } from '../config' + +const mc_version = botConfig.version! +const mcdata = minecraftData(mc_version) +const Item = prismarine_items(mc_version) + +interface Recipe { + inShape?: Array> + ingredients?: Array<{ id: number, count: number }> +} + +export const WOOD_TYPES: string[] = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak'] +export const MATCHING_WOOD_BLOCKS: string[] = [ + 'log', + 'planks', + 'sign', + 'boat', + 'fence_gate', + 'door', + 'fence', + 'slab', + 'stairs', + 'button', + 'pressure_plate', + 'trapdoor', +] +export const WOOL_COLORS: string[] = [ + 'white', + 'orange', + 'magenta', + 'light_blue', + 'yellow', + 'lime', + 'pink', + 'gray', + 'light_gray', + 'cyan', + 'purple', + 'blue', + 'brown', + 'green', + 'red', + 'black', +] + +export function initBot(username: string): Bot { + const bot = createBot({ + username, + + host: botConfig.host, + port: botConfig.port, + auth: 'offline', + + version: mc_version, + }) + bot.loadPlugin(pathfinder) + bot.loadPlugin(pvp) + bot.loadPlugin(collectblock) + bot.loadPlugin(autoEat) + bot.loadPlugin(armorManager) // auto equip armor + bot.once('resourcePack', () => { + bot.acceptResourcePack() + }) + + return bot +} + +export function isHuntable(mob: { name?: string, metadata: any[] }): boolean { + if (!mob || !mob.name) + return false + const animals = ['chicken', 'cow', 'llama', 'mooshroom', 'pig', 'rabbit', 'sheep'] + return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16] // metadata 16 is not baby +} + +export function isHostile(mob: { name?: string, type?: string }): boolean { + if (!mob || !mob.name) + return false + return (mob.type === 'mob' || mob.type === 'hostile') && mob.name !== 'iron_golem' && mob.name !== 'snow_golem' +} + +export function getItemId(itemName: string): number | null { + const item = mcdata.itemsByName[itemName] + if (item) { + return item.id + } + return null +} + +export function getItemName(itemId: number): string | null { + const item = mcdata.items[itemId] + if (item) { + return item.name + } + return null +} + +export function getBlockId(blockName: string): number | null { + const block = mcdata.blocksByName[blockName] + if (block) { + return block.id + } + return null +} + +export function getBlockName(blockId: number): string | null { + const block = mcdata.blocks[blockId] + if (block) { + return block.name + } + return null +} + +export function getAllItems(ignore: string[] = []): any[] { + const items = [] + for (const itemId in mcdata.items) { + const item = mcdata.items[itemId] + if (!ignore.includes(item.name)) { + items.push(item) + } + } + return items +} + +export function getAllItemIds(ignore: string[] = []): number[] { + const items = getAllItems(ignore) + const itemIds = [] + for (const item of items) { + itemIds.push(item.id) + } + return itemIds +} + +export function getAllBlocks(ignore: string[] = []): any[] { + const blocks = [] + for (const blockId in mcdata.blocks) { + const block = mcdata.blocks[blockId] + if (!ignore.includes(block.name)) { + blocks.push(block) + } + } + return blocks +} + +export function getAllBlockIds(ignore: string[] = []): number[] { + const blocks = getAllBlocks(ignore) + const blockIds = [] + for (const block of blocks) { + blockIds.push(block.id) + } + return blockIds +} + +export function getAllBiomes(): any { + return mcdata.biomes +} + +export function getItemCraftingRecipes(itemName: string): Record[] | null { + const itemId = getItemId(itemName) + if (!itemId || !mcdata.recipes[itemId]) { + return null + } + + const recipes: Record[] = [] + for (const r of mcdata.recipes[itemId]) { + const recipe: Record = {} + let ingredients = [] + if (r.ingredients) { + ingredients = r.ingredients + } + else if (r.inShape) { + ingredients = r.inShape.flat() + } + for (const ingredient of ingredients) { + const ingredientName = getItemName(ingredient) + if (ingredientName === null) + continue + if (!recipe[ingredientName]) + recipe[ingredientName] = 0 + recipe[ingredientName]++ + } + recipes.push(recipe) + } + + return recipes +} + +export function isSmeltable(itemName: string): boolean { + const misc_smeltables = ['beef', 'chicken', 'cod', 'mutton', 'porkchop', 'rabbit', 'salmon', 'tropical_fish', 'potato', 'kelp', 'sand', 'cobblestone', 'clay_ball'] + return itemName.includes('raw') || itemName.includes('log') || misc_smeltables.includes(itemName) +} + +export function getSmeltingFuel(bot: Bot): any { + let fuel = bot.inventory.items().find(i => i.name === 'coal' || i.name === 'charcoal') + if (fuel) + return fuel + fuel = bot.inventory.items().find(i => i.name.includes('log') || i.name.includes('planks')) + if (fuel) + return fuel + return bot.inventory.items().find(i => i.name === 'coal_block' || i.name === 'lava_bucket') +} + +export function getFuelSmeltOutput(fuelName: string): number { + if (fuelName === 'coal' || fuelName === 'charcoal') + return 8 + if (fuelName.includes('log') || fuelName.includes('planks')) + return 1.5 + if (fuelName === 'coal_block') + return 80 + if (fuelName === 'lava_bucket') + return 100 + return 0 +} + +export function getItemSmeltingIngredient(itemName: string): string | undefined { + return { + baked_potato: 'potato', + steak: 'raw_beef', + cooked_chicken: 'raw_chicken', + cooked_cod: 'raw_cod', + cooked_mutton: 'raw_mutton', + cooked_porkchop: 'raw_porkchop', + cooked_rabbit: 'raw_rabbit', + cooked_salmon: 'raw_salmon', + dried_kelp: 'kelp', + iron_ingot: 'raw_iron', + gold_ingot: 'raw_gold', + copper_ingot: 'raw_copper', + glass: 'sand', + }[itemName] +} + +export function getItemBlockSources(itemName: string): string[] { + const itemId = getItemId(itemName) + const sources: string[] = [] + for (const block of getAllBlocks()) { + if (block.drops.includes(itemId)) { + sources.push(block.name) + } + } + return sources +} + +export function getItemAnimalSource(itemName: string): string | undefined { + return { + raw_beef: 'cow', + raw_chicken: 'chicken', + raw_cod: 'cod', + raw_mutton: 'sheep', + raw_porkchop: 'pig', + raw_rabbit: 'rabbit', + raw_salmon: 'salmon', + leather: 'cow', + wool: 'sheep', + }[itemName] +} + +export function getBlockTool(blockName: string): string | null { + const block = mcdata.blocksByName[blockName] + if (!block || !block.harvestTools) { + return null + } + return getItemName(Object.keys(block.harvestTools)[0]) // Double check first tool is always simplest +} + +export function makeItem(name: string, amount: number = 1): any { + return new Item(getItemId(name), amount) +} + +export function ingredientsFromPrismarineRecipe(recipe: Recipe): Record { + const requiredIngredients: Record = {} + if (recipe.inShape) { + for (const ingredient of recipe.inShape.flat()) { + if (ingredient.id < 0) + continue // prismarine-recipe uses id -1 as an empty crafting slot + const ingredientName = getItemName(ingredient.id) + if (ingredientName) { + requiredIngredients[ingredientName] ??= 0 + requiredIngredients[ingredientName] += ingredient.count + } + } + } + if (recipe.ingredients) { + for (const ingredient of recipe.ingredients) { + if (ingredient.id < 0) + continue + const ingredientName = getItemName(ingredient.id) + if (ingredientName) { + requiredIngredients[ingredientName] ??= 0 + requiredIngredients[ingredientName] -= ingredient.count + } + // Yes, the `-=` is intended. + // prismarine-recipe uses positive numbers for the shaped ingredients but negative for unshaped. + // Why this is the case is beyond my understanding. + } + } + return requiredIngredients +} + +export function calculateLimitingResource( + availableItems: Record, + requiredItems: Record, + discrete: boolean = true, +): { num: number, limitingResource: T | null } { + let limitingResource: T | null = null + let num = Infinity + for (const itemType in requiredItems) { + if (availableItems[itemType] < requiredItems[itemType] * num) { + limitingResource = itemType + num = availableItems[itemType] / requiredItems[itemType] + } + } + if (discrete) + num = Math.floor(num) + return { num, limitingResource } +} From 5dbf7669bacbf7ecfb833956e99ec29884aa9e34 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 15:51:30 +0800 Subject: [PATCH 08/77] feat: middleware --- services/minecraft/src/components/chest.ts | 263 ------------------ services/minecraft/src/components/echo.ts | 8 +- services/minecraft/src/components/follow.ts | 7 +- .../minecraft/src/components/patchfinder.ts | 9 +- services/minecraft/src/middlewares/chat.ts | 30 ++ 5 files changed, 40 insertions(+), 277 deletions(-) delete mode 100644 services/minecraft/src/components/chest.ts create mode 100644 services/minecraft/src/middlewares/chat.ts diff --git a/services/minecraft/src/components/chest.ts b/services/minecraft/src/components/chest.ts deleted file mode 100644 index 4ae3b5ad6..000000000 --- a/services/minecraft/src/components/chest.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Bot } from 'mineflayer' -import type { Item } from 'prismarine-item' -import type { Window } from 'prismarine-windows' -import type { ComponentLifecycle } from '../bot' -import { useLogg } from '@guiiai/logg' - -const VALID_CHEST_TYPES = ['chest', 'ender_chest', 'trapped_chest'] as const -const VALID_FURNACE_TYPES = ['furnace', 'lit_furnace'] as const -const VALID_ENCHANT_TYPES = ['enchanting_table'] as const - -interface ContainerContext { - window: Window - removeListeners: () => void -} - -// Utility functions -function createItemUtils(bot: Bot) { - const itemToString = (item: Item | null): string => { - if (item) - return `${item.name} x ${item.count}` - return '(nothing)' - } - - const findItemByName = (items: Item[], name: string): Item | null => - items.find(item => item?.name === name) || null - - const findItemByType = (items: Item[], type: number): Item | null => - items.find(item => item?.type === type) || null - - const listItems = (items = bot.inventory.items()): void => { - const output = items.map(itemToString).join(', ') - bot.chat(output || 'empty') - } - - return { - itemToString, - findItemByName, - findItemByType, - listItems, - } -} - -// Container management -function createContainerManager(bot: Bot, logger: ReturnType) { - const setupContainerListeners = ( - window: Window, - onChat: (username: string, message: string) => void, - type = 'container', - ): () => void => { - const updateHandler = (slot: number, oldItem: Item, newItem: Item) => { - bot.chat(`${type} update: ${itemToString(oldItem)} -> ${itemToString(newItem)} (slot: ${slot})`) - } - - const closeHandler = () => { - bot.chat(`${type} closed`) - } - - window.on('updateSlot', updateHandler) - window.on('close', closeHandler) - bot.on('chat', onChat) - - return () => { - window.removeListener('updateSlot', updateHandler) - window.removeListener('close', closeHandler) - bot.removeListener('chat', onChat) - } - } - - const findNearbyBlock = (types: readonly string[], maxDistance = 6): ReturnType => { - return bot.findBlock({ - matching: types - .filter(name => bot.registry.blocksByName[name] !== undefined) - .map(name => bot.registry.blocksByName[name].id), - maxDistance, - }) - } - - return { - setupContainerListeners, - findNearbyBlock, - } -} - -// Chest functions -function createChestManager(bot: Bot, logger: ReturnType, utils: ReturnType) { - const { itemToString, findItemByName, listItems } = utils - - const handleChestCommands = async (window: Window, command: string[]): Promise => { - switch (true) { - case /^close$/.test(command[0]): { - window.close() - break - } - case /^withdraw \d+ \w+$/.test(command.join(' ')): { - const amount = Number.parseInt(command[1], 10) - const name = command[2] - const item = findItemByName(window.containerItems(), name) - - if (!item) { - bot.chat(`unknown item ${name}`) - return - } - - try { - await window.withdraw(item.type, null, amount) - bot.chat(`withdrew ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to withdraw ${amount} ${item.name}`) - } - break - } - case /^deposit \d+ \w+$/.test(command.join(' ')): { - const amount = Number.parseInt(command[1], 10) - const name = command[2] - const item = findItemByName(window.items(), name) - - if (!item) { - bot.chat(`unknown item ${name}`) - return - } - - try { - await window.deposit(item.type, null, amount) - bot.chat(`deposited ${amount} ${item.name}`) - } - catch (err) { - bot.chat(`unable to deposit ${amount} ${item.name}`) - } - break - } - } - } - - const openChest = async (minecart = false): Promise => { - let target - if (minecart) { - target = Object.values(bot.entities) - .find(e => e.entityType === bot.registry.entitiesByName.chest_minecart - && bot.entity.position.distanceTo(e.position) < 3) - - if (!target) { - bot.chat('no chest minecart found') - return null - } - } - else { - target = bot.findBlock({ - matching: VALID_CHEST_TYPES.map(name => bot.registry.blocksByName[name].id), - maxDistance: 6, - }) - - if (!target) { - bot.chat('no chest found') - return null - } - } - - try { - const window = await bot.openContainer(target) - const onChat = (username: string, message: string) => { - if (username === bot.username) - return - handleChestCommands(window, message.split(' ')) - } - - const removeListeners = setupContainerListeners(window, onChat, 'chest') - listItems(window.containerItems()) - - return { window, removeListeners } - } - catch (err) { - logger.error('Failed to open chest', err) - bot.chat('Failed to open chest') - return null - } - } - - return { - openChest, - } -} - -// Furnace functions -function createFurnaceManager(bot: Bot, logger: ReturnType, containerManager: ReturnType) { - const { findNearbyBlock } = containerManager - - const openFurnace = async (): Promise => { - const furnaceBlock = findNearbyBlock(VALID_FURNACE_TYPES) - if (!furnaceBlock) { - bot.chat('no furnace found') - return - } - - try { - const furnace = await bot.openFurnace(furnaceBlock) - let output = '' - output += `input: ${itemToString(furnace.inputItem())}, ` - output += `fuel: ${itemToString(furnace.fuelItem())}, ` - output += `output: ${itemToString(furnace.outputItem())}` - bot.chat(output) - - furnace.on('update', () => { - logger.debug(`fuel: ${Math.round(furnace.fuel * 100)}% progress: ${Math.round(furnace.progress * 100)}%`) - }) - - // Setup furnace command handlers... - } - catch (err) { - logger.error('Failed to open furnace', err) - bot.chat('Failed to open furnace') - } - } - - return { - openFurnace, - } -} - -export function createChestComponent(bot: Bot): ComponentLifecycle { - const logger = useLogg('chest').useGlobalConfig() - logger.log('Loading chest component') - - const utils = createItemUtils(bot) - const containerManager = createContainerManager(bot, logger) - const chestManager = createChestManager(bot, logger, utils) - const furnaceManager = createFurnaceManager(bot, logger, containerManager) - - // Main chat handler - const onChat = async (username: string, message: string): Promise => { - if (username === bot.username) - return - - switch (true) { - case /^list$/.test(message): - utils.listItems() - break - case /^chest$/.test(message): - await chestManager.openChest(false) - break - case /^chestminecart$/.test(message): - await chestManager.openChest(true) - break - case /^furnace$/.test(message): - await furnaceManager.openFurnace() - break - } - } - - // Setup event listeners - bot.on('chat', onChat) - bot.on('experience', () => { - bot.chat(`I am level ${bot.experience.level}`) - }) - - // Cleanup function - return { - cleanup: () => { - bot.removeListener('chat', onChat) - logger.log('Chest component cleaned up') - }, - } -} diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index a6c134d23..191004a5a 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -1,16 +1,14 @@ import type { ComponentLifecycle, Context } from '../bot' import { useLogg } from '@guiiai/logg' +import { formBotChat } from 'src/middlewares/chat' const logger = useLogg('echo').useGlobalConfig() export function createEchoComponent(ctx: Context): ComponentLifecycle { - const onChat = (username: string, message: string) => { - if (username === ctx.bot.username) - return - + const onChat = formBotChat(ctx, (username, message) => { logger.withFields({ username, message }).log('Chat message received') ctx.bot.chat(message) - } + }) ctx.bot.on('chat', onChat) diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index d1ffebc22..3fea63aa9 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -1,6 +1,7 @@ -import type { ComponentLifecycle, Context } from '../ctx.bot' +import type { ComponentLifecycle, Context } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' +import { formBotChat } from 'src/middlewares/chat' export function createFollowComponent(ctx: Context): ComponentLifecycle { const RANGE_GOAL = 2 // get within this radius of the player @@ -30,7 +31,7 @@ export function createFollowComponent(ctx: Context): ComponentLifecycle { ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } - const onChat = (username: string, message: string) => { + const onChat = formBotChat(ctx, (username, message) => { if (username === ctx.bot.username) return @@ -44,7 +45,7 @@ export function createFollowComponent(ctx: Context): ComponentLifecycle { logger.log('Stopping follow') ctx.bot.pathfinder.stop() } - } + }) ctx.bot.once('spawn', () => { defaultMove = new Movements(ctx.bot) diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/patchfinder.ts index d5d46027f..51ee6de38 100644 --- a/services/minecraft/src/components/patchfinder.ts +++ b/services/minecraft/src/components/patchfinder.ts @@ -1,8 +1,7 @@ -// This is an example that uses mineflayer-pathfinder to showcase how simple it is to walk to goals - import type { ComponentLifecycle, Context } from '../bot' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' +import { formBotChat } from 'src/middlewares/chat' export function createPathFinderComponent(ctx: Context): ComponentLifecycle { const RANGE_GOAL = 1 // get within this radius of the player @@ -14,9 +13,7 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle { let defaultMove: Movements - const onChat = (username: string, message: string) => { - if (username === ctx.bot.username) - return + const onChat = formBotChat(ctx, (username, message) => { if (message !== 'come') return @@ -31,7 +28,7 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle { ctx.bot.pathfinder.setMovements(defaultMove) ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) - } + }) ctx.bot.once('spawn', () => { defaultMove = new Movements(ctx.bot) diff --git a/services/minecraft/src/middlewares/chat.ts b/services/minecraft/src/middlewares/chat.ts new file mode 100644 index 000000000..165a0a7f9 --- /dev/null +++ b/services/minecraft/src/middlewares/chat.ts @@ -0,0 +1,30 @@ +import type { Entity } from 'prismarine-entity' +import type { Context } from 'src/bot' + +// TODO: need to be refactored +interface ChatContext { + fromUsername?: string + fromEntity?: Entity + fromMessage?: string + + isBot: () => boolean + isCommand: () => boolean +} + +export function newChatContext(ctx: Context, username: string, message: string): ChatContext { + return { + fromUsername: username, + fromEntity: ctx.bot.entity, + fromMessage: message, + isBot: () => username === ctx.bot.username, + isCommand: () => message.startsWith('#'), + } +} + +export function formBotChat(ctx: Context, cb: (username: string, message: string) => void) { + return (username: string, message: string) => { + if (ctx.bot.username === username) + return + cb(username, message) + } +} From 337117fccb7aaf84a6360e1d9edda9c814ad0218 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 18:32:42 +0800 Subject: [PATCH 09/77] feat: command --- services/minecraft/src/agents/openai.test.ts | 10 + services/minecraft/src/agents/openai.ts | 74 ++++++ services/minecraft/src/agents/queries.ts | 211 ++++++++++++++++++ services/minecraft/src/components/command.ts | 46 ++++ services/minecraft/src/components/echo.ts | 6 +- services/minecraft/src/components/follow.ts | 86 +++---- .../{patchfinder.ts => pathfinder.ts} | 28 +-- services/minecraft/src/components/status.ts | 34 +++ .../minecraft/src/{ => composables}/bot.ts | 31 ++- services/minecraft/src/composables/command.ts | 10 + .../minecraft/src/{ => composables}/config.ts | 0 services/minecraft/src/main.ts | 25 ++- services/minecraft/src/middlewares/chat.ts | 8 +- services/minecraft/src/middlewares/command.ts | 13 ++ services/minecraft/src/prompts/agent.ts | 74 ++++++ services/minecraft/src/utils/mcdata.ts | 2 +- 16 files changed, 580 insertions(+), 78 deletions(-) create mode 100644 services/minecraft/src/agents/openai.test.ts create mode 100644 services/minecraft/src/agents/openai.ts create mode 100644 services/minecraft/src/agents/queries.ts create mode 100644 services/minecraft/src/components/command.ts rename services/minecraft/src/components/{patchfinder.ts => pathfinder.ts} (50%) create mode 100644 services/minecraft/src/components/status.ts rename services/minecraft/src/{ => composables}/bot.ts (73%) create mode 100644 services/minecraft/src/composables/command.ts rename services/minecraft/src/{ => composables}/config.ts (100%) create mode 100644 services/minecraft/src/middlewares/command.ts create mode 100644 services/minecraft/src/prompts/agent.ts diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts new file mode 100644 index 000000000..92271ebb1 --- /dev/null +++ b/services/minecraft/src/agents/openai.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' + +import { initQueryAgent } from './openai' + +describe('openAI agent', () => { + it('should initialize the agent', () => { + const agent = initQueryAgent() + expect(agent).toBeDefined() + }) +}) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts new file mode 100644 index 000000000..6db92d857 --- /dev/null +++ b/services/minecraft/src/agents/openai.ts @@ -0,0 +1,74 @@ +import type { BotContext } from 'src/bot' +import { useLogg } from '@guiiai/logg' +import { tool } from 'xsai' + +import { createQueryAgentBotContext, queryList } from './queries' + +// Types +interface AgentBotContext { + readonly agents: Set> +} + +// State management +const agents = new Set>() + +const logger = useLogg('openai').useGlobalConfig() + +// Agent initialization +// export async function initAgent(): Promise { +// logger.log('Initializing agent') +// let n = neuri() + +// agents.add(initQueryAgent()) + +// agents.forEach(agent => n = n.agent(agent)) + +// return n.build({ +// provider: { +// apiKey: openaiConfig.apiKey, +// baseURL: openaiConfig.baseUrl, +// }, +// }) +// } + +// export async function initQueryAgent(): Promise { +// logger.log('Initializing query agent') +// let queryAgent = agent('query') + +// queryList.forEach((query) => { +// queryAgent = queryAgent.tool( +// query.name, +// query.schema, +// query.perform, +// { description: query.description }, +// ) +// }) + +// return queryAgent.build() +// } + +export async function initAgent(ctx: BotContext) { + logger.log('Initializing agent') + + initQueryAgent(ctx) +} + +export function initQueryAgent(ctx: BotContext) { + logger.log('Initializing query agent') + const agentBotContext = createQueryAgentBotContext(ctx.bot) + + const tools = [] + + for (const query of queryList) { + tools.push( + tool({ + name: query.name, + description: query.description, + execute: query.perform(agentBotContext), + parameters: query.schema as never, + }), + ) + } + + return tools +} diff --git a/services/minecraft/src/agents/queries.ts b/services/minecraft/src/agents/queries.ts new file mode 100644 index 000000000..26db8c7a3 --- /dev/null +++ b/services/minecraft/src/agents/queries.ts @@ -0,0 +1,211 @@ +import type { Bot } from 'mineflayer' +import { z } from 'zod' + +// Core types +type QueryResult = string | Promise + +// BotContext management +let ctx: QueryBotContext + +export function initQueryBotContext(BotContext: QueryBotContext): void { + ctx = BotContext +} + +interface QueryBotContext { + world: { + getBiomeName: (bot: Bot) => string + getNearbyPlayerNames: (bot: Bot) => string[] + getInventoryCounts: (bot: Bot) => Record + getNearbyBlockTypes: (bot: Bot) => string[] + getCraftableItems: (bot: Bot) => string[] + getNearbyEntityTypes: (bot: Bot) => string[] + } + convoManager: { + getInGameAgents: () => string[] + } +} + +interface QueryAgentBotContext { + bot: Bot + name: string + actions: { + currentActionLabel: string + } + isIdle: () => boolean + memory_bank: { + getKeys: () => string[] + } +} + +export function createQueryAgentBotContext(bot: Bot): QueryAgentBotContext { + return { + bot, + name: bot.username, + actions: { + currentActionLabel: bot.actions.currentActionLabel, + }, + isIdle: () => bot.actions.isIdle(), + memory_bank: { + getKeys: () => bot.memory_bank.getKeys(), + }, + } +} + +interface Query { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (agent: QueryAgentBotContext) => () => QueryResult +} + +// Utils +const pad = (str: string): string => `\n${str}\n` + +function formatInventoryItem(item: string, count: number): string { + return count > 0 ? `\n- ${item}: ${count}` : '' +} + +function formatWearingItem(slot: string, item: string | undefined): string { + return item ? `\n${slot}: ${item}` : '' +} + +// Query implementations +function createStatsQuery(): Query { + return { + name: '!stats', + description: 'Get your bot\'s location, health, hunger, and time of day.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => { + const { bot } = agent + const pos = bot.entity.position + const weather = bot.rainState > 0 ? 'Rain' : bot.thunderState > 0 ? 'Thunderstorm' : 'Clear' + const timeOfDay = bot.time.timeOfDay < 6000 + ? 'Morning' + : bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + const action = agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel + + const players = ctx.world.getNearbyPlayerNames(bot) + .filter(p => !ctx.convoManager.getInGameAgents().includes(p)) + const bots = ctx.convoManager.getInGameAgents() + .filter(b => b !== agent.name) + + return pad(`STATS +- Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)} +- Gamemode: ${bot.game.gameMode} +- Health: ${Math.round(bot.health)} / 20 +- Hunger: ${Math.round(bot.food)} / 20 +- Biome: ${ctx.world.getBiomeName(bot)} +- Weather: ${weather} +- Time: ${timeOfDay} +- Current Action: ${action} +- Nearby Human Players: ${players.length > 0 ? players.join(', ') : 'None.'} +- Nearby Bot Players: ${bots.length > 0 ? bots.join(', ') : 'None.'} +${bot.modes.getMiniDocs()}`) + }, + } +} + +function createInventoryQuery(): Query { + return { + name: '!inventory', + description: 'Get your bot\'s inventory.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => { + const { bot } = agent + const inventory = ctx.world.getInventoryCounts(bot) + const items = Object.entries(inventory) + .map(([item, count]) => formatInventoryItem(item, count)) + .join('') + + const wearing = [ + formatWearingItem('Head', bot.inventory.slots[5]?.name), + formatWearingItem('Torso', bot.inventory.slots[6]?.name), + formatWearingItem('Legs', bot.inventory.slots[7]?.name), + formatWearingItem('Feet', bot.inventory.slots[8]?.name), + ].filter(Boolean).join('') + + return pad(`INVENTORY${items || ': Nothing'} +${agent.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} +WEARING: ${wearing || 'Nothing'}`) + }, + } +} + +function createNearbyBlocksQuery(): Query { + return { + name: '!nearbyBlocks', + description: 'Get the blocks near the bot.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => { + const blocks = ctx.world.getNearbyBlockTypes(agent.bot) + return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`) + }, + } +} + +function createCraftableQuery(): Query { + return { + name: '!craftable', + description: 'Get the craftable items with the bot\'s inventory.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => { + const craftable = ctx.world.getCraftableItems(agent.bot) + return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`) + }, + } +} + +function createEntitiesQuery(): Query { + return { + name: '!entities', + description: 'Get the nearby players and entities.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => { + const { bot } = agent + const players = ctx.world.getNearbyPlayerNames(bot) + .filter(p => !ctx.convoManager.getInGameAgents().includes(p)) + const bots = ctx.convoManager.getInGameAgents() + .filter(b => b !== agent.name) + const entities = ctx.world.getNearbyEntityTypes(bot) + .filter(e => e !== 'player' && e !== 'item') + + const result = [ + ...players.map(p => `- Human player: ${p}`), + ...bots.map(b => `- Bot player: ${b}`), + ...entities.map(e => `- entities: ${e}`), + ] + + return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) + }, + } +} + +function createModesQuery(): Query { + return { + name: '!modes', + description: 'Get all available modes and their docs and see which are on/off.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => agent.bot.modes.getDocs(), + } +} + +function createSavedPlacesQuery(): Query { + return { + name: '!savedPlaces', + description: 'List all saved locations.', + schema: z.object({}), + perform: (agent: QueryAgentBotContext) => (): string => + `Saved place names: ${agent.memory_bank.getKeys()}`, + } +} + +// Export query list +export const queryList: readonly Query[] = [ + createStatsQuery(), + createInventoryQuery(), + createNearbyBlocksQuery(), + createCraftableQuery(), + createEntitiesQuery(), + createModesQuery(), + createSavedPlacesQuery(), +] as const diff --git a/services/minecraft/src/components/command.ts b/services/minecraft/src/components/command.ts new file mode 100644 index 000000000..782d04c15 --- /dev/null +++ b/services/minecraft/src/components/command.ts @@ -0,0 +1,46 @@ +import type { BotContext, ComponentLifecycle } from '@/composables/bot' +import { commands } from '@/composables/command' +import { formBotChat } from '@/middlewares/chat' +import { parseCommand } from '@/middlewares/command' +import { useLogg } from '@guiiai/logg' + +const logger = useLogg('command').useGlobalConfig() + +export function createCommandComponent(ctx: BotContext): ComponentLifecycle { + const onChat = formBotChat(ctx, (sender, message) => { + const { isCommand, command, args } = parseCommand(sender, message) + + if (!isCommand) + return + + // Remove the # prefix from command + const cleanCommand = command.slice(1) + + logger.withFields({ sender, command: cleanCommand, args }).log('Command received') + + const handler = commands.get(cleanCommand) + if (handler) { + handler({ sender, isCommand, command: cleanCommand, args }) + return + } + + // Built-in commands + switch (cleanCommand) { + case 'help': { + const commandList = Array.from(commands.keys()).concat(['help']) + ctx.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) + break + } + default: + ctx.bot.chat(`Unknown command: ${cleanCommand}`) + } + }) + + ctx.bot.on('chat', onChat) + + return { + cleanup: () => { + ctx.bot.removeListener('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index 191004a5a..2476fd26f 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -1,10 +1,10 @@ -import type { ComponentLifecycle, Context } from '../bot' +import type { BotContext, ComponentLifecycle } from '@/composables/bot' +import { formBotChat } from '@/middlewares/chat' import { useLogg } from '@guiiai/logg' -import { formBotChat } from 'src/middlewares/chat' const logger = useLogg('echo').useGlobalConfig() -export function createEchoComponent(ctx: Context): ComponentLifecycle { +export function createEchoComponent(ctx: BotContext): ComponentLifecycle { const onChat = formBotChat(ctx, (username, message) => { logger.withFields({ username, message }).log('Chat message received') ctx.bot.chat(message) diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 3fea63aa9..76c4d48f0 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -1,70 +1,76 @@ -import type { ComponentLifecycle, Context } from '../bot' +import type { BotContext, ComponentLifecycle } from '@/composables/bot' +import type { CommandContext } from '@/middlewares/command' +import { registerCommand } from '@/composables/command' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -import { formBotChat } from 'src/middlewares/chat' -export function createFollowComponent(ctx: Context): ComponentLifecycle { - const RANGE_GOAL = 2 // get within this radius of the player +interface FollowContext { + following: string | null + movements: Movements +} +export function createFollowComponent(ctx: BotContext): ComponentLifecycle { + const RANGE_GOAL = 1 // get within this radius of the player const logger = useLogg('follow').useGlobalConfig() - logger.log('Loading follow plugin') ctx.bot.loadPlugin(pathfinder) - let defaultMove: Movements - let following: string | null = null + const state: FollowContext = { + following: null, + movements: new Movements(ctx.bot), + } - const followPlayer = () => { - if (!following) + function startFollow(username: string): void { + state.following = username + logger.withFields({ username }).log('Starting to follow player') + followPlayer() + } + + function stopFollow(): void { + state.following = null + logger.log('Stopping follow') + ctx.bot.pathfinder.stop() + } + + function followPlayer(): void { + if (!state.following) return - const target = ctx.bot.players[following]?.entity + const target = ctx.bot.players[state.following]?.entity if (!target) { ctx.bot.chat('I lost sight of you!') - following = null + state.following = null return } const { x: playerX, y: playerY, z: playerZ } = target.position - ctx.bot.pathfinder.setMovements(defaultMove) + ctx.bot.pathfinder.setMovements(state.movements) ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) } - const onChat = formBotChat(ctx, (username, message) => { - if (username === ctx.bot.username) + registerCommand('follow', (commandCtx: CommandContext) => { + const username = commandCtx.sender + if (!username) { + ctx.bot.chat('Please specify a player name!') return + } + startFollow(username) + }) - if (message === 'follow') { - following = username - logger.withFields({ username }).log('Starting to follow player') + registerCommand('stop', () => { + stopFollow() + }) + + // Continuously update path to follow player + const followInterval = setInterval(() => { + if (state.following) followPlayer() - } - else if (message === 'stop') { - following = null - logger.log('Stopping follow') - ctx.bot.pathfinder.stop() - } - }) - - ctx.bot.once('spawn', () => { - defaultMove = new Movements(ctx.bot) - ctx.bot.on('chat', onChat) - - // Continuously update path to follow player - const followInterval = setInterval(() => { - if (following) - followPlayer() - }, 1000) - - ctx.bot.once('end', () => { - clearInterval(followInterval) - }) - }) + }, 1000) return { cleanup: () => { - ctx.bot.removeListener('chat', onChat) + clearInterval(followInterval) }, } } diff --git a/services/minecraft/src/components/patchfinder.ts b/services/minecraft/src/components/pathfinder.ts similarity index 50% rename from services/minecraft/src/components/patchfinder.ts rename to services/minecraft/src/components/pathfinder.ts index 51ee6de38..0c74a69ae 100644 --- a/services/minecraft/src/components/patchfinder.ts +++ b/services/minecraft/src/components/pathfinder.ts @@ -1,9 +1,10 @@ -import type { ComponentLifecycle, Context } from '../bot' +import type { BotContext, ComponentLifecycle } from '@/composables/bot' +import type { CommandContext } from '@/middlewares/command' +import { registerCommand } from '@/composables/command' import { useLogg } from '@guiiai/logg' import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' -import { formBotChat } from 'src/middlewares/chat' -export function createPathFinderComponent(ctx: Context): ComponentLifecycle { +export function createPathFinderComponent(ctx: BotContext): ComponentLifecycle { const RANGE_GOAL = 1 // get within this radius of the player const logger = useLogg('pathfinder').useGlobalConfig() @@ -13,14 +14,17 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle { let defaultMove: Movements - const onChat = formBotChat(ctx, (username, message) => { - if (message !== 'come') + const handleCome = (commandCtx: CommandContext) => { + const username = commandCtx.sender + if (!username) { + ctx.bot.chat('Please specify a player name!') return + } - logger.withFields({ username, message }).log('Chat message received') + logger.withFields({ username }).log('Come command received') const target = ctx.bot.players[username]?.entity if (!target) { - ctx.bot.chat('I don\'t see you !') + ctx.bot.chat('I don\'t see that player!') return } @@ -28,16 +32,14 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle { ctx.bot.pathfinder.setMovements(defaultMove) ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) - }) + } - ctx.bot.once('spawn', () => { - defaultMove = new Movements(ctx.bot) - ctx.bot.on('chat', onChat) - }) + defaultMove = new Movements(ctx.bot) + registerCommand('come', handleCome) return { cleanup: () => { - ctx.bot.removeListener('chat', onChat) + // Commands are cleaned up automatically }, } } diff --git a/services/minecraft/src/components/status.ts b/services/minecraft/src/components/status.ts new file mode 100644 index 000000000..626c0d29b --- /dev/null +++ b/services/minecraft/src/components/status.ts @@ -0,0 +1,34 @@ +import type { BotContext, ComponentLifecycle } from '@/composables/bot' +import { registerCommand } from '@/composables/command' +import { useLogg } from '@guiiai/logg' + +export function createStatusComponent(ctx: BotContext): ComponentLifecycle { + const logger = useLogg('status').useGlobalConfig() + logger.log('Loading status component') + + const handleStatus = () => { + const pos = ctx.bot.entity.position + const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear' + const timeOfDay = ctx.bot.time.timeOfDay < 6000 + ? 'Morning' + : ctx.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + + ctx.bot.chat(`Status: +Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)} +Health: ${Math.round(ctx.bot.health)} / 20 +Hunger: ${Math.round(ctx.bot.food)} / 20 +Weather: ${weather} +Time: ${timeOfDay}`) + } + + registerCommand('status', () => { + logger.log('Status command received') + handleStatus() + }) + + return { + cleanup: () => { + // Commands are cleaned up automatically + }, + } +} diff --git a/services/minecraft/src/bot.ts b/services/minecraft/src/composables/bot.ts similarity index 73% rename from services/minecraft/src/bot.ts rename to services/minecraft/src/composables/bot.ts index 61997c435..e0f387c7c 100644 --- a/services/minecraft/src/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -3,15 +3,23 @@ import mineflayer, { type Bot, type BotOptions } from 'mineflayer' const logger = useLogg('bot').useGlobalConfig() -let ctx: Context | undefined +let ctx: BotContext | undefined -export interface Context { +export interface BotContext { bot: Bot components: Map + + botName: string + prompt: { + selfPrompt: string + } + memory: { + getSummary: () => string + } } export interface Component { - (ctx: Context): ComponentLifecycle + (ctx: BotContext): ComponentLifecycle } export interface ComponentLifecycle { @@ -28,6 +36,13 @@ export function createBot(options: BotOptions): Bot { password: options.password, }), components: new Map(), + botName: options.username, + prompt: { + selfPrompt: '', + }, + memory: { + getSummary: () => '', + }, } ctx.bot.on('error', (err: Error) => { @@ -49,19 +64,19 @@ export function useBot() { const cleanup = () => { logger.log('Cleaning up bot and components') - ctx!.components.forEach((context: ComponentLifecycle) => context.cleanup?.()) + ctx!.components.forEach((BotContext: ComponentLifecycle) => BotContext.cleanup?.()) ctx!.components.clear() ctx!.bot.end() } const registerComponent = (componentName: string, component: Component) => { logger.withFields({ componentName }).log('Registering new component') - const context = component(ctx!) + const BotContext = component(ctx!) - if (context != null) - ctx!.components.set(componentName, context) + if (BotContext != null) + ctx!.components.set(componentName, BotContext) - return context + return BotContext } const listComponents = () => { diff --git a/services/minecraft/src/composables/command.ts b/services/minecraft/src/composables/command.ts new file mode 100644 index 000000000..9d25d4461 --- /dev/null +++ b/services/minecraft/src/composables/command.ts @@ -0,0 +1,10 @@ +import type { CommandContext } from '@/middlewares/command' + +export const commands = new Map void>() + +export function registerCommand(command: string, handler: (ctx: CommandContext) => void) { + if (commands.has(command)) + throw new Error(`Command ${command} already registered`) + + commands.set(command, handler) +} diff --git a/services/minecraft/src/config.ts b/services/minecraft/src/composables/config.ts similarity index 100% rename from services/minecraft/src/config.ts rename to services/minecraft/src/composables/config.ts diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 1236e9b45..9977ea386 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,11 +1,13 @@ import process from 'node:process' + import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' -import { createBot, useBot } from './bot' -import { createEchoComponent } from './components/echo' +import { createCommandComponent } from './components/command' import { createFollowComponent } from './components/follow' -import { createPathFinderComponent } from './components/patchfinder' -import { botConfig, initEnv } from './config' +import { createPathFinderComponent } from './components/pathfinder' +import { createStatusComponent } from './components/status' +import { createBot, useBot } from './composables/bot' +import { botConfig, initEnv } from './composables/config' const logger = useLogg('main').useGlobalConfig() @@ -16,12 +18,17 @@ async function main() { initEnv() createBot(botConfig) - const { cleanup, registerComponent } = useBot() + const { cleanup, registerComponent, ctx } = useBot() - registerComponent('echo', createEchoComponent) - registerComponent('pathfinder', createPathFinderComponent) - // registerComponent('chest', createChestComponent) - registerComponent('follow', createFollowComponent) + ctx.bot.once('spawn', () => { + registerComponent('status', createStatusComponent) + // registerComponent('echo', createEchoComponent) + registerComponent('pathfinder', createPathFinderComponent) + registerComponent('follow', createFollowComponent) + registerComponent('command', createCommandComponent) + }) + + // initAgent(ctx) process.on('SIGINT', () => { cleanup() diff --git a/services/minecraft/src/middlewares/chat.ts b/services/minecraft/src/middlewares/chat.ts index 165a0a7f9..c83955f0a 100644 --- a/services/minecraft/src/middlewares/chat.ts +++ b/services/minecraft/src/middlewares/chat.ts @@ -1,8 +1,8 @@ +import type { BotContext } from '@/composables/bot' import type { Entity } from 'prismarine-entity' -import type { Context } from 'src/bot' // TODO: need to be refactored -interface ChatContext { +interface ChatBotContext { fromUsername?: string fromEntity?: Entity fromMessage?: string @@ -11,7 +11,7 @@ interface ChatContext { isCommand: () => boolean } -export function newChatContext(ctx: Context, username: string, message: string): ChatContext { +export function newChatBotContext(ctx: BotContext, username: string, message: string): ChatBotContext { return { fromUsername: username, fromEntity: ctx.bot.entity, @@ -21,7 +21,7 @@ export function newChatContext(ctx: Context, username: string, message: string): } } -export function formBotChat(ctx: Context, cb: (username: string, message: string) => void) { +export function formBotChat(ctx: BotContext, cb: (username: string, message: string) => void) { return (username: string, message: string) => { if (ctx.bot.username === username) return diff --git a/services/minecraft/src/middlewares/command.ts b/services/minecraft/src/middlewares/command.ts new file mode 100644 index 000000000..7d1a3c953 --- /dev/null +++ b/services/minecraft/src/middlewares/command.ts @@ -0,0 +1,13 @@ +export interface CommandContext { + sender: string + isCommand: boolean + command: string + args: string[] +} + +export function parseCommand(sender: string, message: string): CommandContext { + const isCommand = message.startsWith('#') + const command = message.split(' ')[0] + const args = message.split(' ').slice(1) + return { sender, isCommand, command, args } +} diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts new file mode 100644 index 000000000..e5671ad2c --- /dev/null +++ b/services/minecraft/src/prompts/agent.ts @@ -0,0 +1,74 @@ +import type { BotContext } from '@/composables/bot' + +export function genSystemPrompt(ctx: BotContext): string { + return ` +You are a playful Minecraft bot named ${ctx.botName} that can converse with players, see, move, +mine, build, and interact with the world by using commands. + +${ctx.prompt.selfPrompt} + +Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in +your responses, don't apologize constantly, don't give instructions or make lists unless +asked, and don't refuse requests. + +Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', +instead say this: 'Sure, I'll stop. !stop'. + +Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer("playername", 3)'. +Respond only as ${ctx.botName}, never output '(FROM OTHER BOT)'or pretend to be someone else. + +If you have nothing to say or do, respond with an just a tab '\t'. +This is extremely important to me, take a deep breath and have fun :) + +Summarized memory: '${ctx.memory.getSummary()}' +$STATS +$INVENTORY +$COMMAND_DOCS +$EXAMPLES + +Conversation Begin: +` +} + +export function genQueryAgentPrompt(tools: string[], status: Map): string { + const BotContextFields: readonly string[] = [ + 'Biome', + 'Time', + 'Nearby blocks', + 'Other blocks that are recently seen', + 'Nearby entities (nearest to farthest)', + 'Health', + 'Hunger', + 'Position', + 'Equipment', + 'Inventory (xx/36)', + 'Chests', + 'Completed tasks so far', + 'Failed tasks that are too hard', + ] as const + + const formatBotContextFields = (fields: readonly string[]): string => + fields.map((field) => { + const value = status.get(field) || '...' + return `${field}: ${value}` + }).join('\n') + + const formatTools = (toolList: string[]): string => + toolList.join('\n') + + const prompt = ` +You are a helpful assistant that asks questions to help me decide the next immediate +task to do in Minecraft. My ultimate goal is to discover as many things as possible, +accomplish as many tasks as possible and become the best Minecraft player in the world. + +I will give you the following information: +${formatBotContextFields(BotContextFields)} + +And I will give you some tools to use: +${formatTools(tools)} + +Then you can choose some of the tools to use. Use the valid JS call function to call the tool. +` + + return prompt +} diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 8128ec7a1..8bdc1c2f5 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -2,6 +2,7 @@ * @source https://github.com/kolbytn/mindcraft */ import type { Bot } from 'mineflayer' +import { botConfig } from '@/composables/config' import minecraftData from 'minecraft-data' import { createBot } from 'mineflayer' import armorManager from 'mineflayer-armor-manager' @@ -10,7 +11,6 @@ import { plugin as collectblock } from 'mineflayer-collectblock' import { pathfinder } from 'mineflayer-pathfinder' import { plugin as pvp } from 'mineflayer-pvp' import prismarine_items from 'prismarine-item' -import { botConfig } from '../config' const mc_version = botConfig.version! const mcdata = minecraftData(mc_version) From ebf9223d6c08e6e34cecd303293471e1bda8241a Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 18:56:54 +0800 Subject: [PATCH 10/77] chore: remove xsai --- services/minecraft/src/agents/openai.ts | 104 ++++++++++++------------ services/minecraft/src/main.ts | 3 +- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 6db92d857..31265a488 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -1,8 +1,8 @@ -import type { BotContext } from 'src/bot' +import type { Agent, Neuri } from 'neuri' import { useLogg } from '@guiiai/logg' -import { tool } from 'xsai' - -import { createQueryAgentBotContext, queryList } from './queries' +import { agent, neuri } from 'neuri' +import { openaiConfig } from '../composables/config' +import { queryList } from './queries' // Types interface AgentBotContext { @@ -15,60 +15,60 @@ const agents = new Set>() const logger = useLogg('openai').useGlobalConfig() // Agent initialization -// export async function initAgent(): Promise { -// logger.log('Initializing agent') -// let n = neuri() - -// agents.add(initQueryAgent()) - -// agents.forEach(agent => n = n.agent(agent)) - -// return n.build({ -// provider: { -// apiKey: openaiConfig.apiKey, -// baseURL: openaiConfig.baseUrl, -// }, -// }) -// } - -// export async function initQueryAgent(): Promise { -// logger.log('Initializing query agent') -// let queryAgent = agent('query') - -// queryList.forEach((query) => { -// queryAgent = queryAgent.tool( -// query.name, -// query.schema, -// query.perform, -// { description: query.description }, -// ) -// }) - -// return queryAgent.build() -// } - -export async function initAgent(ctx: BotContext) { +export async function initAgent(): Promise { logger.log('Initializing agent') + let n = neuri() - initQueryAgent(ctx) + agents.add(initQueryAgent()) + + agents.forEach(agent => n = n.agent(agent)) + + return n.build({ + provider: { + apiKey: openaiConfig.apiKey, + baseURL: openaiConfig.baseUrl, + }, + }) } -export function initQueryAgent(ctx: BotContext) { +export async function initQueryAgent(): Promise { logger.log('Initializing query agent') - const agentBotContext = createQueryAgentBotContext(ctx.bot) + let queryAgent = agent('query') - const tools = [] - - for (const query of queryList) { - tools.push( - tool({ - name: query.name, - description: query.description, - execute: query.perform(agentBotContext), - parameters: query.schema as never, - }), + queryList.forEach((query) => { + queryAgent = queryAgent.tool( + query.name, + query.schema, + query.perform, + { description: query.description }, ) - } + }) - return tools + return queryAgent.build() } + +// export async function initAgent(ctx: BotContext) { +// logger.log('Initializing agent') + +// initQueryAgent(ctx) +// } + +// export function initQueryAgent(ctx: BotContext) { +// logger.log('Initializing query agent') +// const agentBotContext = createQueryAgentBotContext(ctx.bot) + +// const tools = [] + +// for (const query of queryList) { +// tools.push( +// tool({ +// name: query.name, +// description: query.description, +// execute: query.perform(agentBotContext), +// parameters: query.schema as never, +// }), +// ) +// } + +// return tools +// } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 9977ea386..b58f327e7 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -2,6 +2,7 @@ import process from 'node:process' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +import { initAgent } from './agents/openai' import { createCommandComponent } from './components/command' import { createFollowComponent } from './components/follow' import { createPathFinderComponent } from './components/pathfinder' @@ -28,7 +29,7 @@ async function main() { registerComponent('command', createCommandComponent) }) - // initAgent(ctx) + // initAgent() process.on('SIGINT', () => { cleanup() From 8f327c34368fe6bcb1bc877e0b2663ae95694110 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 21:42:02 +0800 Subject: [PATCH 11/77] fix: type module --- services/minecraft/src/components/follow.ts | 22 +++++++++---------- .../minecraft/src/components/pathfinder.ts | 12 +++++----- services/minecraft/src/main.ts | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 76c4d48f0..0888c53c9 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -2,21 +2,19 @@ import type { BotContext, ComponentLifecycle } from '@/composables/bot' import type { CommandContext } from '@/middlewares/command' import { registerCommand } from '@/composables/command' import { useLogg } from '@guiiai/logg' -import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' +import pathfinderModel from 'mineflayer-pathfinder' -interface FollowContext { - following: string | null - movements: Movements -} +const { goals, Movements, pathfinder } = pathfinderModel -export function createFollowComponent(ctx: BotContext): ComponentLifecycle { - const RANGE_GOAL = 1 // get within this radius of the player +export function createFollowComponent(ctx: BotContext, config?: { + rangeGoal: number +}): ComponentLifecycle { const logger = useLogg('follow').useGlobalConfig() ctx.bot.loadPlugin(pathfinder) - const state: FollowContext = { - following: null, + const state = { + following: undefined as string | undefined, movements: new Movements(ctx.bot), } @@ -27,7 +25,7 @@ export function createFollowComponent(ctx: BotContext): ComponentLifecycle { } function stopFollow(): void { - state.following = null + state.following = undefined logger.log('Stopping follow') ctx.bot.pathfinder.stop() } @@ -39,14 +37,14 @@ export function createFollowComponent(ctx: BotContext): ComponentLifecycle { const target = ctx.bot.players[state.following]?.entity if (!target) { ctx.bot.chat('I lost sight of you!') - state.following = null + state.following = undefined return } const { x: playerX, y: playerY, z: playerZ } = target.position ctx.bot.pathfinder.setMovements(state.movements) - ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, config?.rangeGoal ?? 1)) } registerCommand('follow', (commandCtx: CommandContext) => { diff --git a/services/minecraft/src/components/pathfinder.ts b/services/minecraft/src/components/pathfinder.ts index 0c74a69ae..3d7d8746e 100644 --- a/services/minecraft/src/components/pathfinder.ts +++ b/services/minecraft/src/components/pathfinder.ts @@ -2,17 +2,19 @@ import type { BotContext, ComponentLifecycle } from '@/composables/bot' import type { CommandContext } from '@/middlewares/command' import { registerCommand } from '@/composables/command' import { useLogg } from '@guiiai/logg' -import { goals, Movements, pathfinder } from 'mineflayer-pathfinder' +import pathfinderModel from 'mineflayer-pathfinder' -export function createPathFinderComponent(ctx: BotContext): ComponentLifecycle { - const RANGE_GOAL = 1 // get within this radius of the player +const { goals, Movements, pathfinder } = pathfinderModel +export function createPathFinderComponent(ctx: BotContext, config?: { + rangeGoal: number +}): ComponentLifecycle { const logger = useLogg('pathfinder').useGlobalConfig() logger.log('Loading pathfinder plugin') ctx.bot.loadPlugin(pathfinder) - let defaultMove: Movements + let defaultMove: any const handleCome = (commandCtx: CommandContext) => { const username = commandCtx.sender @@ -31,7 +33,7 @@ export function createPathFinderComponent(ctx: BotContext): ComponentLifecycle { const { x: playerX, y: playerY, z: playerZ } = target.position ctx.bot.pathfinder.setMovements(defaultMove) - ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL)) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, config?.rangeGoal ?? 1)) } defaultMove = new Movements(ctx.bot) diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index b58f327e7..ad7a96663 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -29,7 +29,7 @@ async function main() { registerComponent('command', createCommandComponent) }) - // initAgent() + initAgent() process.on('SIGINT', () => { cleanup() From 578aad50e5093d199daa153c2b9cb4d357b5d7bc Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 22:10:46 +0800 Subject: [PATCH 12/77] fix: unit test --- services/minecraft/src/agents/openai.test.ts | 32 ++++++++++++++--- services/minecraft/src/agents/openai.ts | 35 +------------------ .../src/agents/{queries.ts => query.ts} | 14 ++++---- services/minecraft/src/main.ts | 7 ++-- services/minecraft/src/prompts/agent.ts | 9 +++-- services/minecraft/src/utils/logger.ts | 9 +++++ 6 files changed, 53 insertions(+), 53 deletions(-) rename services/minecraft/src/agents/{queries.ts => query.ts} (97%) create mode 100644 services/minecraft/src/utils/logger.ts diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index 92271ebb1..a04c1fbf7 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -1,10 +1,32 @@ -import { describe, expect, it } from 'vitest' +import { useLogg } from '@guiiai/logg' +import { messages, system, user } from 'neuri/openai' -import { initQueryAgent } from './openai' +import { beforeAll, describe, expect, it } from 'vitest' +import { initEnv } from '../composables/config' +import { basicSystemPrompt } from '../prompts/agent' +import { initLogger } from '../utils/logger' +import { initAgent } from './openai' describe('openAI agent', () => { - it('should initialize the agent', () => { - const agent = initQueryAgent() - expect(agent).toBeDefined() + beforeAll(() => { + initLogger() + initEnv() + }) + + it('should initialize the agent', async () => { + const agent = await initAgent() + + const text = await agent.handle( + messages( + system(basicSystemPrompt('airi')), + user('Hello, who are you?'), + ), + async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'gpt-4o-mini' }) + return await completion?.firstContent() + }, + ) + + expect(text?.toLowerCase()).toContain('airi') }) }) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 31265a488..ef9042f4b 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -2,19 +2,12 @@ import type { Agent, Neuri } from 'neuri' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { queryList } from './queries' +import { queryList } from './query' -// Types -interface AgentBotContext { - readonly agents: Set> -} - -// State management const agents = new Set>() const logger = useLogg('openai').useGlobalConfig() -// Agent initialization export async function initAgent(): Promise { logger.log('Initializing agent') let n = neuri() @@ -46,29 +39,3 @@ export async function initQueryAgent(): Promise { return queryAgent.build() } - -// export async function initAgent(ctx: BotContext) { -// logger.log('Initializing agent') - -// initQueryAgent(ctx) -// } - -// export function initQueryAgent(ctx: BotContext) { -// logger.log('Initializing query agent') -// const agentBotContext = createQueryAgentBotContext(ctx.bot) - -// const tools = [] - -// for (const query of queryList) { -// tools.push( -// tool({ -// name: query.name, -// description: query.description, -// execute: query.perform(agentBotContext), -// parameters: query.schema as never, -// }), -// ) -// } - -// return tools -// } diff --git a/services/minecraft/src/agents/queries.ts b/services/minecraft/src/agents/query.ts similarity index 97% rename from services/minecraft/src/agents/queries.ts rename to services/minecraft/src/agents/query.ts index 26db8c7a3..51f3865e4 100644 --- a/services/minecraft/src/agents/queries.ts +++ b/services/minecraft/src/agents/query.ts @@ -72,7 +72,7 @@ function formatWearingItem(slot: string, item: string | undefined): string { // Query implementations function createStatsQuery(): Query { return { - name: '!stats', + name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => { @@ -107,7 +107,7 @@ ${bot.modes.getMiniDocs()}`) function createInventoryQuery(): Query { return { - name: '!inventory', + name: 'inventory', description: 'Get your bot\'s inventory.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => { @@ -133,7 +133,7 @@ WEARING: ${wearing || 'Nothing'}`) function createNearbyBlocksQuery(): Query { return { - name: '!nearbyBlocks', + name: 'nearbyBlocks', description: 'Get the blocks near the bot.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => { @@ -145,7 +145,7 @@ function createNearbyBlocksQuery(): Query { function createCraftableQuery(): Query { return { - name: '!craftable', + name: 'craftable', description: 'Get the craftable items with the bot\'s inventory.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => { @@ -157,7 +157,7 @@ function createCraftableQuery(): Query { function createEntitiesQuery(): Query { return { - name: '!entities', + name: 'entities', description: 'Get the nearby players and entities.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => { @@ -182,7 +182,7 @@ function createEntitiesQuery(): Query { function createModesQuery(): Query { return { - name: '!modes', + name: 'modes', description: 'Get all available modes and their docs and see which are on/off.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => agent.bot.modes.getDocs(), @@ -191,7 +191,7 @@ function createModesQuery(): Query { function createSavedPlacesQuery(): Query { return { - name: '!savedPlaces', + name: 'savedPlaces', description: 'List all saved locations.', schema: z.object({}), perform: (agent: QueryAgentBotContext) => (): string => diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index ad7a96663..ef0ea2e7a 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,6 +1,6 @@ import process from 'node:process' -import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +import { useLogg } from '@guiiai/logg' import { initAgent } from './agents/openai' import { createCommandComponent } from './components/command' @@ -9,13 +9,12 @@ import { createPathFinderComponent } from './components/pathfinder' import { createStatusComponent } from './components/status' import { createBot, useBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' +import { initLogger } from './utils/logger' const logger = useLogg('main').useGlobalConfig() async function main() { - setGlobalLogLevel(LogLevel.Debug) - setGlobalFormat(Format.Pretty) - + initLogger() initEnv() createBot(botConfig) diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index e5671ad2c..6d11c215f 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,9 +1,12 @@ import type { BotContext } from '@/composables/bot' +export function basicSystemPrompt(botName: string): string { + return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, +mine, build, and interact with the world by using commands.` +} + export function genSystemPrompt(ctx: BotContext): string { - return ` -You are a playful Minecraft bot named ${ctx.botName} that can converse with players, see, move, -mine, build, and interact with the world by using commands. + return `${basicSystemPrompt(ctx.botName)} ${ctx.prompt.selfPrompt} diff --git a/services/minecraft/src/utils/logger.ts b/services/minecraft/src/utils/logger.ts new file mode 100644 index 000000000..3a689bb7b --- /dev/null +++ b/services/minecraft/src/utils/logger.ts @@ -0,0 +1,9 @@ +import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' + +export function initLogger() { + setGlobalLogLevel(LogLevel.Debug) + setGlobalFormat(Format.Pretty) + + const logger = useLogg('logger').useGlobalConfig() + logger.log('Logger initialized') +} From ddc2665bc99b6f1354f7352b52e2a9b2feb11d22 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 6 Jan 2025 22:50:43 +0800 Subject: [PATCH 13/77] chore(test): should choose right command --- services/minecraft/src/agents/openai.test.ts | 30 +++++++--- services/minecraft/src/agents/openai.ts | 9 +-- services/minecraft/src/agents/query.ts | 58 ++----------------- services/minecraft/src/components/command.ts | 8 +-- services/minecraft/src/components/echo.ts | 4 +- services/minecraft/src/components/follow.ts | 6 +- .../minecraft/src/components/pathfinder.ts | 6 +- services/minecraft/src/components/status.ts | 38 ++++++------ services/minecraft/src/composables/bot.ts | 2 + services/minecraft/src/composables/command.ts | 2 +- services/minecraft/src/middlewares/chat.ts | 2 +- services/minecraft/src/prompts/agent.ts | 38 +++--------- services/minecraft/src/utils/mcdata.ts | 2 +- 13 files changed, 77 insertions(+), 128 deletions(-) diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index a04c1fbf7..64c05ca6f 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -1,20 +1,21 @@ -import { useLogg } from '@guiiai/logg' import { messages, system, user } from 'neuri/openai' - import { beforeAll, describe, expect, it } from 'vitest' -import { initEnv } from '../composables/config' -import { basicSystemPrompt } from '../prompts/agent' +import { createBot, useBot } from '../composables/bot' +import { botConfig, initEnv } from '../composables/config' +import { basicSystemPrompt, genQueryAgentPrompt } from '../prompts/agent' import { initLogger } from '../utils/logger' import { initAgent } from './openai' -describe('openAI agent', () => { +describe('openAI agent', { timeout: 10000 }, () => { beforeAll(() => { initLogger() initEnv() + createBot(botConfig) }) it('should initialize the agent', async () => { - const agent = await initAgent() + const { ctx } = useBot() + const agent = await initAgent(ctx) const text = await agent.handle( messages( @@ -22,11 +23,26 @@ describe('openAI agent', () => { user('Hello, who are you?'), ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'gpt-4o-mini' }) + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) return await completion?.firstContent() }, ) expect(text?.toLowerCase()).toContain('airi') }) + + it('should choose right command', async () => { + const { ctx } = useBot() + const agent = await initAgent(ctx) + + const text = await agent.handle(messages( + system(genQueryAgentPrompt(ctx)), + user('What are you status?'), + ), async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + return await completion?.firstContent() + }) + + expect(text?.toLowerCase()).toContain('position') + }) }) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index ef9042f4b..991e99fd9 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -1,4 +1,5 @@ import type { Agent, Neuri } from 'neuri' +import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' @@ -8,11 +9,11 @@ const agents = new Set>() const logger = useLogg('openai').useGlobalConfig() -export async function initAgent(): Promise { +export async function initAgent(ctx: BotContext): Promise { logger.log('Initializing agent') let n = neuri() - agents.add(initQueryAgent()) + agents.add(initQueryAgent(ctx)) agents.forEach(agent => n = n.agent(agent)) @@ -24,7 +25,7 @@ export async function initAgent(): Promise { }) } -export async function initQueryAgent(): Promise { +export async function initQueryAgent(ctx: BotContext): Promise { logger.log('Initializing query agent') let queryAgent = agent('query') @@ -32,7 +33,7 @@ export async function initQueryAgent(): Promise { queryAgent = queryAgent.tool( query.name, query.schema, - query.perform, + query.perform(ctx), { description: query.description }, ) }) diff --git a/services/minecraft/src/agents/query.ts b/services/minecraft/src/agents/query.ts index 51f3865e4..57391df95 100644 --- a/services/minecraft/src/agents/query.ts +++ b/services/minecraft/src/agents/query.ts @@ -1,5 +1,7 @@ import type { Bot } from 'mineflayer' +import type { BotContext } from '../composables/bot' import { z } from 'zod' +import { getStatus } from '../components/status' // Core types type QueryResult = string | Promise @@ -25,37 +27,11 @@ interface QueryBotContext { } } -interface QueryAgentBotContext { - bot: Bot - name: string - actions: { - currentActionLabel: string - } - isIdle: () => boolean - memory_bank: { - getKeys: () => string[] - } -} - -export function createQueryAgentBotContext(bot: Bot): QueryAgentBotContext { - return { - bot, - name: bot.username, - actions: { - currentActionLabel: bot.actions.currentActionLabel, - }, - isIdle: () => bot.actions.isIdle(), - memory_bank: { - getKeys: () => bot.memory_bank.getKeys(), - }, - } -} - interface Query { readonly name: string readonly description: string readonly schema: z.ZodObject - readonly perform: (agent: QueryAgentBotContext) => () => QueryResult + readonly perform: (ctx: BotContext) => () => QueryResult } // Utils @@ -75,32 +51,8 @@ function createStatsQuery(): Query { name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => { - const { bot } = agent - const pos = bot.entity.position - const weather = bot.rainState > 0 ? 'Rain' : bot.thunderState > 0 ? 'Thunderstorm' : 'Clear' - const timeOfDay = bot.time.timeOfDay < 6000 - ? 'Morning' - : bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' - const action = agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel - - const players = ctx.world.getNearbyPlayerNames(bot) - .filter(p => !ctx.convoManager.getInGameAgents().includes(p)) - const bots = ctx.convoManager.getInGameAgents() - .filter(b => b !== agent.name) - - return pad(`STATS -- Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)} -- Gamemode: ${bot.game.gameMode} -- Health: ${Math.round(bot.health)} / 20 -- Hunger: ${Math.round(bot.food)} / 20 -- Biome: ${ctx.world.getBiomeName(bot)} -- Weather: ${weather} -- Time: ${timeOfDay} -- Current Action: ${action} -- Nearby Human Players: ${players.length > 0 ? players.join(', ') : 'None.'} -- Nearby Bot Players: ${bots.length > 0 ? bots.join(', ') : 'None.'} -${bot.modes.getMiniDocs()}`) + perform: (ctx: BotContext) => (): string => { + return Array.from(getStatus(ctx).entries()).map(([key, value]) => `${key}: ${value}`).join('\n') }, } } diff --git a/services/minecraft/src/components/command.ts b/services/minecraft/src/components/command.ts index 782d04c15..a81211212 100644 --- a/services/minecraft/src/components/command.ts +++ b/services/minecraft/src/components/command.ts @@ -1,8 +1,8 @@ -import type { BotContext, ComponentLifecycle } from '@/composables/bot' -import { commands } from '@/composables/command' -import { formBotChat } from '@/middlewares/chat' -import { parseCommand } from '@/middlewares/command' +import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' +import { commands } from '../composables/command' +import { formBotChat } from '../middlewares/chat' +import { parseCommand } from '../middlewares/command' const logger = useLogg('command').useGlobalConfig() diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts index 2476fd26f..0280cb4ce 100644 --- a/services/minecraft/src/components/echo.ts +++ b/services/minecraft/src/components/echo.ts @@ -1,6 +1,6 @@ -import type { BotContext, ComponentLifecycle } from '@/composables/bot' -import { formBotChat } from '@/middlewares/chat' +import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' +import { formBotChat } from '../middlewares/chat' const logger = useLogg('echo').useGlobalConfig() diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 0888c53c9..6ac5004e9 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -1,8 +1,8 @@ -import type { BotContext, ComponentLifecycle } from '@/composables/bot' -import type { CommandContext } from '@/middlewares/command' -import { registerCommand } from '@/composables/command' +import type { BotContext, ComponentLifecycle } from '../composables/bot' +import type { CommandContext } from '../middlewares/command' import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' +import { registerCommand } from '../composables/command' const { goals, Movements, pathfinder } = pathfinderModel diff --git a/services/minecraft/src/components/pathfinder.ts b/services/minecraft/src/components/pathfinder.ts index 3d7d8746e..e5fc3d32e 100644 --- a/services/minecraft/src/components/pathfinder.ts +++ b/services/minecraft/src/components/pathfinder.ts @@ -1,8 +1,8 @@ -import type { BotContext, ComponentLifecycle } from '@/composables/bot' -import type { CommandContext } from '@/middlewares/command' -import { registerCommand } from '@/composables/command' +import type { BotContext, ComponentLifecycle } from '../composables/bot' +import type { CommandContext } from '../middlewares/command' import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' +import { registerCommand } from '../composables/command' const { goals, Movements, pathfinder } = pathfinderModel diff --git a/services/minecraft/src/components/status.ts b/services/minecraft/src/components/status.ts index 626c0d29b..0f7f7486f 100644 --- a/services/minecraft/src/components/status.ts +++ b/services/minecraft/src/components/status.ts @@ -1,29 +1,31 @@ -import type { BotContext, ComponentLifecycle } from '@/composables/bot' -import { registerCommand } from '@/composables/command' +import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' +import { registerCommand } from '../composables/command' + +export function getStatus(ctx: BotContext): Map { + const status = new Map() + const pos = ctx.bot.entity.position + const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear' + const timeOfDay = ctx.bot.time.timeOfDay < 6000 + ? 'Morning' + : ctx.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + + status.set('position', `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}`) + status.set('health', `${Math.round(ctx.bot.health)} / 20`) + status.set('weather', weather) + status.set('timeOfDay', timeOfDay) + + return status +} export function createStatusComponent(ctx: BotContext): ComponentLifecycle { const logger = useLogg('status').useGlobalConfig() logger.log('Loading status component') - const handleStatus = () => { - const pos = ctx.bot.entity.position - const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear' - const timeOfDay = ctx.bot.time.timeOfDay < 6000 - ? 'Morning' - : ctx.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' - - ctx.bot.chat(`Status: -Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)} -Health: ${Math.round(ctx.bot.health)} / 20 -Hunger: ${Math.round(ctx.bot.food)} / 20 -Weather: ${weather} -Time: ${timeOfDay}`) - } - registerCommand('status', () => { logger.log('Status command received') - handleStatus() + const status = getStatus(ctx) + ctx.bot.chat(status.toString()) }) return { diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index e0f387c7c..782cf19d8 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -16,6 +16,7 @@ export interface BotContext { memory: { getSummary: () => string } + status: Map } export interface Component { @@ -43,6 +44,7 @@ export function createBot(options: BotOptions): Bot { memory: { getSummary: () => '', }, + status: new Map(), } ctx.bot.on('error', (err: Error) => { diff --git a/services/minecraft/src/composables/command.ts b/services/minecraft/src/composables/command.ts index 9d25d4461..256009a18 100644 --- a/services/minecraft/src/composables/command.ts +++ b/services/minecraft/src/composables/command.ts @@ -1,4 +1,4 @@ -import type { CommandContext } from '@/middlewares/command' +import type { CommandContext } from '../middlewares/command' export const commands = new Map void>() diff --git a/services/minecraft/src/middlewares/chat.ts b/services/minecraft/src/middlewares/chat.ts index c83955f0a..ae255d2e7 100644 --- a/services/minecraft/src/middlewares/chat.ts +++ b/services/minecraft/src/middlewares/chat.ts @@ -1,5 +1,5 @@ -import type { BotContext } from '@/composables/bot' import type { Entity } from 'prismarine-entity' +import type { BotContext } from '../composables/bot' // TODO: need to be refactored interface ChatBotContext { diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 6d11c215f..990340b2d 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,4 +1,4 @@ -import type { BotContext } from '@/composables/bot' +import type { BotContext } from '../composables/bot' export function basicSystemPrompt(botName: string): string { return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, @@ -33,44 +33,20 @@ Conversation Begin: ` } -export function genQueryAgentPrompt(tools: string[], status: Map): string { - const BotContextFields: readonly string[] = [ - 'Biome', - 'Time', - 'Nearby blocks', - 'Other blocks that are recently seen', - 'Nearby entities (nearest to farthest)', - 'Health', - 'Hunger', - 'Position', - 'Equipment', - 'Inventory (xx/36)', - 'Chests', - 'Completed tasks so far', - 'Failed tasks that are too hard', - ] as const - - const formatBotContextFields = (fields: readonly string[]): string => - fields.map((field) => { - const value = status.get(field) || '...' - return `${field}: ${value}` - }).join('\n') - - const formatTools = (toolList: string[]): string => - toolList.join('\n') - +export function genQueryAgentPrompt(ctx: BotContext): string { const prompt = ` You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world. I will give you the following information: -${formatBotContextFields(BotContextFields)} - -And I will give you some tools to use: -${formatTools(tools)} +${Array.from(ctx.status.entries()).map(([key, value]) => `${key}: ${value}`).join('\n')} Then you can choose some of the tools to use. Use the valid JS call function to call the tool. + +## For example: +### Get the stats +stats() ` return prompt diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 8bdc1c2f5..60340e4c8 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -2,7 +2,6 @@ * @source https://github.com/kolbytn/mindcraft */ import type { Bot } from 'mineflayer' -import { botConfig } from '@/composables/config' import minecraftData from 'minecraft-data' import { createBot } from 'mineflayer' import armorManager from 'mineflayer-armor-manager' @@ -11,6 +10,7 @@ import { plugin as collectblock } from 'mineflayer-collectblock' import { pathfinder } from 'mineflayer-pathfinder' import { plugin as pvp } from 'mineflayer-pvp' import prismarine_items from 'prismarine-item' +import { botConfig } from '../composables/config' const mc_version = botConfig.version! const mcdata = minecraftData(mc_version) From 18e460ae50f536abeb3441a06f321a88bcdcee03 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 00:16:51 +0800 Subject: [PATCH 14/77] wip: world --- services/minecraft/src/agents/agent.ts | 0 services/minecraft/src/composables/world.ts | 305 ++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 services/minecraft/src/agents/agent.ts create mode 100644 services/minecraft/src/composables/world.ts diff --git a/services/minecraft/src/agents/agent.ts b/services/minecraft/src/agents/agent.ts new file mode 100644 index 000000000..e69de29bb diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts new file mode 100644 index 000000000..0e873b295 --- /dev/null +++ b/services/minecraft/src/composables/world.ts @@ -0,0 +1,305 @@ +import pf from 'mineflayer-pathfinder' + +import * as mc from '../utils/mcdata' + +export function getNearestFreeSpace(bot, size = 1, distance = 8) { + /** + * Get the nearest empty space with solid blocks beneath it of the given size. + * @param {Bot} bot - The bot to get the nearest free space for. + * @param {number} size - The (size x size) of the space to find, default 1. + * @param {number} distance - The maximum distance to search, default 8. + * @returns {Vec3} - The south west corner position of the nearest free space. + * @example + * let position = world.getNearestFreeSpace(bot, 1, 8); + */ + const empty_pos = bot.findBlocks({ + matching: (block) => { + return block && block.name == 'air' + }, + maxDistance: distance, + count: 1000, + }) + for (let i = 0; i < empty_pos.length; i++) { + let empty = true + for (let x = 0; x < size; x++) { + for (let z = 0; z < size; z++) { + const top = bot.blockAt(empty_pos[i].offset(x, 0, z)) + const bottom = bot.blockAt(empty_pos[i].offset(x, -1, z)) + if (!top || !top.name == 'air' || !bottom || bottom.drops.length == 0 || !bottom.diggable) { + empty = false + break + } + } + if (!empty) + break + } + if (empty) { + return empty_pos[i] + } + } +} + +export function getNearestBlocks(bot, block_types = null, distance = 16, count = 10000) { + /** + * Get a list of the nearest blocks of the given types. + * @param {Bot} bot - The bot to get the nearest block for. + * @param {string[]} block_types - The names of the blocks to search for. + * @param {number} distance - The maximum distance to search, default 16. + * @param {number} count - The maximum number of blocks to find, default 10000. + * @returns {Block[]} - The nearest blocks of the given type. + * @example + * let woodBlocks = world.getNearestBlocks(bot, ['oak_log', 'birch_log'], 16, 1); + */ + // if blocktypes is not a list, make it a list + let block_ids = [] + if (block_types === null) { + block_ids = mc.getAllBlockIds(['air']) + } + else { + if (!Array.isArray(block_types)) + block_types = [block_types] + for (const block_type of block_types) { + block_ids.push(mc.getBlockId(block_type)) + } + } + + const positions = bot.findBlocks({ matching: block_ids, maxDistance: distance, count }) + const blocks = [] + for (let i = 0; i < positions.length; i++) { + const block = bot.blockAt(positions[i]) + const distance = positions[i].distanceTo(bot.entity.position) + blocks.push({ block, distance }) + } + blocks.sort((a, b) => a.distance - b.distance) + + const res = [] + for (let i = 0; i < blocks.length; i++) { + res.push(blocks[i].block) + } + return res +} + +export function getNearestBlock(bot, block_type, distance = 16) { + /** + * Get the nearest block of the given type. + * @param {Bot} bot - The bot to get the nearest block for. + * @param {string} block_type - The name of the block to search for. + * @param {number} distance - The maximum distance to search, default 16. + * @returns {Block} - The nearest block of the given type. + * @example + * let coalBlock = world.getNearestBlock(bot, 'coal_ore', 16); + */ + const blocks = getNearestBlocks(bot, block_type, distance, 1) + if (blocks.length > 0) { + return blocks[0] + } + return null +} + +export function getNearbyEntities(bot, maxDistance = 16) { + const entities = [] + for (const entity of Object.values(bot.entities)) { + const distance = entity.position.distanceTo(bot.entity.position) + if (distance > maxDistance) + continue + entities.push({ entity, distance }) + } + entities.sort((a, b) => a.distance - b.distance) + const res = [] + for (let i = 0; i < entities.length; i++) { + res.push(entities[i].entity) + } + return res +} + +export function getNearestEntityWhere(bot, predicate, maxDistance = 16) { + return bot.nearestEntity(entity => predicate(entity) && bot.entity.position.distanceTo(entity.position) < maxDistance) +} + +export function getNearbyPlayers(bot, maxDistance) { + if (maxDistance == null) + maxDistance = 16 + const players = [] + for (const entity of Object.values(bot.entities)) { + const distance = entity.position.distanceTo(bot.entity.position) + if (distance > maxDistance) + continue + if (entity.type == 'player' && entity.username != bot.username) { + players.push({ entity, distance }) + } + } + players.sort((a, b) => a.distance - b.distance) + const res = [] + for (let i = 0; i < players.length; i++) { + res.push(players[i].entity) + } + return res +} + +export function getInventoryStacks(bot) { + const inventory = [] + for (const item of bot.inventory.items()) { + if (item != null) { + inventory.push(item) + } + } + return inventory +} + +export function getInventoryCounts(bot) { + /** + * Get an object representing the bot's inventory. + * @param {Bot} bot - The bot to get the inventory for. + * @returns {object} - An object with item names as keys and counts as values. + * @example + * let inventory = world.getInventoryCounts(bot); + * let oakLogCount = inventory['oak_log']; + * let hasWoodenPickaxe = inventory['wooden_pickaxe'] > 0; + */ + const inventory = {} + for (const item of bot.inventory.items()) { + if (item != null) { + if (inventory[item.name] == null) { + inventory[item.name] = 0 + } + inventory[item.name] += item.count + } + } + return inventory +} + +export function getCraftableItems(bot) { + /** + * Get a list of all items that can be crafted with the bot's current inventory. + * @param {Bot} bot - The bot to get the craftable items for. + * @returns {string[]} - A list of all items that can be crafted. + * @example + * let craftableItems = world.getCraftableItems(bot); + */ + let table = getNearestBlock(bot, 'crafting_table') + if (!table) { + for (const item of bot.inventory.items()) { + if (item != null && item.name === 'crafting_table') { + table = item + break + } + } + } + const res = [] + for (const item of mc.getAllItems()) { + const recipes = bot.recipesFor(item.id, null, 1, table) + if (recipes.length > 0) + res.push(item.name) + } + return res +} + +export function getPosition(bot) { + /** + * Get your position in the world (Note that y is vertical). + * @param {Bot} bot - The bot to get the position for. + * @returns {Vec3} - An object with x, y, and x attributes representing the position of the bot. + * @example + * let position = world.getPosition(bot); + * let x = position.x; + */ + return bot.entity.position +} + +export function getNearbyEntityTypes(bot) { + /** + * Get a list of all nearby mob types. + * @param {Bot} bot - The bot to get nearby mobs for. + * @returns {string[]} - A list of all nearby mobs. + * @example + * let mobs = world.getNearbyEntityTypes(bot); + */ + const mobs = getNearbyEntities(bot, 16) + const found = [] + for (let i = 0; i < mobs.length; i++) { + if (!found.includes(mobs[i].name)) { + found.push(mobs[i].name) + } + } + return found +} + +export function getNearbyPlayerNames(bot) { + /** + * Get a list of all nearby player names. + * @param {Bot} bot - The bot to get nearby players for. + * @returns {string[]} - A list of all nearby players. + * @example + * let players = world.getNearbyPlayerNames(bot); + */ + const players = getNearbyPlayers(bot, 64) + const found = [] + for (let i = 0; i < players.length; i++) { + if (!found.includes(players[i].username) && players[i].username != bot.username) { + found.push(players[i].username) + } + } + return found +} + +export function getNearbyBlockTypes(bot, distance = 16) { + /** + * Get a list of all nearby block names. + * @param {Bot} bot - The bot to get nearby blocks for. + * @param {number} distance - The maximum distance to search, default 16. + * @returns {string[]} - A list of all nearby blocks. + * @example + * let blocks = world.getNearbyBlockTypes(bot); + */ + const blocks = getNearestBlocks(bot, null, distance) + const found = [] + for (let i = 0; i < blocks.length; i++) { + if (!found.includes(blocks[i].name)) { + found.push(blocks[i].name) + } + } + return found +} + +export async function isClearPath(bot, target) { + /** + * Check if there is a path to the target that requires no digging or placing blocks. + * @param {Bot} bot - The bot to get the path for. + * @param {Entity} target - The target to path to. + * @returns {boolean} - True if there is a clear path, false otherwise. + */ + const movements = new pf.Movements(bot) + movements.canDig = false + movements.canPlaceOn = false + const goal = new pf.goals.GoalNear(target.position.x, target.position.y, target.position.z, 1) + const path = await bot.pathfinder.getPathTo(movements, goal, 100) + return path.status === 'success' +} + +export function shouldPlaceTorch(bot) { + if (!bot.modes.isOn('torch_placing') || bot.interrupt_code) + return false + const pos = getPosition(bot) + // TODO: check light level instead of nearby torches, block.light is broken + let nearest_torch = getNearestBlock(bot, 'torch', 6) + if (!nearest_torch) + nearest_torch = getNearestBlock(bot, 'wall_torch', 6) + if (!nearest_torch) { + const block = bot.blockAt(pos) + const has_torch = bot.inventory.items().find(item => item.name === 'torch') + return has_torch && block?.name === 'air' + } + return false +} + +export function getBiomeName(bot) { + /** + * Get the name of the biome the bot is in. + * @param {Bot} bot - The bot to get the biome for. + * @returns {string} - The name of the biome. + * @example + * let biome = world.getBiomeName(bot); + */ + const biomeId = bot.world.getBiome(bot.entity.position) + return mc.getAllBiomes()[biomeId].name +} From 2575f453069d7e30f60e306b982e3bd01e20833b Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 00:41:15 +0800 Subject: [PATCH 15/77] fix: world and query --- services/minecraft/src/agents/query.ts | 85 +---- services/minecraft/src/components/status.ts | 4 + services/minecraft/src/composables/world.ts | 379 +++++++------------- services/minecraft/src/main.ts | 2 +- services/minecraft/src/prompts/agent.ts | 9 +- 5 files changed, 150 insertions(+), 329 deletions(-) diff --git a/services/minecraft/src/agents/query.ts b/services/minecraft/src/agents/query.ts index 57391df95..c64a78c95 100644 --- a/services/minecraft/src/agents/query.ts +++ b/services/minecraft/src/agents/query.ts @@ -1,32 +1,11 @@ -import type { Bot } from 'mineflayer' import type { BotContext } from '../composables/bot' import { z } from 'zod' -import { getStatus } from '../components/status' +import { getStatusToString } from '../components/status' +import * as world from '../composables/world' // Core types type QueryResult = string | Promise -// BotContext management -let ctx: QueryBotContext - -export function initQueryBotContext(BotContext: QueryBotContext): void { - ctx = BotContext -} - -interface QueryBotContext { - world: { - getBiomeName: (bot: Bot) => string - getNearbyPlayerNames: (bot: Bot) => string[] - getInventoryCounts: (bot: Bot) => Record - getNearbyBlockTypes: (bot: Bot) => string[] - getCraftableItems: (bot: Bot) => string[] - getNearbyEntityTypes: (bot: Bot) => string[] - } - convoManager: { - getInGameAgents: () => string[] - } -} - interface Query { readonly name: string readonly description: string @@ -51,9 +30,7 @@ function createStatsQuery(): Query { name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - return Array.from(getStatus(ctx).entries()).map(([key, value]) => `${key}: ${value}`).join('\n') - }, + perform: (ctx: BotContext) => (): string => getStatusToString(ctx), } } @@ -62,9 +39,9 @@ function createInventoryQuery(): Query { name: 'inventory', description: 'Get your bot\'s inventory.', schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => { - const { bot } = agent - const inventory = ctx.world.getInventoryCounts(bot) + perform: (ctx: BotContext) => (): string => { + const { bot } = ctx + const inventory = world.getInventoryCounts({ bot, botCtx: ctx }) const items = Object.entries(inventory) .map(([item, count]) => formatInventoryItem(item, count)) .join('') @@ -77,7 +54,7 @@ function createInventoryQuery(): Query { ].filter(Boolean).join('') return pad(`INVENTORY${items || ': Nothing'} -${agent.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} +${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} WEARING: ${wearing || 'Nothing'}`) }, } @@ -88,8 +65,8 @@ function createNearbyBlocksQuery(): Query { name: 'nearbyBlocks', description: 'Get the blocks near the bot.', schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => { - const blocks = ctx.world.getNearbyBlockTypes(agent.bot) + perform: (ctx: BotContext) => (): string => { + const blocks = world.getNearbyBlockTypes({ bot: ctx.bot, botCtx: ctx }) return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`) }, } @@ -100,8 +77,8 @@ function createCraftableQuery(): Query { name: 'craftable', description: 'Get the craftable items with the bot\'s inventory.', schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => { - const craftable = ctx.world.getCraftableItems(agent.bot) + perform: (ctx: BotContext) => (): string => { + const craftable = world.getCraftableItems({ bot: ctx.bot, botCtx: ctx }) return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`) }, } @@ -112,19 +89,16 @@ function createEntitiesQuery(): Query { name: 'entities', description: 'Get the nearby players and entities.', schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => { - const { bot } = agent - const players = ctx.world.getNearbyPlayerNames(bot) - .filter(p => !ctx.convoManager.getInGameAgents().includes(p)) - const bots = ctx.convoManager.getInGameAgents() - .filter(b => b !== agent.name) - const entities = ctx.world.getNearbyEntityTypes(bot) - .filter(e => e !== 'player' && e !== 'item') + perform: (ctx: BotContext) => (): string => { + const { bot } = ctx + const worldCtx = { bot, botCtx: ctx } + const players = world.getNearbyPlayerNames(worldCtx) + const entities = world.getNearbyEntityTypes(worldCtx) + .filter((e: string) => e !== 'player' && e !== 'item') const result = [ - ...players.map(p => `- Human player: ${p}`), - ...bots.map(b => `- Bot player: ${b}`), - ...entities.map(e => `- entities: ${e}`), + ...players.map((p: string) => `- Human player: ${p}`), + ...entities.map((e: string) => `- entities: ${e}`), ] return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) @@ -132,25 +106,6 @@ function createEntitiesQuery(): Query { } } -function createModesQuery(): Query { - return { - name: 'modes', - description: 'Get all available modes and their docs and see which are on/off.', - schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => agent.bot.modes.getDocs(), - } -} - -function createSavedPlacesQuery(): Query { - return { - name: 'savedPlaces', - description: 'List all saved locations.', - schema: z.object({}), - perform: (agent: QueryAgentBotContext) => (): string => - `Saved place names: ${agent.memory_bank.getKeys()}`, - } -} - // Export query list export const queryList: readonly Query[] = [ createStatsQuery(), @@ -158,6 +113,4 @@ export const queryList: readonly Query[] = [ createNearbyBlocksQuery(), createCraftableQuery(), createEntitiesQuery(), - createModesQuery(), - createSavedPlacesQuery(), ] as const diff --git a/services/minecraft/src/components/status.ts b/services/minecraft/src/components/status.ts index 0f7f7486f..f83345403 100644 --- a/services/minecraft/src/components/status.ts +++ b/services/minecraft/src/components/status.ts @@ -2,6 +2,10 @@ import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { registerCommand } from '../composables/command' +export function getStatusToString(ctx: BotContext): string { + return Array.from(getStatus(ctx).entries()).map(([key, value]) => `${key}: ${value}`).join('\n') +} + export function getStatus(ctx: BotContext): Map { const status = new Map() const pos = ctx.bot.entity.position diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index 0e873b295..9a83f0339 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -1,305 +1,174 @@ +import type { Bot } from 'mineflayer' +import type { Block } from 'prismarine-block' +import type { Entity } from 'prismarine-entity' +import type { Item } from 'prismarine-item' +import type { Vec3 } from 'vec3' +import type { BotContext } from './bot' import pf from 'mineflayer-pathfinder' - import * as mc from '../utils/mcdata' -export function getNearestFreeSpace(bot, size = 1, distance = 8) { - /** - * Get the nearest empty space with solid blocks beneath it of the given size. - * @param {Bot} bot - The bot to get the nearest free space for. - * @param {number} size - The (size x size) of the space to find, default 1. - * @param {number} distance - The maximum distance to search, default 8. - * @returns {Vec3} - The south west corner position of the nearest free space. - * @example - * let position = world.getNearestFreeSpace(bot, 1, 8); - */ - const empty_pos = bot.findBlocks({ - matching: (block) => { - return block && block.name == 'air' - }, +interface WorldContext { + bot: Bot + botCtx: BotContext +} + +export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distance: number = 8): Vec3 | undefined { + const emptyPositions = ctx.bot.findBlocks({ + matching: (block: Block) => block?.name === 'air', maxDistance: distance, count: 1000, }) - for (let i = 0; i < empty_pos.length; i++) { - let empty = true + + return emptyPositions.find((pos) => { for (let x = 0; x < size; x++) { for (let z = 0; z < size; z++) { - const top = bot.blockAt(empty_pos[i].offset(x, 0, z)) - const bottom = bot.blockAt(empty_pos[i].offset(x, -1, z)) - if (!top || !top.name == 'air' || !bottom || bottom.drops.length == 0 || !bottom.diggable) { - empty = false - break + const top = ctx.bot.blockAt(pos.offset(x, 0, z)) + const bottom = ctx.bot.blockAt(pos.offset(x, -1, z)) + if (!top || top.name !== 'air' || !bottom?.drops?.length || !bottom.diggable) { + return false } } - if (!empty) - break } - if (empty) { - return empty_pos[i] - } - } + return true + }) } -export function getNearestBlocks(bot, block_types = null, distance = 16, count = 10000) { - /** - * Get a list of the nearest blocks of the given types. - * @param {Bot} bot - The bot to get the nearest block for. - * @param {string[]} block_types - The names of the blocks to search for. - * @param {number} distance - The maximum distance to search, default 16. - * @param {number} count - The maximum number of blocks to find, default 10000. - * @returns {Block[]} - The nearest blocks of the given type. - * @example - * let woodBlocks = world.getNearestBlocks(bot, ['oak_log', 'birch_log'], 16, 1); - */ - // if blocktypes is not a list, make it a list - let block_ids = [] - if (block_types === null) { - block_ids = mc.getAllBlockIds(['air']) - } - else { - if (!Array.isArray(block_types)) - block_types = [block_types] - for (const block_type of block_types) { - block_ids.push(mc.getBlockId(block_type)) - } - } +export function getNearestBlocks(ctx: WorldContext, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { + const blockIds = blockTypes === null + ? mc.getAllBlockIds(['air']) + : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId) - const positions = bot.findBlocks({ matching: block_ids, maxDistance: distance, count }) - const blocks = [] - for (let i = 0; i < positions.length; i++) { - const block = bot.blockAt(positions[i]) - const distance = positions[i].distanceTo(bot.entity.position) - blocks.push({ block, distance }) - } - blocks.sort((a, b) => a.distance - b.distance) + const positions = ctx.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) - const res = [] - for (let i = 0; i < blocks.length; i++) { - res.push(blocks[i].block) - } - return res + return positions + .map((pos) => { + const block = ctx.bot.blockAt(pos) + const dist = pos.distanceTo(ctx.bot.entity.position) + return block ? { block, distance: dist } : null + }) + .filter((item): item is { block: Block, distance: number } => item !== null) + .sort((a, b) => a.distance - b.distance) + .map(item => item.block) } -export function getNearestBlock(bot, block_type, distance = 16) { - /** - * Get the nearest block of the given type. - * @param {Bot} bot - The bot to get the nearest block for. - * @param {string} block_type - The name of the block to search for. - * @param {number} distance - The maximum distance to search, default 16. - * @returns {Block} - The nearest block of the given type. - * @example - * let coalBlock = world.getNearestBlock(bot, 'coal_ore', 16); - */ - const blocks = getNearestBlocks(bot, block_type, distance, 1) - if (blocks.length > 0) { - return blocks[0] - } - return null +export function getNearestBlock(ctx: WorldContext, blockType: string, distance: number = 16): Block | null { + const blocks = getNearestBlocks(ctx, blockType, distance, 1) + return blocks[0] || null } -export function getNearbyEntities(bot, maxDistance = 16) { - const entities = [] - for (const entity of Object.values(bot.entities)) { - const distance = entity.position.distanceTo(bot.entity.position) - if (distance > maxDistance) - continue - entities.push({ entity, distance }) - } - entities.sort((a, b) => a.distance - b.distance) - const res = [] - for (let i = 0; i < entities.length; i++) { - res.push(entities[i].entity) - } - return res +export function getNearbyEntities(ctx: WorldContext, maxDistance: number = 16): Entity[] { + return Object.values(ctx.bot.entities) + .filter((entity): entity is Entity => + entity !== null + && entity.position.distanceTo(ctx.bot.entity.position) <= maxDistance, + ) + .sort((a, b) => + a.position.distanceTo(ctx.bot.entity.position) + - b.position.distanceTo(ctx.bot.entity.position), + ) } -export function getNearestEntityWhere(bot, predicate, maxDistance = 16) { - return bot.nearestEntity(entity => predicate(entity) && bot.entity.position.distanceTo(entity.position) < maxDistance) +export function getNearestEntityWhere(ctx: WorldContext, predicate: (entity: Entity) => boolean, maxDistance: number = 16): Entity | null { + return ctx.bot.nearestEntity(entity => + predicate(entity) + && ctx.bot.entity.position.distanceTo(entity.position) < maxDistance, + ) } -export function getNearbyPlayers(bot, maxDistance) { - if (maxDistance == null) - maxDistance = 16 - const players = [] - for (const entity of Object.values(bot.entities)) { - const distance = entity.position.distanceTo(bot.entity.position) - if (distance > maxDistance) - continue - if (entity.type == 'player' && entity.username != bot.username) { - players.push({ entity, distance }) - } - } - players.sort((a, b) => a.distance - b.distance) - const res = [] - for (let i = 0; i < players.length; i++) { - res.push(players[i].entity) - } - return res +export function getNearbyPlayers(ctx: WorldContext, maxDistance: number = 16): Entity[] { + return getNearbyEntities(ctx, maxDistance) + .filter(entity => + entity.type === 'player' + && entity.username !== ctx.bot.username, + ) } -export function getInventoryStacks(bot) { - const inventory = [] - for (const item of bot.inventory.items()) { - if (item != null) { - inventory.push(item) - } - } - return inventory +export function getInventoryStacks(ctx: WorldContext): Item[] { + return ctx.bot.inventory.items().filter((item): item is Item => item !== null) } -export function getInventoryCounts(bot) { - /** - * Get an object representing the bot's inventory. - * @param {Bot} bot - The bot to get the inventory for. - * @returns {object} - An object with item names as keys and counts as values. - * @example - * let inventory = world.getInventoryCounts(bot); - * let oakLogCount = inventory['oak_log']; - * let hasWoodenPickaxe = inventory['wooden_pickaxe'] > 0; - */ - const inventory = {} - for (const item of bot.inventory.items()) { - if (item != null) { - if (inventory[item.name] == null) { - inventory[item.name] = 0 - } - inventory[item.name] += item.count - } - } - return inventory +export function getInventoryCounts(ctx: WorldContext): Record { + return getInventoryStacks(ctx).reduce((counts, item) => { + counts[item.name] = (counts[item.name] || 0) + item.count + return counts + }, {} as Record) } -export function getCraftableItems(bot) { - /** - * Get a list of all items that can be crafted with the bot's current inventory. - * @param {Bot} bot - The bot to get the craftable items for. - * @returns {string[]} - A list of all items that can be crafted. - * @example - * let craftableItems = world.getCraftableItems(bot); - */ - let table = getNearestBlock(bot, 'crafting_table') - if (!table) { - for (const item of bot.inventory.items()) { - if (item != null && item.name === 'crafting_table') { - table = item - break - } - } - } - const res = [] - for (const item of mc.getAllItems()) { - const recipes = bot.recipesFor(item.id, null, 1, table) - if (recipes.length > 0) - res.push(item.name) - } - return res +export function getCraftableItems(ctx: WorldContext): string[] { + const table = getNearestBlock(ctx, 'crafting_table') + || getInventoryStacks(ctx).find(item => item.name === 'crafting_table') + + return mc.getAllItems() + .filter(item => ctx.bot.recipesFor(item.id, null, 1, table).length > 0) + .map(item => item.name) } -export function getPosition(bot) { - /** - * Get your position in the world (Note that y is vertical). - * @param {Bot} bot - The bot to get the position for. - * @returns {Vec3} - An object with x, y, and x attributes representing the position of the bot. - * @example - * let position = world.getPosition(bot); - * let x = position.x; - */ - return bot.entity.position +export function getPosition(ctx: WorldContext): Vec3 { + return ctx.bot.entity.position } -export function getNearbyEntityTypes(bot) { - /** - * Get a list of all nearby mob types. - * @param {Bot} bot - The bot to get nearby mobs for. - * @returns {string[]} - A list of all nearby mobs. - * @example - * let mobs = world.getNearbyEntityTypes(bot); - */ - const mobs = getNearbyEntities(bot, 16) - const found = [] - for (let i = 0; i < mobs.length; i++) { - if (!found.includes(mobs[i].name)) { - found.push(mobs[i].name) - } - } - return found +export function getNearbyEntityTypes(ctx: WorldContext): string[] { + return [...new Set( + getNearbyEntities(ctx, 16) + .map(mob => mob.name) + .filter((name): name is string => name !== undefined), + )] } -export function getNearbyPlayerNames(bot) { - /** - * Get a list of all nearby player names. - * @param {Bot} bot - The bot to get nearby players for. - * @returns {string[]} - A list of all nearby players. - * @example - * let players = world.getNearbyPlayerNames(bot); - */ - const players = getNearbyPlayers(bot, 64) - const found = [] - for (let i = 0; i < players.length; i++) { - if (!found.includes(players[i].username) && players[i].username != bot.username) { - found.push(players[i].username) - } - } - return found +export function getNearbyPlayerNames(ctx: WorldContext): string[] { + return [...new Set( + getNearbyPlayers(ctx, 64) + .map(player => player.username) + .filter((name): name is string => + name !== undefined + && name !== ctx.bot.username, + ), + )] } -export function getNearbyBlockTypes(bot, distance = 16) { - /** - * Get a list of all nearby block names. - * @param {Bot} bot - The bot to get nearby blocks for. - * @param {number} distance - The maximum distance to search, default 16. - * @returns {string[]} - A list of all nearby blocks. - * @example - * let blocks = world.getNearbyBlockTypes(bot); - */ - const blocks = getNearestBlocks(bot, null, distance) - const found = [] - for (let i = 0; i < blocks.length; i++) { - if (!found.includes(blocks[i].name)) { - found.push(blocks[i].name) - } - } - return found +export function getNearbyBlockTypes(ctx: WorldContext, distance: number = 16): string[] { + return [...new Set( + getNearestBlocks(ctx, null, distance) + .map(block => block.name), + )] } -export async function isClearPath(bot, target) { - /** - * Check if there is a path to the target that requires no digging or placing blocks. - * @param {Bot} bot - The bot to get the path for. - * @param {Entity} target - The target to path to. - * @returns {boolean} - True if there is a clear path, false otherwise. - */ - const movements = new pf.Movements(bot) +export async function isClearPath(ctx: WorldContext, target: Entity): Promise { + const movements = new pf.Movements(ctx.bot) movements.canDig = false movements.canPlaceOn = false - const goal = new pf.goals.GoalNear(target.position.x, target.position.y, target.position.z, 1) - const path = await bot.pathfinder.getPathTo(movements, goal, 100) + + const goal = new pf.goals.GoalNear( + target.position.x, + target.position.y, + target.position.z, + 1, + ) + + const path = await ctx.bot.pathfinder.getPathTo(movements, goal, 100) return path.status === 'success' } -export function shouldPlaceTorch(bot) { - if (!bot.modes.isOn('torch_placing') || bot.interrupt_code) +export function shouldPlaceTorch(ctx: WorldContext): boolean { + // if (!ctx.bot.modes.isOn('torch_placing') || ctx.bot.interrupt_code) { + // return false + // } + + const pos = getPosition(ctx) + const nearestTorch = getNearestBlock(ctx, 'torch', 6) + || getNearestBlock(ctx, 'wall_torch', 6) + + if (nearestTorch) { return false - const pos = getPosition(bot) - // TODO: check light level instead of nearby torches, block.light is broken - let nearest_torch = getNearestBlock(bot, 'torch', 6) - if (!nearest_torch) - nearest_torch = getNearestBlock(bot, 'wall_torch', 6) - if (!nearest_torch) { - const block = bot.blockAt(pos) - const has_torch = bot.inventory.items().find(item => item.name === 'torch') - return has_torch && block?.name === 'air' } - return false + + const block = ctx.bot.blockAt(pos) + const hasTorch = ctx.bot.inventory.items().some(item => item?.name === 'torch') + + return Boolean(hasTorch && block?.name === 'air') } -export function getBiomeName(bot) { - /** - * Get the name of the biome the bot is in. - * @param {Bot} bot - The bot to get the biome for. - * @returns {string} - The name of the biome. - * @example - * let biome = world.getBiomeName(bot); - */ - const biomeId = bot.world.getBiome(bot.entity.position) +export function getBiomeName(ctx: WorldContext): string { + const biomeId = ctx.bot.world.getBiome(ctx.bot.entity.position) return mc.getAllBiomes()[biomeId].name } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index ef0ea2e7a..4ab245a74 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -28,7 +28,7 @@ async function main() { registerComponent('command', createCommandComponent) }) - initAgent() + initAgent(ctx) process.on('SIGINT', () => { cleanup() diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 990340b2d..8c41887c0 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,4 +1,5 @@ import type { BotContext } from '../composables/bot' +import { getStatusToString } from '../components/status' export function basicSystemPrompt(botName: string): string { return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, @@ -40,13 +41,7 @@ task to do in Minecraft. My ultimate goal is to discover as many things as possi accomplish as many tasks as possible and become the best Minecraft player in the world. I will give you the following information: -${Array.from(ctx.status.entries()).map(([key, value]) => `${key}: ${value}`).join('\n')} - -Then you can choose some of the tools to use. Use the valid JS call function to call the tool. - -## For example: -### Get the stats -stats() +${getStatusToString(ctx)} ` return prompt From fa3a0fef539b7e0cb17f0d260fbc5774f176699c Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Tue, 7 Jan 2025 01:06:07 +0800 Subject: [PATCH 16/77] feat: ticker --- services/minecraft/src/agents/query.ts | 8 ++-- services/minecraft/src/composables/bot.ts | 51 ++++++++++++++++++-- services/minecraft/src/main.ts | 14 ++++-- services/minecraft/src/utils/ticker.ts | 58 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 services/minecraft/src/utils/ticker.ts diff --git a/services/minecraft/src/agents/query.ts b/services/minecraft/src/agents/query.ts index c64a78c95..b872a5d24 100644 --- a/services/minecraft/src/agents/query.ts +++ b/services/minecraft/src/agents/query.ts @@ -109,8 +109,8 @@ function createEntitiesQuery(): Query { // Export query list export const queryList: readonly Query[] = [ createStatsQuery(), - createInventoryQuery(), - createNearbyBlocksQuery(), - createCraftableQuery(), - createEntitiesQuery(), + // createInventoryQuery(), + // createNearbyBlocksQuery(), + // createCraftableQuery(), + // createEntitiesQuery(), ] as const diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 782cf19d8..ee0ebf649 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -7,16 +7,25 @@ let ctx: BotContext | undefined export interface BotContext { bot: Bot + botName: string + components: Map - botName: string prompt: { selfPrompt: string } + memory: { getSummary: () => string } + status: Map + + health: { + value: number + lastDamageTime: number + lastDamageTaken: number + } } export interface Component { @@ -45,16 +54,52 @@ export function createBot(options: BotOptions): Bot { getSummary: () => '', }, status: new Map(), + health: { + value: 20, + lastDamageTime: 0, + lastDamageTaken: 0, + }, } - ctx.bot.on('error', (err: Error) => { - logger.errorWithError('Bot error:', err) + ctx.bot.on('health', () => { + if (!ctx) + return + + logger.withFields({ + health: ctx.health.value, + lastDamageTime: ctx.health.lastDamageTime, + lastDamageTaken: ctx.health.lastDamageTaken, + previousHealth: ctx.bot.health, + }).log('Health updated') + + if (ctx.bot.health < ctx.health.value) { + ctx.health.lastDamageTime = Date.now() + ctx.health.lastDamageTaken = ctx.health.value - ctx.bot.health + } + + ctx.health.value = ctx.bot.health + }) + + ctx.bot.on('death', () => { + logger.error('Bot died') + }) + + ctx.bot.on('messagestr', () => { + + }) + + ctx.bot.on('end', (reason) => { + logger.withFields({ reason }).log('Bot ended') }) ctx.bot.on('kicked', (reason: string) => { logger.withFields({ reason }).error('Bot was kicked') }) + ctx.bot.on('error', (err: Error) => { + logger.errorWithError('Bot error:', err) + }) + logger.log('Bot created') return ctx.bot } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 4ab245a74..f1134bea7 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,4 +1,4 @@ -import process from 'node:process' +import process, { exit } from 'node:process' import { useLogg } from '@guiiai/logg' @@ -10,6 +10,7 @@ import { createStatusComponent } from './components/status' import { createBot, useBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' import { initLogger } from './utils/logger' +import { createTicker } from './utils/ticker' const logger = useLogg('main').useGlobalConfig() @@ -28,15 +29,20 @@ async function main() { registerComponent('command', createCommandComponent) }) - initAgent(ctx) + await initAgent(ctx) + + const ticker = createTicker() + ticker.on('tick', async ({ delta }) => { + logger.log(`Tick ${delta}ms`) + }) process.on('SIGINT', () => { cleanup() - process.exit(0) + exit(0) }) } main().catch((err: Error) => { logger.errorWithError('Fatal error', err) - process.exit(1) + exit(1) }) diff --git a/services/minecraft/src/utils/ticker.ts b/services/minecraft/src/utils/ticker.ts new file mode 100644 index 000000000..735187c80 --- /dev/null +++ b/services/minecraft/src/utils/ticker.ts @@ -0,0 +1,58 @@ +export interface TickContext { + delta: number + nextTick: () => Promise +} + +export interface TickEventHandlers { + tick: (ctx: TickContext) => void +} + +export type TickEvents = keyof TickEventHandlers +export type TickEventsHandler = TickEventHandlers[K] + +// This update loop ensures that each update() is called one at a time, even if it takes longer than the interval +export function createTicker(options?: { interval?: number }) { + const { interval = 300 } = options ?? { interval: 300 } + + let last = Date.now() + const tickingCbs: Record> = { + tick: [], + } + + setTimeout(async () => { + while (true) { + const start = Date.now() + const nextTickPromise = new Promise((resolve) => { + // Schedule nextTick resolution for after all callbacks complete + setImmediate(resolve) + }) + + // Run all callbacks without awaiting them + const callbackPromises = tickingCbs.tick.map(cb => cb({ + delta: start - last, + nextTick: () => nextTickPromise, + })) + + // Wait for all callbacks to complete or timeout + await Promise.race([ + Promise.all(callbackPromises), + new Promise(resolve => + setTimeout(resolve, interval), + ), + ]) + + const remaining = interval - (Date.now() - start) + if (remaining > 0) { + await new Promise(resolve => setTimeout(resolve, remaining)) + } + + last = start + } + }, interval) + + return { + on(event: K, cb: TickEventsHandler) { + tickingCbs[event].push(cb) + }, + } +} From b0dd132fbf3615d3a8322bf808cc8377e1f06e8e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 01:09:51 +0800 Subject: [PATCH 17/77] feat: skills --- services/minecraft/src/agents/actions.ts | 583 ++++++++++++++++++ services/minecraft/src/agents/openai.ts | 2 +- .../src/agents/{query.ts => queries.ts} | 0 services/minecraft/src/skills/base.ts | 22 + services/minecraft/src/skills/blocks.ts | 396 ++++++++++++ services/minecraft/src/skills/combat.ts | 133 ++++ services/minecraft/src/skills/crafting.ts | 268 ++++++++ services/minecraft/src/skills/index.ts | 50 ++ services/minecraft/src/skills/inventory.ts | 257 ++++++++ services/minecraft/src/skills/movement.ts | 208 +++++++ 10 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 services/minecraft/src/agents/actions.ts rename services/minecraft/src/agents/{query.ts => queries.ts} (100%) create mode 100644 services/minecraft/src/skills/base.ts create mode 100644 services/minecraft/src/skills/blocks.ts create mode 100644 services/minecraft/src/skills/combat.ts create mode 100644 services/minecraft/src/skills/crafting.ts create mode 100644 services/minecraft/src/skills/index.ts create mode 100644 services/minecraft/src/skills/inventory.ts create mode 100644 services/minecraft/src/skills/movement.ts diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts new file mode 100644 index 000000000..25e2997b8 --- /dev/null +++ b/services/minecraft/src/agents/actions.ts @@ -0,0 +1,583 @@ +import type { BotContext } from 'src/composables/bot' +import { z } from 'zod' +import * as skills from '../skills' + +function runAsAction(actionFn, resume = false, timeout = -1) { + let actionLabel = null // Will be set on first use + + const wrappedAction = async function (agent, ...args) { + // Set actionLabel only once, when the action is first created + if (!actionLabel) { + const actionObj = actionsList.find(a => a.perform === wrappedAction) + actionLabel = actionObj.name.substring(1) // Remove the ! prefix + } + + const actionFnWithAgent = async () => { + await actionFn(agent, ...args) + } + const code_return = await agent.actions.runAction(`action:${actionLabel}`, actionFnWithAgent, { timeout, resume }) + if (code_return.interrupted && !code_return.timedout) + return + return code_return.message + } + + return wrappedAction +} + +type ActionResult = string | Promise + +interface Action { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (ctx: BotContext) => () => ActionResult +} + +function getNewAction(): Action { + return { + name: '!newAction', + description: 'Perform new and unknown custom behaviors that are not available as a command.', + schema: z.object({ + prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), + }), + perform: (agent: BotContext) => async () => { + if (!settings.allow_insecure_coding) + return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' + return await agent.coder.generateCode(agent.history) + }, + } +} + +function getStopAction(): Action { + return { + name: '!stop', + description: 'Force stop all actions and commands that are currently executing.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + await agent.actions.stop() + agent.clearBotLogs() + agent.actions.cancelResume() + agent.bot.emit('idle') + let msg = 'Agent stopped.' + if (agent.self_prompter.on) + msg += ' Self-prompting still active.' + return msg + }, + } +} + +function getStfuAction(): Action { + return { + name: '!stfu', + description: 'Stop all chatting and self prompting, but continue current action.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.openChat('Shutting up.') + agent.shutUp() + }, + } +} + +function getRestartAction(): Action { + return { + name: '!restart', + description: 'Restart the agent process.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.cleanKill() + }, + } +} + +function getClearChatAction(): Action { + return { + name: '!clearChat', + description: 'Clear the chat history.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.history.clear() + return `${agent.name}'s chat history was cleared, starting new conversation from scratch.` + }, + } +} + +function getGoToPlayerAction(): Action { + return { + name: '!goToPlayer', + description: 'Go to the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to go to.'), + closeness: z.number().describe('How close to get to the player.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, closeness) => { + return await skills.goToPlayer(agent.bot, player_name, closeness) + }), + } +} + +function getFollowPlayerAction(): Action { + return { + name: '!followPlayer', + description: 'Endlessly follow the given player.', + schema: z.object({ + player_name: z.string().describe('name of the player to follow.'), + follow_dist: z.number().describe('The distance to follow from.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, follow_dist) => { + await skills.followPlayer(agent.bot, player_name, follow_dist) + }, true), + } +} + +function getGoToCoordinatesAction(): Action { + return { + name: '!goToCoordinates', + description: 'Go to the given x, y, z location.', + schema: z.object({ + x: z.number().describe('The x coordinate.'), + y: z.number().describe('The y coordinate.').min(-64).max(320), + z: z.number().describe('The z coordinate.'), + closeness: z.number().describe('How close to get to the location.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, x, y, z, closeness) => { + await skills.goToPosition(agent.bot, x, y, z, closeness) + }), + } +} + +function getSearchForBlockAction(): Action { + return { + name: '!searchForBlock', + description: 'Find and go to the nearest block of a given type in a given range.', + schema: z.object({ + type: z.string().describe('The block type to go to.'), + search_range: z.number().describe('The range to search for the block.').min(32).max(512), + }), + perform: (agent: BotContext) => runAsAction(async (agent, block_type, range) => { + await skills.goToNearestBlock(agent.bot, block_type, 4, range) + }), + } +} + +function getSearchForEntityAction(): Action { + return { + name: '!searchForEntity', + description: 'Find and go to the nearest entity of a given type in a given range.', + schema: z.object({ + type: z.string().describe('The type of entity to go to.'), + search_range: z.number().describe('The range to search for the entity.').min(32).max(512), + }), + perform: (agent: BotContext) => runAsAction(async (agent, entity_type, range) => { + await skills.goToNearestEntity(agent.bot, entity_type, 4, range) + }), + } +} + +function getMoveAwayAction(): Action { + return { + name: '!moveAway', + description: 'Move away from the current location in any direction by a given distance.', + schema: z.object({ + distance: z.number().describe('The distance to move away.').min(0), + }), + perform: (agent: BotContext) => runAsAction(async (agent, distance) => { + await skills.moveAway(agent.bot, distance) + }), + } +} + +function getRememberHereAction(): Action { + return { + name: '!rememberHere', + description: 'Save the current location with a given name.', + schema: z.object({ + name: z.string().describe('The name to remember the location as.'), + }), + perform: (agent: BotContext) => async (name: string) => { + const pos = agent.bot.entity.position + agent.memory_bank.rememberPlace(name, pos.x, pos.y, pos.z) + return `Location saved as "${name}".` + }, + } +} + +function getGoToRememberedPlaceAction(): Action { + return { + name: '!goToRememberedPlace', + description: 'Go to a saved location.', + schema: z.object({ + name: z.string().describe('The name of the location to go to.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, name) => { + const pos = agent.memory_bank.recallPlace(name) + if (!pos) { + skills.log(agent.bot, `No location named "${name}" saved.`) + return + } + await skills.goToPosition(agent.bot, pos[0], pos[1], pos[2], 1) + }), + } +} + +function getGivePlayerAction(): Action { + return { + name: '!givePlayer', + description: 'Give the specified item to the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to give the item to.'), + item_name: z.string().describe('The name of the item to give.'), + num: z.number().int().describe('The number of items to give.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name, item_name, num) => { + await skills.giveToPlayer(agent.bot, item_name, player_name, num) + }), + } +} + +function getConsumeAction(): Action { + return { + name: '!consume', + description: 'Eat/drink the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to consume.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { + await skills.consume(agent.bot, item_name) + }), + } +} + +function getEquipAction(): Action { + return { + name: '!equip', + description: 'Equip the given item.', + schema: z.object({ + item_name: z.string().describe('The name of the item to equip.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { + await skills.equip(agent.bot, item_name) + }), + } +} + +function getPutInChestAction(): Action { + return { + name: '!putInChest', + description: 'Put the given item in the nearest chest.', + schema: z.object({ + item_name: z.string().describe('The name of the item to put in the chest.'), + num: z.number().int().describe('The number of items to put in the chest.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + await skills.putInChest(agent.bot, item_name, num) + }), + } +} + +function getTakeFromChestAction(): Action { + return { + name: '!takeFromChest', + description: 'Take the given items from the nearest chest.', + schema: z.object({ + item_name: z.string().describe('The name of the item to take.'), + num: z.number().int().describe('The number of items to take.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + await skills.takeFromChest(agent.bot, item_name, num) + }), + } +} + +function getViewChestAction(): Action { + return { + name: '!viewChest', + description: 'View the items/counts of the nearest chest.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.viewChest(agent.bot) + }), + } +} + +function getDiscardAction(): Action { + return { + name: '!discard', + description: 'Discard the given item from the inventory.', + schema: z.object({ + item_name: z.string().describe('The name of the item to discard.'), + num: z.number().int().describe('The number of items to discard.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + const start_loc = agent.bot.entity.position + await skills.moveAway(agent.bot, 5) + await skills.discard(agent.bot, item_name, num) + await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0) + }), + } +} + +function getCollectBlocksAction(): Action { + return { + name: '!collectBlocks', + description: 'Collect the nearest blocks of a given type.', + schema: z.object({ + type: z.string().describe('The block type to collect.'), + num: z.number().int().describe('The number of blocks to collect.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type, num) => { + await skills.collectBlock(agent.bot, type, num) + }, false, 10), // 10 minute timeout + } +} + +function getCraftRecipeAction(): Action { + return { + name: '!craftRecipe', + description: 'Craft the given recipe a given number of times.', + schema: z.object({ + recipe_name: z.string().describe('The name of the output item to craft.'), + num: z.number().int().describe('The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, recipe_name, num) => { + await skills.craftRecipe(agent.bot, recipe_name, num) + }), + } +} + +function getSmeltItemAction(): Action { + return { + name: '!smeltItem', + description: 'Smelt the given item the given number of times.', + schema: z.object({ + item_name: z.string().describe('The name of the input item to smelt.'), + num: z.number().int().describe('The number of times to smelt the item.').min(1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { + const success = await skills.smeltItem(agent.bot, item_name, num) + if (success) { + setTimeout(() => { + agent.cleanKill('Safely restarting to update inventory.') + }, 500) + } + }), + } +} + +function getClearFurnaceAction(): Action { + return { + name: '!clearFurnace', + description: 'Take all items out of the nearest furnace.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.clearNearestFurnace(agent.bot) + }), + } +} + +function getPlaceHereAction(): Action { + return { + name: '!placeHere', + description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.', + schema: z.object({ + type: z.string().describe('The block type to place.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + const pos = agent.bot.entity.position + await skills.placeBlock(agent.bot, type, pos.x, pos.y, pos.z) + }), + } +} + +function getAttackAction(): Action { + return { + name: '!attack', + description: 'Attack and kill the nearest entity of a given type.', + schema: z.object({ + type: z.string().describe('The type of entity to attack.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + await skills.attackNearest(agent.bot, type, true) + }), + } +} + +function getAttackPlayerAction(): Action { + return { + name: '!attackPlayer', + description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', + schema: z.object({ + player_name: z.string().describe('The name of the player to attack.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, player_name) => { + const player = agent.bot.players[player_name]?.entity + if (!player) { + skills.log(agent.bot, `Could not find player ${player_name}.`) + return false + } + await skills.attackEntity(agent.bot, player, true) + }), + } +} + +function getGoToBedAction(): Action { + return { + name: '!goToBed', + description: 'Go to the nearest bed and sleep.', + schema: z.object({}), + perform: (agent: BotContext) => runAsAction(async (agent) => { + await skills.goToBed(agent.bot) + }), + } +} + +function getActivateAction(): Action { + return { + name: '!activate', + description: 'Activate the nearest object of a given type.', + schema: z.object({ + type: z.string().describe('The type of object to activate.'), + }), + perform: (agent: BotContext) => runAsAction(async (agent, type) => { + await skills.activateNearestBlock(agent.bot, type) + }), + } +} + +function getStayAction(): Action { + return { + name: '!stay', + description: 'Stay in the current location no matter what. Pauses all modes.', + schema: z.object({ + type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), + }), + perform: (agent: BotContext) => runAsAction(async (agent, seconds) => { + await skills.stay(agent.bot, seconds) + }), + } +} + +function getSetModeAction(): Action { + return { + name: '!setMode', + description: 'Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.', + schema: z.object({ + mode_name: z.string().describe('The name of the mode to enable.'), + on: z.boolean().describe('Whether to enable or disable the mode.'), + }), + perform: (agent: BotContext) => async (mode_name: string, on: boolean) => { + const modes = agent.bot.modes + if (!modes.exists(mode_name)) + return `Mode ${mode_name} does not exist.${modes.getDocs()}` + if (modes.isOn(mode_name) === on) + return `Mode ${mode_name} is already ${on ? 'on' : 'off'}.` + modes.setOn(mode_name, on) + return `Mode ${mode_name} is now ${on ? 'on' : 'off'}.` + }, + } +} + +function getGoalAction(): Action { + return { + name: '!goal', + description: 'Set a goal prompt to endlessly work towards with continuous self-prompting.', + schema: z.object({ + selfPrompt: z.string().describe('The goal prompt.'), + }), + perform: (agent: BotContext) => async (prompt: string) => { + if (convoManager.inConversation()) { + agent.self_prompter.setPrompt(prompt) + convoManager.scheduleSelfPrompter() + } + else { + agent.self_prompter.start(prompt) + } + }, + } +} + +function getEndGoalAction(): Action { + return { + name: '!endGoal', + description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', + schema: z.object({}), + perform: (agent: BotContext) => async () => { + agent.self_prompter.stop() + convoManager.cancelSelfPrompter() + return 'Self-prompting stopped.' + }, + } +} + +function getStartConversationAction(): Action { + return { + name: '!startConversation', + description: 'Start a conversation with a player. Use for bots only.', + schema: z.object({ + player_name: z.string().describe('The name of the player to send the message to.'), + message: z.string().describe('The message to send.'), + }), + perform: (agent: BotContext) => async (player_name: string, message: string) => { + if (!convoManager.isOtherAgent(player_name)) + return `${player_name} is not a bot, cannot start conversation.` + if (convoManager.inConversation() && !convoManager.inConversation(player_name)) + convoManager.forceEndCurrentConversation() + else if (convoManager.inConversation(player_name)) + agent.history.add('system', `You are already in conversation with ${player_name}. Don't use this command to talk to them.`) + convoManager.startConversation(player_name, message) + }, + } +} + +function getEndConversationAction(): Action { + return { + name: '!endConversation', + description: 'End the conversation with the given player.', + schema: z.object({ + player_name: z.string().describe('The name of the player to end the conversation with.'), + }), + perform: (agent: BotContext) => async (player_name: string) => { + if (!convoManager.inConversation(player_name)) + return `Not in conversation with ${player_name}.` + convoManager.endConversation(player_name) + return `Converstaion with ${player_name} ended.` + }, + } +} + +export const actionsList = [ + getNewAction(), + getStopAction(), + getStfuAction(), + getRestartAction(), + getClearChatAction(), + getGoToPlayerAction(), + getFollowPlayerAction(), + getGoToCoordinatesAction(), + getSearchForBlockAction(), + getSearchForEntityAction(), + getMoveAwayAction(), + getRememberHereAction(), + getGoToRememberedPlaceAction(), + getGivePlayerAction(), + getConsumeAction(), + getEquipAction(), + getPutInChestAction(), + getTakeFromChestAction(), + getViewChestAction(), + getDiscardAction(), + getCollectBlocksAction(), + getCraftRecipeAction(), + getSmeltItemAction(), + getClearFurnaceAction(), + getPlaceHereAction(), + getAttackAction(), + getAttackPlayerAction(), + getGoToBedAction(), + getActivateAction(), + getStayAction(), + getSetModeAction(), + getGoalAction(), + getEndGoalAction(), + getStartConversationAction(), + getEndConversationAction(), +] diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 991e99fd9..6989b7999 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -3,7 +3,7 @@ import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { queryList } from './query' +import { queryList } from './queries' const agents = new Set>() diff --git a/services/minecraft/src/agents/query.ts b/services/minecraft/src/agents/queries.ts similarity index 100% rename from services/minecraft/src/agents/query.ts rename to services/minecraft/src/agents/queries.ts diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts new file mode 100644 index 000000000..ed7ae041c --- /dev/null +++ b/services/minecraft/src/skills/base.ts @@ -0,0 +1,22 @@ +import type { Bot } from 'mineflayer' + +/** + * Log a message to the bot's output + */ +export function log(bot: Bot, message: string): void { + bot.chat(`${message}`) +} + +/** + * Type definition for a position in the world + */ +export interface Position { + x: number + y: number + z: number +} + +/** + * Type definition for a block face direction + */ +export type BlockFace = 'top' | 'bottom' | 'north' | 'south' | 'east' | 'west' | 'side' diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts new file mode 100644 index 000000000..a96e97e51 --- /dev/null +++ b/services/minecraft/src/skills/blocks.ts @@ -0,0 +1,396 @@ +import type { Bot } from 'mineflayer' +import type { BlockFace } from './base' +import Vec3 from 'vec3' +import * as world from '../composables/world' +import * as mc from '../utils/mcdata' +import { log } from './base' +import { goToPosition } from './movement' + +/** + * Place a torch if needed + */ +async function autoLight(bot: Bot): Promise { + if (world.shouldPlaceTorch(bot)) { + try { + const pos = world.getPosition(bot) + return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true) + } + catch { + return false + } + } + return false +} + +/** + * Break a block at the specified position + */ +export async function breakBlockAt( + bot: Bot, + x: number, + y: number, + z: number, +): Promise { + if (x == null || y == null || z == null) { + throw new Error('Invalid position to break block at.') + } + + const block = bot.blockAt(new Vec3(x, y, z)) + if (block.name === 'air' || block.name === 'water' || block.name === 'lava') { + return false + } + + if (bot.modes.isOn('cheat')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + log(bot, `Used /setblock to break block at ${x}, ${y}, ${z}.`) + return true + } + + if (bot.entity.position.distanceTo(block.position) > 4.5) { + const pos = block.position + const movements = new bot.pathfinder.Movements(bot) + movements.allowParkour = false + movements.allowSprinting = false + bot.pathfinder.setMovements(movements) + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + + if (bot.game.gameMode !== 'creative') { + await bot.tool.equipForBlock(block) + const itemId = bot.heldItem?.type + if (!block.canHarvest(itemId)) { + log(bot, `Don't have right tools to break ${block.name}.`) + return false + } + } + + await bot.dig(block, true) + log(bot, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + return true +} + +/** + * Place a block at the specified position + */ +export async function placeBlock( + bot: Bot, + blockType: string, + x: number, + y: number, + z: number, + placeOn: BlockFace = 'bottom', + dontCheat = false, +): Promise { + if (!mc.getBlockId(blockType)) { + log(bot, `Invalid block type: ${blockType}.`) + return false + } + + const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + + if (bot.modes.isOn('cheat') && !dontCheat) { + // Invert the facing direction + const face = placeOn === 'north' + ? 'south' + : placeOn === 'south' + ? 'north' + : placeOn === 'east' + ? 'west' + : placeOn === 'west' + ? 'east' + : placeOn + + let blockState = blockType + if (blockType.includes('torch') && placeOn !== 'bottom') { + blockState = blockType.replace('torch', 'wall_torch') + if (placeOn !== 'side' && placeOn !== 'top') { + blockState += `[facing=${face}]` + } + } + + if (blockType.includes('button') || blockType === 'lever') { + if (placeOn === 'top') { + blockState += '[face=ceiling]' + } + else if (placeOn === 'bottom') { + blockState += '[face=floor]' + } + else { + blockState += `[facing=${face}]` + } + } + + if (blockType === 'ladder' || blockType === 'repeater' || blockType === 'comparator') { + blockState += `[facing=${face}]` + } + + if (blockType.includes('stairs')) { + blockState += `[facing=${face}]` + } + + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} ${blockState}`) + + if (blockType.includes('door')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y + 1)} ${Math.floor(z)} ${blockState}[half=upper]`) + } + + if (blockType.includes('bed')) { + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z - 1)} ${blockState}[part=head]`) + } + + log(bot, `Used /setblock to place ${blockType} at ${targetDest}.`) + return true + } + + let itemName = blockType + if (itemName === 'redstone_wire') { + itemName = 'redstone' + } + + let block = bot.inventory.items().find(item => item.name === itemName) + if (!block && bot.game.gameMode === 'creative') { + await bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) + block = bot.inventory.items().find(item => item.name === itemName) + } + + if (!block) { + log(bot, `Don't have any ${blockType} to place.`) + return false + } + + const targetBlock = bot.blockAt(targetDest) + if (targetBlock.name === blockType) { + log(bot, `${blockType} already at ${targetBlock.position}.`) + return false + } + + const emptyBlocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern'] + if (!emptyBlocks.includes(targetBlock.name)) { + log(bot, `${blockType} in the way at ${targetBlock.position}.`) + const removed = await breakBlockAt(bot, x, y, z) + if (!removed) { + log(bot, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) + return false + } + await new Promise(resolve => setTimeout(resolve, 200)) + } + + const dirMap = { + top: new Vec3(0, 1, 0), + bottom: new Vec3(0, -1, 0), + north: new Vec3(0, 0, -1), + south: new Vec3(0, 0, 1), + east: new Vec3(1, 0, 0), + west: new Vec3(-1, 0, 0), + } + + const dirs = [] + if (placeOn === 'side') { + dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) + } + else if (dirMap[placeOn]) { + dirs.push(dirMap[placeOn]) + } + else { + dirs.push(dirMap.bottom) + log(bot, `Unknown placeOn value "${placeOn}". Defaulting to bottom.`) + } + dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) + + let buildOffBlock = null + let faceVec = null + + for (const d of dirs) { + const block = bot.blockAt(targetDest.plus(d)) + if (!emptyBlocks.includes(block.name)) { + buildOffBlock = block + faceVec = new Vec3(-d.x, -d.y, -d.z) + break + } + } + + if (!buildOffBlock) { + log(bot, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`) + return false + } + + const pos = bot.entity.position + const posAbove = pos.plus(new Vec3(0, 1, 0)) + const dontMoveFor = [ + 'torch', + 'redstone_torch', + 'redstone_wire', + 'lever', + 'button', + 'rail', + 'detector_rail', + 'powered_rail', + 'activator_rail', + 'tripwire_hook', + 'tripwire', + 'water_bucket', + ] + + if (!dontMoveFor.includes(blockType) + && (pos.distanceTo(targetBlock.position) < 1 + || posAbove.distanceTo(targetBlock.position) < 1)) { + const goal = bot.pathfinder.goals.GoalNear(targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + bot.pathfinder.setMovements(new bot.pathfinder.Movements(bot)) + await bot.pathfinder.goto(invertedGoal) + } + + if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + const pos = targetBlock.position + const movements = new bot.pathfinder.Movements(bot) + bot.pathfinder.setMovements(movements) + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + + await bot.equip(block, 'hand') + await bot.lookAt(buildOffBlock.position) + + try { + await bot.placeBlock(buildOffBlock, faceVec) + log(bot, `Placed ${blockType} at ${targetDest}.`) + await new Promise(resolve => setTimeout(resolve, 200)) + return true + } + catch { + log(bot, `Failed to place ${blockType} at ${targetDest}.`) + return false + } +} + +/** + * Use a door at the specified position + */ +export async function useDoor(bot: Bot, doorPos: Vec3 | null = null): Promise { + if (!doorPos) { + const doorTypes = [ + 'oak_door', + 'spruce_door', + 'birch_door', + 'jungle_door', + 'acacia_door', + 'dark_oak_door', + 'mangrove_door', + 'cherry_door', + 'bamboo_door', + 'crimson_door', + 'warped_door', + ] + + for (const doorType of doorTypes) { + const block = world.getNearestBlock(bot, doorType, 16) + if (block) { + doorPos = block.position + break + } + } + } + + if (!doorPos) { + log(bot, 'Could not find a door to use.') + return false + } + + await goToPosition(bot, doorPos.x, doorPos.y, doorPos.z, 1) + while (bot.pathfinder.isMoving()) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + const doorBlock = bot.blockAt(doorPos) + await bot.lookAt(doorPos) + + if (!doorBlock._properties.open) { + await bot.activateBlock(doorBlock) + } + + bot.setControlState('forward', true) + await new Promise(resolve => setTimeout(resolve, 600)) + bot.setControlState('forward', false) + await bot.activateBlock(doorBlock) + + log(bot, `Used door at ${doorPos}.`) + return true +} + +/** + * Till and sow a block at the specified position + */ +export async function tillAndSow( + bot: Bot, + x: number, + y: number, + z: number, + seedType: string | null = null, +): Promise { + x = Math.round(x) + y = Math.round(y) + z = Math.round(z) + + const block = bot.blockAt(new Vec3(x, y, z)) + if (block.name !== 'grass_block' && block.name !== 'dirt' && block.name !== 'farmland') { + log(bot, `Cannot till ${block.name}, must be grass_block or dirt.`) + return false + } + + const above = bot.blockAt(new Vec3(x, y + 1, z)) + if (above.name !== 'air') { + log(bot, `Cannot till, there is ${above.name} above the block.`) + return false + } + + if (bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(bot, block.position.x, block.position.y, block.position.z, 4) + } + + if (block.name !== 'farmland') { + const hoe = bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + log(bot, 'Cannot till, no hoes.') + return false + } + await bot.equip(hoe, 'hand') + await bot.activateBlock(block) + log(bot, `Tilled block x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + } + + if (seedType) { + if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) { + seedType += 's' // Fix common mistake + } + + const seeds = bot.inventory.items().find(item => item.name === seedType) + if (!seeds) { + log(bot, `No ${seedType} to plant.`) + return false + } + + await bot.equip(seeds, 'hand') + await bot.placeBlock(block, new Vec3(0, -1, 0)) + log(bot, `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + } + + return true +} + +/** + * Activate the nearest block of a specific type + */ +export async function activateNearestBlock(bot: Bot, type: string): Promise { + const block = world.getNearestBlock(bot, type, 16) + if (!block) { + log(bot, `Could not find any ${type} to activate.`) + return false + } + + if (bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(bot, block.position.x, block.position.y, block.position.z, 4) + } + + await bot.activateBlock(block) + log(bot, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) + return true +} diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts new file mode 100644 index 000000000..8afd98aca --- /dev/null +++ b/services/minecraft/src/skills/combat.ts @@ -0,0 +1,133 @@ +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' +import * as mc from '../../../utils/mcdata.js' +import * as world from '../world.js' +import { log } from './base.js' + +/** + * Equip the item with highest attack damage + */ +async function equipHighestAttack(bot: Bot): Promise { + const weapons = bot.inventory.items().filter(item => + item.name.includes('sword') + || (item.name.includes('axe') && !item.name.includes('pickaxe')), + ) + + if (weapons.length === 0) { + const tools = bot.inventory.items().filter(item => + item.name.includes('pickaxe') + || item.name.includes('shovel'), + ) + if (tools.length === 0) + return + + tools.sort((a, b) => b.attackDamage - a.attackDamage) + const tool = tools[0] + if (tool) + await bot.equip(tool, 'hand') + return + } + + weapons.sort((a, b) => b.attackDamage - a.attackDamage) + const weapon = weapons[0] + if (weapon) + await bot.equip(weapon, 'hand') +} + +/** + * Attack the nearest mob of the given type + */ +export async function attackNearest(bot: Bot, mobType: string, kill = true): Promise { + bot.modes.pause('cowardice') + if (mobType === 'drowned' || mobType === 'cod' || mobType === 'salmon' + || mobType === 'tropical_fish' || mobType === 'squid') { + bot.modes.pause('self_preservation') + } + + const mob = world.getNearbyEntities(bot, 24).find(entity => entity.name === mobType) + if (mob) { + return await attackEntity(bot, mob, kill) + } + log(bot, `Could not find any ${mobType} to attack.`) + return false +} + +/** + * Attack a specific entity + */ +export async function attackEntity(bot: Bot, entity: Entity, kill = true): Promise { + const pos = entity.position + await equipHighestAttack(bot) + + if (!kill) { + if (bot.entity.position.distanceTo(pos) > 5) { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + await bot.attack(entity) + } + else { + bot.pvp.attack(entity) + while (world.getNearbyEntities(bot, 24).includes(entity)) { + await new Promise(resolve => setTimeout(resolve, 1000)) + if (bot.interrupt_code) { + bot.pvp.stop() + return false + } + } + log(bot, `Successfully killed ${entity.name}.`) + return true + } + return true +} + +/** + * Defend against nearby hostile mobs + */ +export async function defendSelf(bot: Bot, range = 9): Promise { + bot.modes.pause('self_defense') + bot.modes.pause('cowardice') + + let attacked = false + let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) + + while (enemy) { + await equipHighestAttack(bot) + + if (bot.entity.position.distanceTo(enemy.position) >= 4 + && enemy.name !== 'creeper' && enemy.name !== 'phantom') { + try { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(enemy, 3.5), true) + } + catch { /* might error if entity dies, ignore */ } + } + + if (bot.entity.position.distanceTo(enemy.position) <= 2) { + try { + const inverted_goal = bot.pathfinder.goals.GoalInvert( + bot.pathfinder.goals.GoalFollow(enemy, 2), + ) + await bot.pathfinder.goto(inverted_goal, true) + } + catch { /* might error if entity dies, ignore */ } + } + + bot.pvp.attack(enemy) + attacked = true + await new Promise(resolve => setTimeout(resolve, 500)) + enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) + + if (bot.interrupt_code) { + bot.pvp.stop() + return false + } + } + + bot.pvp.stop() + if (attacked) { + log(bot, `Successfully defended self.`) + } + else { + log(bot, `No enemies nearby to defend self from.`) + } + return attacked +} diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts new file mode 100644 index 000000000..b364117e9 --- /dev/null +++ b/services/minecraft/src/skills/crafting.ts @@ -0,0 +1,268 @@ +import type { Bot } from 'mineflayer' +import * as world from '../composables/world' +import * as mc from '../utils/mcdata' +import { log } from './base' +import { collectBlock } from './blocks' +import { goToPosition } from './movement' + +/** + * Craft items from a recipe + */ +export async function craftRecipe(bot: Bot, itemName: string, num = 1): Promise { + let placedTable = false + + if (mc.getItemCraftingRecipes(itemName).length === 0) { + log(bot, `${itemName} is either not an item, or it does not have a crafting recipe!`) + return false + } + + // Get recipes that don't require a crafting table + let recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, null) + let craftingTable = null + const craftingTableRange = 32 + + if (!recipes || recipes.length === 0) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, true) + if (!recipes || recipes.length === 0) { + log(bot, `You do not have the resources to craft a ${itemName}.`) + return false + } + + // Look for crafting table + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange) + if (!craftingTable) { + // Try to place crafting table + const hasTable = world.getInventoryCounts(bot).crafting_table > 0 + if (hasTable) { + const pos = world.getNearestFreeSpace(bot, 1, 6) + await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z) + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange) + if (craftingTable) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable) + placedTable = true + } + } + else { + log(bot, `Crafting ${itemName} requires a crafting table.`) + return false + } + } + else { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable) + } + } + + if (!recipes || recipes.length === 0) { + log(bot, `You do not have the resources to craft a ${itemName}. It requires: ${ + Object.entries(mc.getItemCraftingRecipes(itemName)[0]) + .map(([key, value]) => `${key}: ${value}`) + .join(', ') + }.`) + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1) + } + return false + } + + if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { + await goToPosition(bot, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) + } + + const recipe = recipes[0] + // Check that the agent has sufficient items to use the recipe `num` times + const inventory = world.getInventoryCounts(bot) // Items in the agents inventory + const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe) // Items required to use the recipe once + const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients) + + await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable) + + if (craftLimit.num < num) { + log(bot, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + } + else { + log(bot, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + } + + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1) + } + + // Equip any armor the bot may have crafted + bot.armorManager.equipAll() + + return true +} + +/** + * Smelt items in a furnace + */ +export async function smeltItem(bot: Bot, itemName: string, num = 1): Promise { + if (!mc.isSmeltable(itemName)) { + log(bot, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) + return false + } + + let placedFurnace = false + const furnaceRange = 32 + let furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange) + + if (!furnaceBlock) { + // Try to place furnace + const hasFurnace = world.getInventoryCounts(bot).furnace > 0 + if (hasFurnace) { + const pos = world.getNearestFreeSpace(bot, 1, furnaceRange) + await placeBlock(bot, 'furnace', pos.x, pos.y, pos.z) + furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange) + placedFurnace = true + } + } + + if (!furnaceBlock) { + log(bot, 'There is no furnace nearby and you have no furnace.') + return false + } + + if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + } + + bot.modes.pause('unstuck') + await bot.lookAt(furnaceBlock.position) + + const furnace = await bot.openFurnace(furnaceBlock) + + // Check if the furnace is already smelting something + const inputItem = furnace.inputItem() + if (inputItem && inputItem.type !== mc.getItemId(itemName) && inputItem.count > 0) { + log(bot, `The furnace is currently smelting ${mc.getItemName(inputItem.type)}.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + // Check if the bot has enough items to smelt + const invCounts = world.getInventoryCounts(bot) + if (!invCounts[itemName] || invCounts[itemName] < num) { + log(bot, `You do not have enough ${itemName} to smelt.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + // Fuel the furnace + if (!furnace.fuelItem()) { + const fuel = mc.getSmeltingFuel(bot) + if (!fuel) { + log(bot, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + log(bot, `Using ${fuel.name} as fuel.`) + const putFuel = Math.ceil(num / mc.getFuelSmeltOutput(fuel.name)) + + if (fuel.count < putFuel) { + log(bot, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + return false + } + + await furnace.putFuel(fuel.type, null, putFuel) + log(bot, `Added ${putFuel} ${mc.getItemName(fuel.type)} to furnace fuel.`) + } + + // Put the items in the furnace + await furnace.putInput(mc.getItemId(itemName), null, num) + + // Wait for the items to smelt + let total = 0 + let collectedLast = true + let smeltedItem = null + await new Promise(resolve => setTimeout(resolve, 200)) + + while (total < num) { + await new Promise(resolve => setTimeout(resolve, 10000)) + let collected = false + + if (furnace.outputItem()) { + smeltedItem = await furnace.takeOutput() + if (smeltedItem) { + total += smeltedItem.count + collected = true + } + } + + if (!collected && !collectedLast) { + break // If nothing was collected this time or last time + } + + collectedLast = collected + if (bot.interrupt_code) { + break + } + } + + await bot.closeWindow(furnace) + + if (placedFurnace) { + await collectBlock(bot, 'furnace', 1) + } + + if (total === 0) { + log(bot, `Failed to smelt ${itemName}.`) + return false + } + + if (total < num) { + log(bot, `Only smelted ${total} ${mc.getItemName(smeltedItem.type)}.`) + return false + } + + log(bot, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smeltedItem.type)}.`) + return true +} + +/** + * Clear the nearest furnace + */ +export async function clearNearestFurnace(bot: Bot): Promise { + const furnaceBlock = world.getNearestBlock(bot, 'furnace', 32) + if (!furnaceBlock) { + log(bot, 'No furnace nearby to clear.') + return false + } + + if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + } + + const furnace = await bot.openFurnace(furnaceBlock) + + // Take the items out of the furnace + let smeltedItem, inputItem, fuelItem + + if (furnace.outputItem()) { + smeltedItem = await furnace.takeOutput() + } + + if (furnace.inputItem()) { + inputItem = await furnace.takeInput() + } + + if (furnace.fuelItem()) { + fuelItem = await furnace.takeFuel() + } + + const smeltedName = smeltedItem ? `${smeltedItem.count} ${smeltedItem.name}` : '0 smelted items' + const inputName = inputItem ? `${inputItem.count} ${inputItem.name}` : '0 input items' + const fuelName = fuelItem ? `${fuelItem.count} ${fuelItem.name}` : '0 fuel items' + + log(bot, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) + return true +} diff --git a/services/minecraft/src/skills/index.ts b/services/minecraft/src/skills/index.ts new file mode 100644 index 000000000..ed2a3589e --- /dev/null +++ b/services/minecraft/src/skills/index.ts @@ -0,0 +1,50 @@ +// Base utilities +export { log } from './base' +export type { BlockFace, Position } from './base' + +// Block interaction functions +export { + activateNearestBlock, + breakBlockAt, + placeBlock, + tillAndSow, + useDoor, +} from './blocks.js' + +// Combat related functions +export { + attackEntity, + attackNearest, + defendSelf, +} from './combat.js' + +// Crafting and smelting functions +export { + clearNearestFurnace, + craftRecipe, + smeltItem, +} from './crafting.js' + +// Inventory management functions +export { + consume, + discard, + equip, + giveToPlayer, + pickupNearbyItems, + putInChest, + takeFromChest, + viewChest, +} from './inventory.js' + +// Movement related functions +export { + followPlayer, + goToNearestBlock, + goToNearestEntity, + goToPlayer, + goToPosition, + moveAway, + moveAwayFromEntity, + stay, +} from './movement.js' diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts new file mode 100644 index 000000000..aef535afe --- /dev/null +++ b/services/minecraft/src/skills/inventory.ts @@ -0,0 +1,257 @@ +import type { Bot } from 'mineflayer' +import * as world from '../composables/world' +import { log } from './base' +import { goToPosition } from './movement' + +/** + * Pick up nearby items + */ +export async function pickupNearbyItems(bot: Bot): Promise { + const distance = 8 + const getNearestItem = (bot: Bot) => + bot.nearestEntity(entity => + entity.name === 'item' + && bot.entity.position.distanceTo(entity.position) < distance, + ) + + let nearestItem = getNearestItem(bot) + let pickedUp = 0 + + while (nearestItem) { + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(nearestItem, 0.8), true) + await new Promise(resolve => setTimeout(resolve, 200)) + + const prev = nearestItem + nearestItem = getNearestItem(bot) + if (prev === nearestItem) { + break + } + pickedUp++ + } + + log(bot, `Picked up ${pickedUp} items.`) + return true +} + +/** + * Equip an item + */ +export async function equip(bot: Bot, itemName: string): Promise { + const item = bot.inventory.slots.find(slot => slot && slot.name === itemName) + if (!item) { + log(bot, `You do not have any ${itemName} to equip.`) + return false + } + + if (itemName.includes('leggings')) { + await bot.equip(item, 'legs') + } + else if (itemName.includes('boots')) { + await bot.equip(item, 'feet') + } + else if (itemName.includes('helmet')) { + await bot.equip(item, 'head') + } + else if (itemName.includes('chestplate') || itemName.includes('elytra')) { + await bot.equip(item, 'torso') + } + else if (itemName.includes('shield')) { + await bot.equip(item, 'off-hand') + } + else { + await bot.equip(item, 'hand') + } + + log(bot, `Equipped ${itemName}.`) + return true +} + +/** + * Discard items + */ +export async function discard(bot: Bot, itemName: string, num = -1): Promise { + let discarded = 0 + + while (true) { + const item = bot.inventory.items().find(item => item.name === itemName) + if (!item) { + break + } + + const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) + await bot.toss(item.type, null, toDiscard) + discarded += toDiscard + + if (num !== -1 && discarded >= num) { + break + } + } + + if (discarded === 0) { + log(bot, `You do not have any ${itemName} to discard.`) + return false + } + + log(bot, `Discarded ${discarded} ${itemName}.`) + return true +} + +/** + * Put items in a chest + */ +export async function putInChest(bot: Bot, itemName: string, num = -1): Promise { + const chest = world.getNearestBlock(bot, 'chest', 32) + if (!chest) { + log(bot, 'Could not find a chest nearby.') + return false + } + + const item = bot.inventory.items().find(item => item.name === itemName) + if (!item) { + log(bot, `You do not have any ${itemName} to put in the chest.`) + return false + } + + const toPut = num === -1 ? item.count : Math.min(num, item.count) + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + + const chestContainer = await bot.openContainer(chest) + await chestContainer.deposit(item.type, null, toPut) + await chestContainer.close() + + log(bot, `Successfully put ${toPut} ${itemName} in the chest.`) + return true +} + +/** + * Take items from a chest + */ +export async function takeFromChest(bot: Bot, itemName: string, num = -1): Promise { + const chest = world.getNearestBlock(bot, 'chest', 32) + if (!chest) { + log(bot, 'Could not find a chest nearby.') + return false + } + + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await bot.openContainer(chest) + + const item = chestContainer.containerItems().find(item => item.name === itemName) + if (!item) { + log(bot, `Could not find any ${itemName} in the chest.`) + await chestContainer.close() + return false + } + + const toTake = num === -1 ? item.count : Math.min(num, item.count) + await chestContainer.withdraw(item.type, null, toTake) + await chestContainer.close() + + log(bot, `Successfully took ${toTake} ${itemName} from the chest.`) + return true +} + +/** + * View contents of a chest + */ +export async function viewChest(bot: Bot): Promise { + const chest = world.getNearestBlock(bot, 'chest', 32) + if (!chest) { + log(bot, 'Could not find a chest nearby.') + return false + } + + await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await bot.openContainer(chest) + const items = chestContainer.containerItems() + + if (items.length === 0) { + log(bot, 'The chest is empty.') + } + else { + log(bot, 'The chest contains:') + for (const item of items) { + log(bot, `${item.count} ${item.name}`) + } + } + + await chestContainer.close() + return true +} + +/** + * Consume (eat/drink) an item + */ +export async function consume(bot: Bot, itemName = ''): Promise { + let item + let name + + if (itemName) { + item = bot.inventory.items().find(item => item.name === itemName) + name = itemName + } + + if (!item) { + log(bot, `You do not have any ${name} to eat.`) + return false + } + + await bot.equip(item, 'hand') + await bot.consume() + log(bot, `Consumed ${item.name}.`) + return true +} + +/** + * Give items to a player + */ +export async function giveToPlayer( + bot: Bot, + itemType: string, + username: string, + num = 1, +): Promise { + const player = bot.players[username]?.entity + if (!player) { + log(bot, `Could not find ${username}.`) + return false + } + + await goToPosition(bot, player.position.x, player.position.y, player.position.z, 3) + + if (bot.entity.position.y < player.position.y - 1) { + await goToPosition(bot, player.position.x, player.position.y, player.position.z, 1) + } + + if (bot.entity.position.distanceTo(player.position) < 2) { + const goal = bot.pathfinder.goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + await bot.pathfinder.goto(invertedGoal) + } + + await bot.lookAt(player.position) + + if (await discard(bot, itemType, num)) { + let given = false + bot.once('playerCollect', (collector, collected) => { + if (collector.username === username) { + log(bot, `${username} received ${itemType}.`) + given = true + } + }) + + const start = Date.now() + while (!given && !bot.interrupt_code) { + await new Promise(resolve => setTimeout(resolve, 500)) + if (given) { + return true + } + if (Date.now() - start > 3000) { + break + } + } + } + + log(bot, `Failed to give ${itemType} to ${username}, it was never received.`) + return false +} diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts new file mode 100644 index 000000000..ac2778243 --- /dev/null +++ b/services/minecraft/src/skills/movement.ts @@ -0,0 +1,208 @@ +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' +import * as world from '../composables/world' +import { log } from './base' + +/** + * Navigate to a specific position + */ +export async function goToPosition( + bot: Bot, + x: number, + y: number, + z: number, + minDistance = 2, +): Promise { + if (x == null || y == null || z == null) { + log(bot, `Missing coordinates, given x:${x} y:${y} z:${z}`) + return false + } + + if (bot.modes.isOn('cheat')) { + bot.chat(`/tp @s ${x} ${y} ${z}`) + log(bot, `Teleported to ${x}, ${y}, ${z}.`) + return true + } + + await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(x, y, z, minDistance)) + log(bot, `You have reached ${x}, ${y}, ${z}.`) + return true +} + +/** + * Navigate to the nearest block of a specific type + */ +export async function goToNearestBlock( + bot: Bot, + blockType: string, + minDistance = 2, + range = 64, +): Promise { + const MAX_RANGE = 512 + if (range > MAX_RANGE) { + log(bot, `Maximum search range capped at ${MAX_RANGE}.`) + range = MAX_RANGE + } + + const block = world.getNearestBlock(bot, blockType, range) + if (!block) { + log(bot, `Could not find any ${blockType} in ${range} blocks.`) + return false + } + + log(bot, `Found ${blockType} at ${block.position}.`) + await goToPosition(bot, block.position.x, block.position.y, block.position.z, minDistance) + return true +} + +/** + * Navigate to the nearest entity of a specific type + */ +export async function goToNearestEntity( + bot: Bot, + entityType: string, + minDistance = 2, + range = 64, +): Promise { + const entity = world.getNearestEntityWhere( + bot, + entity => entity.name === entityType, + range, + ) + + if (!entity) { + log(bot, `Could not find any ${entityType} in ${range} blocks.`) + return false + } + + const distance = bot.entity.position.distanceTo(entity.position) + log(bot, `Found ${entityType} ${distance} blocks away.`) + await goToPosition( + bot, + entity.position.x, + entity.position.y, + entity.position.z, + minDistance, + ) + return true +} + +/** + * Navigate to a specific player + */ +export async function goToPlayer(bot: Bot, username: string, distance = 3): Promise { + if (bot.modes.isOn('cheat')) { + bot.chat(`/tp @s ${username}`) + log(bot, `Teleported to ${username}.`) + return true + } + + bot.modes.pause('self_defense') + bot.modes.pause('cowardice') + + const player = bot.players[username]?.entity + if (!player) { + log(bot, `Could not find ${username}.`) + return false + } + + await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(player, distance), true) + log(bot, `You have reached ${username}.`) + return true +} + +/** + * Follow a player continuously + */ +export async function followPlayer(bot: Bot, username: string, distance = 4): Promise { + const player = bot.players[username]?.entity + if (!player) + return false + + bot.pathfinder.setGoal(bot.pathfinder.goals.GoalFollow(player, distance), true) + log(bot, `You are now actively following player ${username}.`) + + while (!bot.interrupt_code) { + await new Promise(resolve => setTimeout(resolve, 500)) + + if (bot.modes.isOn('cheat') + && bot.entity.position.distanceTo(player.position) > 100 + && player.isOnGround) { + await goToPlayer(bot, username) + } + + if (bot.modes.isOn('unstuck')) { + const isNearby = bot.entity.position.distanceTo(player.position) <= distance + 1 + if (isNearby) { + bot.modes.pause('unstuck') + } + else { + bot.modes.unpause('unstuck') + } + } + } + return true +} + +/** + * Move away from current position + */ +export async function moveAway(bot: Bot, distance: number): Promise { + const pos = bot.entity.position + const goal = bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, distance) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + + if (bot.modes.isOn('cheat')) { + const move = new bot.pathfinder.Movements(bot) + const path = await bot.pathfinder.getPathTo(move, invertedGoal, 10000) + const lastMove = path.path[path.path.length - 1] + + if (lastMove) { + const x = Math.floor(lastMove.x) + const y = Math.floor(lastMove.y) + const z = Math.floor(lastMove.z) + bot.chat(`/tp @s ${x} ${y} ${z}`) + return true + } + } + + await bot.pathfinder.goto(invertedGoal) + const newPos = bot.entity.position + log(bot, `Moved away from nearest entity to ${newPos}.`) + return true +} + +/** + * Move away from a specific entity + */ +export async function moveAwayFromEntity( + bot: Bot, + entity: Entity, + distance = 16, +): Promise { + const goal = bot.pathfinder.goals.GoalFollow(entity, distance) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + await bot.pathfinder.goto(invertedGoal) + return true +} + +/** + * Stay in current position + */ +export async function stay(bot: Bot, seconds = 30): Promise { + bot.modes.pause('self_preservation') + bot.modes.pause('unstuck') + bot.modes.pause('cowardice') + bot.modes.pause('self_defense') + bot.modes.pause('hunting') + bot.modes.pause('torch_placing') + bot.modes.pause('item_collecting') + + const start = Date.now() + while (!bot.interrupt_code && (seconds === -1 || Date.now() - start < seconds * 1000)) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + + log(bot, `Stayed for ${(Date.now() - start) / 1000} seconds.`) + return true +} From d4d1e8135a12e17ceee4b66fcd647b35e49fd6e3 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 01:19:18 +0800 Subject: [PATCH 18/77] fix: status --- services/minecraft/src/components/status.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/minecraft/src/components/status.ts b/services/minecraft/src/components/status.ts index f83345403..560e852df 100644 --- a/services/minecraft/src/components/status.ts +++ b/services/minecraft/src/components/status.ts @@ -2,12 +2,13 @@ import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { registerCommand } from '../composables/command' +const status = new Map() + export function getStatusToString(ctx: BotContext): string { return Array.from(getStatus(ctx).entries()).map(([key, value]) => `${key}: ${value}`).join('\n') } export function getStatus(ctx: BotContext): Map { - const status = new Map() const pos = ctx.bot.entity.position const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear' const timeOfDay = ctx.bot.time.timeOfDay < 6000 @@ -28,8 +29,8 @@ export function createStatusComponent(ctx: BotContext): ComponentLifecycle { registerCommand('status', () => { logger.log('Status command received') - const status = getStatus(ctx) - ctx.bot.chat(status.toString()) + const status = getStatusToString(ctx) + ctx.bot.chat(status) }) return { From 4fc1c4e44afa18da8f29db5702019477146e31b3 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Tue, 7 Jan 2025 01:31:35 +0800 Subject: [PATCH 19/77] feat: bot internal event & health & message & death --- services/minecraft/src/composables/bot.ts | 175 ++++++++++++++++++- services/minecraft/src/composables/events.ts | 9 + 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 services/minecraft/src/composables/events.ts diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index ee0ebf649..42079d112 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,3 +1,4 @@ +import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' @@ -26,6 +27,9 @@ export interface BotContext { lastDamageTime: number lastDamageTaken: number } + + emit: (event: BotInternalEvents) => any + eventListeners: Record> } export interface Component { @@ -59,8 +63,36 @@ export function createBot(options: BotOptions): Bot { lastDamageTime: 0, lastDamageTaken: 0, }, + emit: (event: BotInternalEvents) => { + if (!ctx) + return + const listeners = ctx.eventListeners[event] + if (listeners) { + listeners.forEach(listener => listener()) + } + }, + eventListeners: { + 'time:sunrise': [], + 'time:noon': [], + 'time:sunset': [], + 'time:midnight': [], + }, } + ctx.bot.on('time', () => { + if (!ctx) + return + + if (ctx.bot.time.timeOfDay === 0) + ctx.emit('time:sunrise') + else if (ctx.bot.time.timeOfDay === 6000) + ctx.emit('time:noon') + else if (ctx.bot.time.timeOfDay === 12000) + ctx.emit('time:sunset') + else if (ctx.bot.time.timeOfDay === 18000) + ctx.emit('time:midnight') + }) + ctx.bot.on('health', () => { if (!ctx) return @@ -84,8 +116,27 @@ export function createBot(options: BotOptions): Bot { logger.error('Bot died') }) - ctx.bot.on('messagestr', () => { + ctx.bot.on('messagestr', async (message, _, jsonMsg) => { + if (!ctx) + return + // jsonMsg.translate: + // - death.attack.player + // message: + // - was slain by + // - drowned + if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(ctx.botName)) { + const deathPos = ctx.bot.entity.position + + // this.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) + let deathPosStr: string | undefined + if (deathPos) { + deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` + } + + const dimension = ctx.bot.game.dimension + await handleMessage(ctx, '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.`) + } }) ctx.bot.on('end', (reason) => { @@ -104,6 +155,128 @@ export function createBot(options: BotOptions): Bot { return ctx.bot } +async function handleMessage(ctx: BotContext, source: string, message: string, maxResponses: number = Infinity) { + // if (!source || !message) { + // console.warn('Received empty message from', source); + // return false; + // } + + // let used_command = false; + // if (maxResponses === null) { + // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; + // } + // if (maxResponses === -1) { + // maxResponses = Infinity; + // } + + // const self_prompt = source === 'system' || source === ctx.botName; + // const from_other_bot = convoManager.isOtherAgent(source); + + // if (!self_prompt && !from_other_bot) { // from user, check for forced commands + // const user_command_name = containsCommand(message); + // if (user_command_name) { + // if (!commandExists(user_command_name)) { + // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); + // return false; + // } + // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); + // if (user_command_name === '!newAction') { + // // all user-initiated commands are ignored by the bot except for this one + // // add the preceding message to the history to give context for newAction + // this.history.add(source, message); + // } + // let execute_res = await executeCommand(this, message); + // if (execute_res) + // this.routeResponse(source, execute_res); + // return true; + // } + // } + + // if (from_other_bot) + // this.last_sender = source; + + // // Now translate the message + // message = await handleEnglishTranslation(message); + // console.log('received message from', source, ':', message); + + // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); + + // let behavior_log = this.bot.modes.flushBehaviorLog(); + // if (behavior_log.trim().length > 0) { + // const MAX_LOG = 500; + // if (behavior_log.length > MAX_LOG) { + // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); + // } + // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); + // await this.history.add('system', behavior_log); + // } + + // // Handle other user messages + // await this.history.add(source, message); + // this.history.save(); + + // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting + // maxResponses = 1; // force only respond to this message, then let self-prompting take over + // for (let i=0; i 0) + // chat_message = `${pre_message} ${chat_message}`; + // this.routeResponse(source, chat_message); + // } + + // let execute_res = await executeCommand(this, res); + + // console.log('Agent executed:', command_name, 'and got:', execute_res); + // used_command = true; + + // if (execute_res) + // this.history.add('system', execute_res); + // else + // break; + // } + // else { // conversation response + // this.history.add(this.name, res); + // this.routeResponse(source, res); + // break; + // } + + // this.history.save(); + // } + + // return used_command; +} + export function useBot() { if (ctx == null || ctx.bot == null) { throw new Error('Bot instance not found') diff --git a/services/minecraft/src/composables/events.ts b/services/minecraft/src/composables/events.ts new file mode 100644 index 000000000..e00c57096 --- /dev/null +++ b/services/minecraft/src/composables/events.ts @@ -0,0 +1,9 @@ +export interface BotInternalEventHandlers { + 'time:sunrise': () => void + 'time:noon': () => void + 'time:sunset': () => void + 'time:midnight': () => void +} + +export type BotInternalEvents = keyof BotInternalEventHandlers +export type BotInternalEventsHandler = BotInternalEventHandlers[K] From 88af318f0ab9abd3fba8779ea8adf6d3ddcd7e05 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 01:58:36 +0800 Subject: [PATCH 20/77] refactor: actions --- services/minecraft/src/agents/actions.ts | 752 +++++++++------------- services/minecraft/src/agents/agent.ts | 0 services/minecraft/src/agents/openai.ts | 22 +- services/minecraft/src/agents/queries.ts | 141 ++-- services/minecraft/src/skills/blocks.ts | 81 +++ services/minecraft/src/skills/combat.ts | 6 +- services/minecraft/src/skills/index.ts | 56 +- services/minecraft/src/skills/movement.ts | 31 + 8 files changed, 517 insertions(+), 572 deletions(-) delete mode 100644 services/minecraft/src/agents/agent.ts diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 25e2997b8..42317c2dd 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -2,136 +2,113 @@ import type { BotContext } from 'src/composables/bot' import { z } from 'zod' import * as skills from '../skills' -function runAsAction(actionFn, resume = false, timeout = -1) { - let actionLabel = null // Will be set on first use - - const wrappedAction = async function (agent, ...args) { - // Set actionLabel only once, when the action is first created - if (!actionLabel) { - const actionObj = actionsList.find(a => a.perform === wrappedAction) - actionLabel = actionObj.name.substring(1) // Remove the ! prefix - } - - const actionFnWithAgent = async () => { - await actionFn(agent, ...args) - } - const code_return = await agent.actions.runAction(`action:${actionLabel}`, actionFnWithAgent, { timeout, resume }) - if (code_return.interrupted && !code_return.timedout) - return - return code_return.message - } - - return wrappedAction -} - type ActionResult = string | Promise interface Action { readonly name: string readonly description: string readonly schema: z.ZodObject - readonly perform: (ctx: BotContext) => () => ActionResult + readonly perform: (ctx: BotContext) => (...args: any[]) => ActionResult } -function getNewAction(): Action { - return { - name: '!newAction', - description: 'Perform new and unknown custom behaviors that are not available as a command.', - schema: z.object({ - prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), - }), - perform: (agent: BotContext) => async () => { - if (!settings.allow_insecure_coding) - return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' - return await agent.coder.generateCode(agent.history) - }, - } -} +export const actionsList: Action[] = [ + // getNewAction(): Action { + // return { + // name: 'newAction', + // description: 'Perform new and unknown custom behaviors that are not available as a command.', + // schema: z.object({ + // prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), + // }), + // perform: (ctx: BotContext) => async (prompt: string) => { + // if (!settings.allow_insecure_coding) + // return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' + // return await ctx.coder.generateCode(ctx.history) + // }, + // } + // }, -function getStopAction(): Action { - return { - name: '!stop', - description: 'Force stop all actions and commands that are currently executing.', - schema: z.object({}), - perform: (agent: BotContext) => async () => { - await agent.actions.stop() - agent.clearBotLogs() - agent.actions.cancelResume() - agent.bot.emit('idle') - let msg = 'Agent stopped.' - if (agent.self_prompter.on) - msg += ' Self-prompting still active.' - return msg - }, - } -} + // getStopAction(): Action { + // return { + // name: 'stop', + // description: 'Force stop all actions and commands that are currently executing.', + // schema: z.object({}), + // perform: (ctx: BotContext) => async () => { + // await ctx.actions.stop() + // ctx.clearBotLogs() + // ctx.actions.cancelResume() + // ctx.bot.emit('idle') + // let msg = 'Agent stopped.' + // if (ctx.self_prompter.on) + // msg += ' Self-prompting still active.' + // return msg + // }, + // } + // }, -function getStfuAction(): Action { - return { - name: '!stfu', - description: 'Stop all chatting and self prompting, but continue current action.', - schema: z.object({}), - perform: (agent: BotContext) => async () => { - agent.openChat('Shutting up.') - agent.shutUp() - }, - } -} + // getStfuAction(): Action { + // return { + // name: 'stfu', + // description: 'Stop all chatting and self prompting, but continue current action.', + // schema: z.object({}), + // perform: (ctx: BotContext) => async () => { + // ctx.openChat('Shutting up.') + // ctx.shutUp() + // return 'Shutting up.' + // }, + // } + // }, -function getRestartAction(): Action { - return { - name: '!restart', - description: 'Restart the agent process.', - schema: z.object({}), - perform: (agent: BotContext) => async () => { - agent.cleanKill() - }, - } -} + // getRestartAction(): Action { + // return { + // name: 'restart', + // description: 'Restart the agent process.', + // schema: z.object({}), + // perform: (ctx: BotContext) => async () => { + // ctx.cleanKill() + // return 'Restarting agent...' + // }, + // } + // }, -function getClearChatAction(): Action { - return { - name: '!clearChat', - description: 'Clear the chat history.', - schema: z.object({}), - perform: (agent: BotContext) => async () => { - agent.history.clear() - return `${agent.name}'s chat history was cleared, starting new conversation from scratch.` - }, - } -} - -function getGoToPlayerAction(): Action { - return { - name: '!goToPlayer', + // getClearChatAction(): Action { + // return { + // name: 'clearChat', + // description: 'Clear the chat history.', + // schema: z.object({}), + // perform: (ctx: BotContext) => async () => { + // ctx.history.clear() + // return `${ctx.name}'s chat history was cleared, starting new conversation from scratch.` + // }, + // } + // }, + { + name: 'goToPlayer', description: 'Go to the given player.', schema: z.object({ player_name: z.string().describe('The name of the player to go to.'), closeness: z.number().describe('How close to get to the player.').min(0), }), - perform: (agent: BotContext) => runAsAction(async (agent, player_name, closeness) => { - return await skills.goToPlayer(agent.bot, player_name, closeness) - }), - } -} + perform: (ctx: BotContext) => async (player_name: string, closeness: number) => { + await skills.goToPlayer(ctx.bot, player_name, closeness) + return 'Moving to player...' + }, + }, -function getFollowPlayerAction(): Action { - return { - name: '!followPlayer', + { + name: 'followPlayer', description: 'Endlessly follow the given player.', schema: z.object({ player_name: z.string().describe('name of the player to follow.'), follow_dist: z.number().describe('The distance to follow from.').min(0), }), - perform: (agent: BotContext) => runAsAction(async (agent, player_name, follow_dist) => { - await skills.followPlayer(agent.bot, player_name, follow_dist) - }, true), - } -} + perform: (ctx: BotContext) => async (player_name: string, follow_dist: number) => { + await skills.followPlayer(ctx.bot, player_name, follow_dist) + return 'Following player...' + }, + }, -function getGoToCoordinatesAction(): Action { - return { - name: '!goToCoordinates', + { + name: 'goToCoordinates', description: 'Go to the given x, y, z location.', schema: z.object({ x: z.number().describe('The x coordinate.'), @@ -139,445 +116,350 @@ function getGoToCoordinatesAction(): Action { z: z.number().describe('The z coordinate.'), closeness: z.number().describe('How close to get to the location.').min(0), }), - perform: (agent: BotContext) => runAsAction(async (agent, x, y, z, closeness) => { - await skills.goToPosition(agent.bot, x, y, z, closeness) - }), - } -} + perform: (ctx: BotContext) => async (x: number, y: number, z: number, closeness: number) => { + await skills.goToPosition(ctx.bot, x, y, z, closeness) + return 'Moving to coordinates...' + }, + }, -function getSearchForBlockAction(): Action { - return { - name: '!searchForBlock', + { + name: 'searchForBlock', description: 'Find and go to the nearest block of a given type in a given range.', schema: z.object({ type: z.string().describe('The block type to go to.'), search_range: z.number().describe('The range to search for the block.').min(32).max(512), }), - perform: (agent: BotContext) => runAsAction(async (agent, block_type, range) => { - await skills.goToNearestBlock(agent.bot, block_type, 4, range) - }), - } -} + perform: (ctx: BotContext) => async (block_type: string, range: number) => { + await skills.goToNearestBlock(ctx.bot, block_type, 4, range) + return 'Searching for block...' + }, + }, -function getSearchForEntityAction(): Action { - return { - name: '!searchForEntity', + { + name: 'searchForEntity', description: 'Find and go to the nearest entity of a given type in a given range.', schema: z.object({ type: z.string().describe('The type of entity to go to.'), search_range: z.number().describe('The range to search for the entity.').min(32).max(512), }), - perform: (agent: BotContext) => runAsAction(async (agent, entity_type, range) => { - await skills.goToNearestEntity(agent.bot, entity_type, 4, range) - }), - } -} + perform: (ctx: BotContext) => async (entity_type: string, range: number) => { + await skills.goToNearestEntity(ctx.bot, entity_type, 4, range) + return 'Searching for entity...' + }, + }, -function getMoveAwayAction(): Action { - return { - name: '!moveAway', + { + name: 'moveAway', description: 'Move away from the current location in any direction by a given distance.', schema: z.object({ distance: z.number().describe('The distance to move away.').min(0), }), - perform: (agent: BotContext) => runAsAction(async (agent, distance) => { - await skills.moveAway(agent.bot, distance) - }), - } -} - -function getRememberHereAction(): Action { - return { - name: '!rememberHere', - description: 'Save the current location with a given name.', - schema: z.object({ - name: z.string().describe('The name to remember the location as.'), - }), - perform: (agent: BotContext) => async (name: string) => { - const pos = agent.bot.entity.position - agent.memory_bank.rememberPlace(name, pos.x, pos.y, pos.z) - return `Location saved as "${name}".` + perform: (ctx: BotContext) => async (distance: number) => { + await skills.moveAway(ctx.bot, distance) + return 'Moving away...' }, - } -} + }, -function getGoToRememberedPlaceAction(): Action { - return { - name: '!goToRememberedPlace', - description: 'Go to a saved location.', - schema: z.object({ - name: z.string().describe('The name of the location to go to.'), - }), - perform: (agent: BotContext) => runAsAction(async (agent, name) => { - const pos = agent.memory_bank.recallPlace(name) - if (!pos) { - skills.log(agent.bot, `No location named "${name}" saved.`) - return - } - await skills.goToPosition(agent.bot, pos[0], pos[1], pos[2], 1) - }), - } -} - -function getGivePlayerAction(): Action { - return { - name: '!givePlayer', + { + name: 'givePlayer', description: 'Give the specified item to the given player.', schema: z.object({ player_name: z.string().describe('The name of the player to give the item to.'), item_name: z.string().describe('The name of the item to give.'), num: z.number().int().describe('The number of items to give.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, player_name, item_name, num) => { - await skills.giveToPlayer(agent.bot, item_name, player_name, num) - }), - } -} + perform: (ctx: BotContext) => async (player_name: string, item_name: string, num: number) => { + await skills.giveToPlayer(ctx.bot, item_name, player_name, num) + return 'Giving items to player...' + }, + }, -function getConsumeAction(): Action { - return { - name: '!consume', + { + name: 'consume', description: 'Eat/drink the given item.', schema: z.object({ item_name: z.string().describe('The name of the item to consume.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { - await skills.consume(agent.bot, item_name) - }), - } -} + perform: (ctx: BotContext) => async (item_name: string) => { + await skills.consume(ctx.bot, item_name) + return 'Consuming item...' + }, + }, -function getEquipAction(): Action { - return { - name: '!equip', + { + name: 'equip', description: 'Equip the given item.', schema: z.object({ item_name: z.string().describe('The name of the item to equip.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name) => { - await skills.equip(agent.bot, item_name) - }), - } -} + perform: (ctx: BotContext) => async (item_name: string) => { + await skills.equip(ctx.bot, item_name) + return 'Equipping item...' + }, + }, -function getPutInChestAction(): Action { - return { - name: '!putInChest', + { + name: 'putInChest', description: 'Put the given item in the nearest chest.', schema: z.object({ item_name: z.string().describe('The name of the item to put in the chest.'), num: z.number().int().describe('The number of items to put in the chest.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { - await skills.putInChest(agent.bot, item_name, num) - }), - } -} + perform: (ctx: BotContext) => async (item_name: string, num: number) => { + await skills.putInChest(ctx.bot, item_name, num) + return 'Putting items in chest...' + }, + }, -function getTakeFromChestAction(): Action { - return { - name: '!takeFromChest', + { + name: 'takeFromChest', description: 'Take the given items from the nearest chest.', schema: z.object({ item_name: z.string().describe('The name of the item to take.'), num: z.number().int().describe('The number of items to take.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { - await skills.takeFromChest(agent.bot, item_name, num) - }), - } -} + perform: (ctx: BotContext) => async (item_name: string, num: number) => { + await skills.takeFromChest(ctx.bot, item_name, num) + return 'Taking items from chest...' + }, + }, -function getViewChestAction(): Action { - return { - name: '!viewChest', + { + name: 'viewChest', description: 'View the items/counts of the nearest chest.', schema: z.object({}), - perform: (agent: BotContext) => runAsAction(async (agent) => { - await skills.viewChest(agent.bot) - }), - } -} + perform: (ctx: BotContext) => async () => { + await skills.viewChest(ctx.bot) + return 'Viewing chest contents...' + }, + }, -function getDiscardAction(): Action { - return { - name: '!discard', + { + name: 'discard', description: 'Discard the given item from the inventory.', schema: z.object({ item_name: z.string().describe('The name of the item to discard.'), num: z.number().int().describe('The number of items to discard.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { - const start_loc = agent.bot.entity.position - await skills.moveAway(agent.bot, 5) - await skills.discard(agent.bot, item_name, num) - await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0) - }), - } -} + perform: (ctx: BotContext) => async (item_name: string, num: number) => { + const start_loc = ctx.bot.entity.position + await skills.moveAway(ctx.bot, 5) + await skills.discard(ctx.bot, item_name, num) + await skills.goToPosition(ctx.bot, start_loc.x, start_loc.y, start_loc.z, 0) + return 'Discarding items...' + }, + }, -function getCollectBlocksAction(): Action { - return { - name: '!collectBlocks', + { + name: 'collectBlocks', description: 'Collect the nearest blocks of a given type.', schema: z.object({ type: z.string().describe('The block type to collect.'), num: z.number().int().describe('The number of blocks to collect.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, type, num) => { - await skills.collectBlock(agent.bot, type, num) - }, false, 10), // 10 minute timeout - } -} + perform: (ctx: BotContext) => async (type: string, num: number) => { + await skills.collectBlock(ctx.bot, type, num) + return 'Collecting blocks...' + }, + }, -function getCraftRecipeAction(): Action { - return { - name: '!craftRecipe', + { + name: 'craftRecipe', description: 'Craft the given recipe a given number of times.', schema: z.object({ recipe_name: z.string().describe('The name of the output item to craft.'), num: z.number().int().describe('The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, recipe_name, num) => { - await skills.craftRecipe(agent.bot, recipe_name, num) - }), - } -} + perform: (ctx: BotContext) => async (recipe_name: string, num: number) => { + await skills.craftRecipe(ctx.bot, recipe_name, num) + return 'Crafting items...' + }, + }, -function getSmeltItemAction(): Action { - return { - name: '!smeltItem', + { + name: 'smeltItem', description: 'Smelt the given item the given number of times.', schema: z.object({ item_name: z.string().describe('The name of the input item to smelt.'), num: z.number().int().describe('The number of times to smelt the item.').min(1), }), - perform: (agent: BotContext) => runAsAction(async (agent, item_name, num) => { - const success = await skills.smeltItem(agent.bot, item_name, num) - if (success) { - setTimeout(() => { - agent.cleanKill('Safely restarting to update inventory.') - }, 500) - } - }), - } -} + perform: (ctx: BotContext) => async (item_name: string, num: number) => { + await skills.smeltItem(ctx.bot, item_name, num) + return 'Smelting items...' + }, + }, -function getClearFurnaceAction(): Action { - return { - name: '!clearFurnace', + { + name: 'clearFurnace', description: 'Take all items out of the nearest furnace.', schema: z.object({}), - perform: (agent: BotContext) => runAsAction(async (agent) => { - await skills.clearNearestFurnace(agent.bot) - }), - } -} + perform: (ctx: BotContext) => async () => { + await skills.clearNearestFurnace(ctx.bot) + return 'Clearing furnace...' + }, + }, -function getPlaceHereAction(): Action { - return { - name: '!placeHere', + { + name: 'placeHere', description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.', schema: z.object({ type: z.string().describe('The block type to place.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, type) => { - const pos = agent.bot.entity.position - await skills.placeBlock(agent.bot, type, pos.x, pos.y, pos.z) - }), - } -} + perform: (ctx: BotContext) => async (type: string) => { + const pos = ctx.bot.entity.position + await skills.placeBlock(ctx.bot, type, pos.x, pos.y, pos.z) + return 'Placing block...' + }, + }, -function getAttackAction(): Action { - return { - name: '!attack', + { + name: 'attack', description: 'Attack and kill the nearest entity of a given type.', schema: z.object({ type: z.string().describe('The type of entity to attack.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, type) => { - await skills.attackNearest(agent.bot, type, true) - }), - } -} + perform: (ctx: BotContext) => async (type: string) => { + await skills.attackNearest(ctx.bot, type, true) + return 'Attacking entity...' + }, + }, -function getAttackPlayerAction(): Action { - return { - name: '!attackPlayer', + { + name: 'attackPlayer', description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', schema: z.object({ player_name: z.string().describe('The name of the player to attack.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, player_name) => { - const player = agent.bot.players[player_name]?.entity + perform: (ctx: BotContext) => async (player_name: string) => { + const player = ctx.bot.players[player_name]?.entity if (!player) { - skills.log(agent.bot, `Could not find player ${player_name}.`) - return false + skills.log(ctx.bot, `Could not find player ${player_name}.`) + return 'Player not found' } - await skills.attackEntity(agent.bot, player, true) - }), - } -} + await skills.attackEntity(ctx.bot, player, true) + return 'Attacking player...' + }, + }, -function getGoToBedAction(): Action { - return { - name: '!goToBed', + { + name: 'goToBed', description: 'Go to the nearest bed and sleep.', schema: z.object({}), - perform: (agent: BotContext) => runAsAction(async (agent) => { - await skills.goToBed(agent.bot) - }), - } -} + perform: (ctx: BotContext) => async () => { + await skills.goToBed(ctx.bot) + return 'Going to bed...' + }, + }, -function getActivateAction(): Action { - return { - name: '!activate', + { + name: 'activate', description: 'Activate the nearest object of a given type.', schema: z.object({ type: z.string().describe('The type of object to activate.'), }), - perform: (agent: BotContext) => runAsAction(async (agent, type) => { - await skills.activateNearestBlock(agent.bot, type) - }), - } -} + perform: (ctx: BotContext) => async (type: string) => { + await skills.activateNearestBlock(ctx.bot, type) + return 'Activating block...' + }, + }, -function getStayAction(): Action { - return { - name: '!stay', + { + name: 'stay', description: 'Stay in the current location no matter what. Pauses all modes.', schema: z.object({ type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), }), - perform: (agent: BotContext) => runAsAction(async (agent, seconds) => { - await skills.stay(agent.bot, seconds) - }), - } -} - -function getSetModeAction(): Action { - return { - name: '!setMode', - description: 'Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.', - schema: z.object({ - mode_name: z.string().describe('The name of the mode to enable.'), - on: z.boolean().describe('Whether to enable or disable the mode.'), - }), - perform: (agent: BotContext) => async (mode_name: string, on: boolean) => { - const modes = agent.bot.modes - if (!modes.exists(mode_name)) - return `Mode ${mode_name} does not exist.${modes.getDocs()}` - if (modes.isOn(mode_name) === on) - return `Mode ${mode_name} is already ${on ? 'on' : 'off'}.` - modes.setOn(mode_name, on) - return `Mode ${mode_name} is now ${on ? 'on' : 'off'}.` + perform: (ctx: BotContext) => async (seconds: number) => { + await skills.stay(ctx.bot, seconds) + return 'Staying in place...' }, - } -} + }, + // getSetModeAction(): Action { + // return { + // name: 'setMode', + // description: 'Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.', + // schema: z.object({ + // mode_name: z.string().describe('The name of the mode to enable.'), + // on: z.boolean().describe('Whether to enable or disable the mode.'), + // }), + // perform: (ctx: BotContext) => async (mode_name: string, on: boolean) => { + // const modes = ctx.bot.modes + // if (!modes.exists(mode_name)) + // return `Mode ${mode_name} does not exist.${modes.getDocs()}` + // if (modes.isOn(mode_name) === on) + // return `Mode ${mode_name} is already ${on ? 'on' : 'off'}.` + // modes.setOn(mode_name, on) + // return `Mode ${mode_name} is now ${on ? 'on' : 'off'}.` + // }, + // } + // }, -function getGoalAction(): Action { - return { - name: '!goal', - description: 'Set a goal prompt to endlessly work towards with continuous self-prompting.', - schema: z.object({ - selfPrompt: z.string().describe('The goal prompt.'), - }), - perform: (agent: BotContext) => async (prompt: string) => { - if (convoManager.inConversation()) { - agent.self_prompter.setPrompt(prompt) - convoManager.scheduleSelfPrompter() - } - else { - agent.self_prompter.start(prompt) - } - }, - } -} + // getGoalAction(): Action { + // return { + // name: 'goal', + // description: 'Set a goal prompt to endlessly work towards with continuous self-prompting.', + // schema: z.object({ + // selfPrompt: z.string().describe('The goal prompt.'), + // }), + // perform: (ctx: BotContext) => async (prompt: string) => { + // if (convoManager.inConversation()) { + // ctx.self_prompter.setPrompt(prompt) + // convoManager.scheduleSelfPrompter() + // } + // else { + // ctx.self_prompter.start(prompt) + // } + // return 'Goal set...' + // }, + // } + // }, -function getEndGoalAction(): Action { - return { - name: '!endGoal', - description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', - schema: z.object({}), - perform: (agent: BotContext) => async () => { - agent.self_prompter.stop() - convoManager.cancelSelfPrompter() - return 'Self-prompting stopped.' - }, - } -} + // getEndGoalAction(): Action { + // return { + // name: 'endGoal', + // description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', + // schema: z.object({}), + // perform: (ctx: BotContext) => async () => { + // ctx.self_prompter.stop() + // convoManager.cancelSelfPrompter() + // return 'Self-prompting stopped.' + // }, + // } + // }, -function getStartConversationAction(): Action { - return { - name: '!startConversation', - description: 'Start a conversation with a player. Use for bots only.', - schema: z.object({ - player_name: z.string().describe('The name of the player to send the message to.'), - message: z.string().describe('The message to send.'), - }), - perform: (agent: BotContext) => async (player_name: string, message: string) => { - if (!convoManager.isOtherAgent(player_name)) - return `${player_name} is not a bot, cannot start conversation.` - if (convoManager.inConversation() && !convoManager.inConversation(player_name)) - convoManager.forceEndCurrentConversation() - else if (convoManager.inConversation(player_name)) - agent.history.add('system', `You are already in conversation with ${player_name}. Don't use this command to talk to them.`) - convoManager.startConversation(player_name, message) - }, - } -} + // getStartConversationAction(): Action { + // return { + // name: 'startConversation', + // description: 'Start a conversation with a player. Use for bots only.', + // schema: z.object({ + // player_name: z.string().describe('The name of the player to send the message to.'), + // message: z.string().describe('The message to send.'), + // }), + // perform: (ctx: BotContext) => async (player_name: string, message: string) => { + // if (!convoManager.isOtherAgent(player_name)) + // return `${player_name} is not a bot, cannot start conversation.` + // if (convoManager.inConversation() && !convoManager.inConversation(player_name)) + // convoManager.forceEndCurrentConversation() + // else if (convoManager.inConversation(player_name)) + // ctx.history.add('system', `You are already in conversation with ${player_name}. Don't use this command to talk to them.`) + // convoManager.startConversation(player_name, message) + // }, + // } + // }, -function getEndConversationAction(): Action { - return { - name: '!endConversation', - description: 'End the conversation with the given player.', - schema: z.object({ - player_name: z.string().describe('The name of the player to end the conversation with.'), - }), - perform: (agent: BotContext) => async (player_name: string) => { - if (!convoManager.inConversation(player_name)) - return `Not in conversation with ${player_name}.` - convoManager.endConversation(player_name) - return `Converstaion with ${player_name} ended.` - }, - } -} - -export const actionsList = [ - getNewAction(), - getStopAction(), - getStfuAction(), - getRestartAction(), - getClearChatAction(), - getGoToPlayerAction(), - getFollowPlayerAction(), - getGoToCoordinatesAction(), - getSearchForBlockAction(), - getSearchForEntityAction(), - getMoveAwayAction(), - getRememberHereAction(), - getGoToRememberedPlaceAction(), - getGivePlayerAction(), - getConsumeAction(), - getEquipAction(), - getPutInChestAction(), - getTakeFromChestAction(), - getViewChestAction(), - getDiscardAction(), - getCollectBlocksAction(), - getCraftRecipeAction(), - getSmeltItemAction(), - getClearFurnaceAction(), - getPlaceHereAction(), - getAttackAction(), - getAttackPlayerAction(), - getGoToBedAction(), - getActivateAction(), - getStayAction(), - getSetModeAction(), - getGoalAction(), - getEndGoalAction(), - getStartConversationAction(), - getEndConversationAction(), + // getEndConversationAction(): Action { + // return { + // name: 'endConversation', + // description: 'End the conversation with the given player.', + // schema: z.object({ + // player_name: z.string().describe('The name of the player to end the conversation with.'), + // }), + // perform: (ctx: BotContext) => async (player_name: string) => { + // if (!convoManager.inConversation(player_name)) + // return `Not in conversation with ${player_name}.` + // convoManager.endConversation(player_name) + // return `Converstaion with ${player_name} ended.` + // }, + // } + // }, ] diff --git a/services/minecraft/src/agents/agent.ts b/services/minecraft/src/agents/agent.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 6989b7999..865f293ba 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -3,7 +3,8 @@ import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { queryList } from './queries' +import { actionsList } from './actions' +import { queriesList } from './queries' const agents = new Set>() @@ -14,6 +15,7 @@ export async function initAgent(ctx: BotContext): Promise { let n = neuri() agents.add(initQueryAgent(ctx)) + agents.add(initActionAgent(ctx)) agents.forEach(agent => n = n.agent(agent)) @@ -29,7 +31,7 @@ export async function initQueryAgent(ctx: BotContext): Promise { logger.log('Initializing query agent') let queryAgent = agent('query') - queryList.forEach((query) => { + Object.values(queriesList).forEach((query) => { queryAgent = queryAgent.tool( query.name, query.schema, @@ -40,3 +42,19 @@ export async function initQueryAgent(ctx: BotContext): Promise { return queryAgent.build() } + +export async function initActionAgent(ctx: BotContext): Promise { + logger.log('Initializing action agent') + let actionAgent = agent('action') + + Object.values(actionsList).forEach((action) => { + actionAgent = actionAgent.tool( + action.name, + action.schema, + action.perform(ctx), + { description: action.description }, + ) + }) + + return actionAgent.build() +} diff --git a/services/minecraft/src/agents/queries.ts b/services/minecraft/src/agents/queries.ts index b872a5d24..6db4d84bf 100644 --- a/services/minecraft/src/agents/queries.ts +++ b/services/minecraft/src/agents/queries.ts @@ -1,7 +1,6 @@ import type { BotContext } from '../composables/bot' import { z } from 'zod' import { getStatusToString } from '../components/status' -import * as world from '../composables/world' // Core types type QueryResult = string | Promise @@ -24,93 +23,71 @@ function formatWearingItem(slot: string, item: string | undefined): string { return item ? `\n${slot}: ${item}` : '' } -// Query implementations -function createStatsQuery(): Query { - return { +export const queriesList: Query[] = [ + { name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), perform: (ctx: BotContext) => (): string => getStatusToString(ctx), - } -} + }, + // { + // name: 'inventory', + // description: 'Get your bot\'s inventory.', + // schema: z.object({}), + // perform: (ctx: BotContext) => (): string => { + // const { bot } = ctx + // const inventory = world.getInventoryCounts({ bot, botCtx: ctx }) + // const items = Object.entries(inventory) + // .map(([item, count]) => formatInventoryItem(item, count)) + // .join('') -function createInventoryQuery(): Query { - return { - name: 'inventory', - description: 'Get your bot\'s inventory.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const { bot } = ctx - const inventory = world.getInventoryCounts({ bot, botCtx: ctx }) - const items = Object.entries(inventory) - .map(([item, count]) => formatInventoryItem(item, count)) - .join('') + // const wearing = [ + // formatWearingItem('Head', bot.inventory.slots[5]?.name), + // formatWearingItem('Torso', bot.inventory.slots[6]?.name), + // formatWearingItem('Legs', bot.inventory.slots[7]?.name), + // formatWearingItem('Feet', bot.inventory.slots[8]?.name), + // ].filter(Boolean).join('') - const wearing = [ - formatWearingItem('Head', bot.inventory.slots[5]?.name), - formatWearingItem('Torso', bot.inventory.slots[6]?.name), - formatWearingItem('Legs', bot.inventory.slots[7]?.name), - formatWearingItem('Feet', bot.inventory.slots[8]?.name), - ].filter(Boolean).join('') + // return pad(`INVENTORY${items || ': Nothing'} + // ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} + // WEARING: ${wearing || 'Nothing'}`) + // }, + // }, + // { + // name: 'nearbyBlocks', + // description: 'Get the blocks near the bot.', + // schema: z.object({}), + // perform: (ctx: BotContext) => (): string => { + // const blocks = world.getNearbyBlockTypes({ bot: ctx.bot, botCtx: ctx }) + // return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`) + // }, + // }, + // { + // name: 'craftable', + // description: 'Get the craftable items with the bot\'s inventory.', + // schema: z.object({}), + // perform: (ctx: BotContext) => (): string => { + // const craftable = world.getCraftableItems({ bot: ctx.bot, botCtx: ctx }) + // return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`) + // }, + // }, + // { + // name: 'entities', + // description: 'Get the nearby players and entities.', + // schema: z.object({}), + // perform: (ctx: BotContext) => (): string => { + // const { bot } = ctx + // const worldCtx = { bot, botCtx: ctx } + // const players = world.getNearbyPlayerNames(worldCtx) + // const entities = world.getNearbyEntityTypes(worldCtx) + // .filter((e: string) => e !== 'player' && e !== 'item') - return pad(`INVENTORY${items || ': Nothing'} -${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} -WEARING: ${wearing || 'Nothing'}`) - }, - } -} + // const result = [ + // ...players.map((p: string) => `- Human player: ${p}`), + // ...entities.map((e: string) => `- entities: ${e}`), + // ] -function createNearbyBlocksQuery(): Query { - return { - name: 'nearbyBlocks', - description: 'Get the blocks near the bot.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const blocks = world.getNearbyBlockTypes({ bot: ctx.bot, botCtx: ctx }) - return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`) - }, - } -} - -function createCraftableQuery(): Query { - return { - name: 'craftable', - description: 'Get the craftable items with the bot\'s inventory.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const craftable = world.getCraftableItems({ bot: ctx.bot, botCtx: ctx }) - return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`) - }, - } -} - -function createEntitiesQuery(): Query { - return { - name: 'entities', - description: 'Get the nearby players and entities.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const { bot } = ctx - const worldCtx = { bot, botCtx: ctx } - const players = world.getNearbyPlayerNames(worldCtx) - const entities = world.getNearbyEntityTypes(worldCtx) - .filter((e: string) => e !== 'player' && e !== 'item') - - const result = [ - ...players.map((p: string) => `- Human player: ${p}`), - ...entities.map((e: string) => `- entities: ${e}`), - ] - - return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) - }, - } -} - -// Export query list -export const queryList: readonly Query[] = [ - createStatsQuery(), - // createInventoryQuery(), - // createNearbyBlocksQuery(), - // createCraftableQuery(), - // createEntitiesQuery(), + // return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) + // }, + // }, ] as const diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index a96e97e51..cca0cb14f 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -6,6 +6,87 @@ import * as mc from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' +export async function collectBlock( + bot: Bot, + blockType: string, + num: number = 1, + exclude: typeof Vec3[] | null = null, +): Promise { + if (num < 1) { + log(bot, `Invalid number of blocks to collect: ${num}.`) + return false + } + + const blocktypes: string[] = [blockType] + if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' + || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' + || blockType === 'redstone') { + blocktypes.push(`${blockType}_ore`) + } + if (blockType.endsWith('ore')) { + blocktypes.push(`deepslate_${blockType}`) + } + if (blockType === 'dirt') { + blocktypes.push('grass_block') + } + + let collected = 0 + + for (let i = 0; i < num; i++) { + let blocks = world.getNearestBlocks(bot, blocktypes, 64) + if (exclude) { + blocks = blocks.filter( + block => !exclude.some(pos => + pos.x === block.position.x + && pos.y === block.position.y + && pos.z === block.position.z, + ), + ) + } + + const movements = new bot.pathfinder.Movements(bot) + movements.dontMineUnderFallingBlock = false + blocks = blocks.filter(block => movements.safeToBreak(block)) + + if (blocks.length === 0) { + log(bot, collected === 0 + ? `No ${blockType} nearby to collect.` + : `No more ${blockType} nearby to collect.`) + break + } + + const block = blocks[0] + await bot.tool.equipForBlock(block) + const itemId = bot.heldItem ? bot.heldItem.type : null + + if (!block.canHarvest(itemId)) { + log(bot, `Don't have right tools to harvest ${blockType}.`) + return false + } + + try { + await bot.collectBlock.collect(block) + collected++ + await autoLight(bot) + } + catch (err) { + if (err.name === 'NoChests') { + log(bot, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) + break + } + log(bot, `Failed to collect ${blockType}: ${err}.`) + continue + } + + if (bot.interrupt_code) { + break + } + } + + log(bot, `Collected ${collected} ${blockType}.`) + return collected > 0 +} + /** * Place a torch if needed */ diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 8afd98aca..a6fdc989e 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -1,8 +1,8 @@ import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' -import * as mc from '../../../utils/mcdata.js' -import * as world from '../world.js' -import { log } from './base.js' +import * as world from '../composables/world' +import * as mc from '../utils/mcdata' +import { log } from './base' /** * Equip the item with highest attack damage diff --git a/services/minecraft/src/skills/index.ts b/services/minecraft/src/skills/index.ts index ed2a3589e..6d1ceb0cf 100644 --- a/services/minecraft/src/skills/index.ts +++ b/services/minecraft/src/skills/index.ts @@ -1,50 +1,6 @@ -// Base utilities -export { log } from './base' -export type { BlockFace, Position } from './base' - -// Block interaction functions -export { - activateNearestBlock, - breakBlockAt, - placeBlock, - tillAndSow, - useDoor, -} from './blocks.js' - -// Combat related functions -export { - attackEntity, - attackNearest, - defendSelf, -} from './combat.js' - -// Crafting and smelting functions -export { - clearNearestFurnace, - craftRecipe, - smeltItem, -} from './crafting.js' - -// Inventory management functions -export { - consume, - discard, - equip, - giveToPlayer, - pickupNearbyItems, - putInChest, - takeFromChest, - viewChest, -} from './inventory.js' - -// Movement related functions -export { - followPlayer, - goToNearestBlock, - goToNearestEntity, - goToPlayer, - goToPosition, - moveAway, - moveAwayFromEntity, - stay, -} from './movement.js' +export * from './base' +export * from './blocks.js' +export * from './combat.js' +export * from './crafting.js' +export * from './inventory.js' +export * from './movement.js' diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index ac2778243..0e809cc26 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -206,3 +206,34 @@ export async function stay(bot: Bot, seconds = 30): Promise { log(bot, `Stayed for ${(Date.now() - start) / 1000} seconds.`) return true } +/** + * Sleep in the nearest bed within 32 blocks + */ +export async function goToBed(bot: Bot): Promise { + const beds: Vec3[] = bot.findBlocks({ + matching: (block: Block) => block.name.includes('bed'), + maxDistance: 32, + count: 1, + }) + + if (beds.length === 0) { + log(bot, 'Could not find a bed to sleep in.') + return false + } + + const loc: Vec3 = beds[0] + await goToPosition(bot, loc.x, loc.y, loc.z) + + const bed: Block | null = bot.blockAt(loc) + await bot.sleep(bed) + log(bot, 'You are in bed.') + + bot.modes.pause('unstuck') + + while (bot.isSleeping) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + + log(bot, 'You have woken up.') + return true +} From 63aa146fcaf18957c6a10e2e8ae97660b3066acd Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 02:02:23 +0800 Subject: [PATCH 21/77] chore: queries --- services/minecraft/src/agents/openai.test.ts | 2 +- services/minecraft/src/agents/queries.ts | 118 ++++++++++--------- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index 64c05ca6f..c7ea1644b 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -31,7 +31,7 @@ describe('openAI agent', { timeout: 10000 }, () => { expect(text?.toLowerCase()).toContain('airi') }) - it('should choose right command', async () => { + it('should choose right query command', async () => { const { ctx } = useBot() const agent = await initAgent(ctx) diff --git a/services/minecraft/src/agents/queries.ts b/services/minecraft/src/agents/queries.ts index 6db4d84bf..075d887c0 100644 --- a/services/minecraft/src/agents/queries.ts +++ b/services/minecraft/src/agents/queries.ts @@ -1,6 +1,7 @@ import type { BotContext } from '../composables/bot' import { z } from 'zod' import { getStatusToString } from '../components/status' +import * as world from '../composables/world' // Core types type QueryResult = string | Promise @@ -30,64 +31,67 @@ export const queriesList: Query[] = [ schema: z.object({}), perform: (ctx: BotContext) => (): string => getStatusToString(ctx), }, - // { - // name: 'inventory', - // description: 'Get your bot\'s inventory.', - // schema: z.object({}), - // perform: (ctx: BotContext) => (): string => { - // const { bot } = ctx - // const inventory = world.getInventoryCounts({ bot, botCtx: ctx }) - // const items = Object.entries(inventory) - // .map(([item, count]) => formatInventoryItem(item, count)) - // .join('') + { + name: 'inventory', + description: 'Get your bot\'s inventory.', + schema: z.object({}), + perform: (ctx: BotContext) => (): string => { + const { bot } = ctx + const worldCtx = { bot, botCtx: ctx } + const inventory = world.getInventoryCounts(worldCtx) + const items = Object.entries(inventory) + .map(([item, count]) => formatInventoryItem(item, count)) + .join('') - // const wearing = [ - // formatWearingItem('Head', bot.inventory.slots[5]?.name), - // formatWearingItem('Torso', bot.inventory.slots[6]?.name), - // formatWearingItem('Legs', bot.inventory.slots[7]?.name), - // formatWearingItem('Feet', bot.inventory.slots[8]?.name), - // ].filter(Boolean).join('') + const wearing = [ + formatWearingItem('Head', bot.inventory.slots[5]?.name), + formatWearingItem('Torso', bot.inventory.slots[6]?.name), + formatWearingItem('Legs', bot.inventory.slots[7]?.name), + formatWearingItem('Feet', bot.inventory.slots[8]?.name), + ].filter(Boolean).join('') - // return pad(`INVENTORY${items || ': Nothing'} - // ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} - // WEARING: ${wearing || 'Nothing'}`) - // }, - // }, - // { - // name: 'nearbyBlocks', - // description: 'Get the blocks near the bot.', - // schema: z.object({}), - // perform: (ctx: BotContext) => (): string => { - // const blocks = world.getNearbyBlockTypes({ bot: ctx.bot, botCtx: ctx }) - // return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`) - // }, - // }, - // { - // name: 'craftable', - // description: 'Get the craftable items with the bot\'s inventory.', - // schema: z.object({}), - // perform: (ctx: BotContext) => (): string => { - // const craftable = world.getCraftableItems({ bot: ctx.bot, botCtx: ctx }) - // return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`) - // }, - // }, - // { - // name: 'entities', - // description: 'Get the nearby players and entities.', - // schema: z.object({}), - // perform: (ctx: BotContext) => (): string => { - // const { bot } = ctx - // const worldCtx = { bot, botCtx: ctx } - // const players = world.getNearbyPlayerNames(worldCtx) - // const entities = world.getNearbyEntityTypes(worldCtx) - // .filter((e: string) => e !== 'player' && e !== 'item') + return pad(`INVENTORY${items || ': Nothing'} + ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} + WEARING: ${wearing || 'Nothing'}`) + }, + }, + { + name: 'nearbyBlocks', + description: 'Get the blocks near the bot.', + schema: z.object({}), + perform: (ctx: BotContext) => (): string => { + const worldCtx = { bot: ctx.bot, botCtx: ctx } + const blocks = world.getNearbyBlockTypes(worldCtx) + return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) + }, + }, + { + name: 'craftable', + description: 'Get the craftable items with the bot\'s inventory.', + schema: z.object({}), + perform: (ctx: BotContext) => (): string => { + const worldCtx = { bot: ctx.bot, botCtx: ctx } + const craftable = world.getCraftableItems(worldCtx) + return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) + }, + }, + { + name: 'entities', + description: 'Get the nearby players and entities.', + schema: z.object({}), + perform: (ctx: BotContext) => (): string => { + const { bot } = ctx + const worldCtx = { bot, botCtx: ctx } + const players = world.getNearbyPlayerNames(worldCtx) + const entities = world.getNearbyEntityTypes(worldCtx) + .filter((e: string) => e !== 'player' && e !== 'item') - // const result = [ - // ...players.map((p: string) => `- Human player: ${p}`), - // ...entities.map((e: string) => `- entities: ${e}`), - // ] + const result = [ + ...players.map((p: string) => `- Human player: ${p}`), + ...entities.map((e: string) => `- entities: ${e}`), + ] - // return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) - // }, - // }, -] as const + return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) + }, + }, +] From 02ef3ec6ecb9001da072774a636eb6e0e086e59e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 02:29:26 +0800 Subject: [PATCH 22/77] refactor: skill ctx --- services/minecraft/src/skills/base.ts | 38 +- services/minecraft/src/skills/blocks.ts | 721 +++++++++++++-------- services/minecraft/src/skills/combat.ts | 75 ++- services/minecraft/src/skills/crafting.ts | 156 +++-- services/minecraft/src/skills/inventory.ts | 79 ++- services/minecraft/src/skills/movement.ts | 132 ++-- 6 files changed, 707 insertions(+), 494 deletions(-) diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index ed7ae041c..e20c02400 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,14 +1,42 @@ import type { Bot } from 'mineflayer' /** - * Log a message to the bot's output + * Context for skill execution */ -export function log(bot: Bot, message: string): void { - bot.chat(`${message}`) +export interface SkillContext { + bot: Bot + // Whether the bot is in creative mode + isCreative: boolean + // Whether the bot should use cheats (like /tp, /setblock) + allowCheats: boolean + // Whether the bot should interrupt current action + shouldInterrupt: boolean + // Output buffer for logging + output: string[] } /** - * Type definition for a position in the world + * Create a new skill context + */ +export function createContext(bot: Bot): SkillContext { + return { + bot, + isCreative: bot.game.gameMode === 'creative', + allowCheats: false, + shouldInterrupt: false, + output: [], + } +} + +/** + * Log a message to the context's output buffer + */ +export function log(ctx: SkillContext, message: string): void { + ctx.output.push(message) +} + +/** + * Position in the world */ export interface Position { x: number @@ -17,6 +45,6 @@ export interface Position { } /** - * Type definition for a block face direction + * Block face direction */ export type BlockFace = 'top' | 'bottom' | 'north' | 'south' | 'east' | 'west' | 'side' diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index cca0cb14f..657101cc8 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,100 +1,18 @@ -import type { Bot } from 'mineflayer' -import type { BlockFace } from './base' -import Vec3 from 'vec3' +import type { BlockFace, SkillContext } from './base' +import { Vec3 } from 'vec3' import * as world from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' -export async function collectBlock( - bot: Bot, - blockType: string, - num: number = 1, - exclude: typeof Vec3[] | null = null, -): Promise { - if (num < 1) { - log(bot, `Invalid number of blocks to collect: ${num}.`) - return false - } - - const blocktypes: string[] = [blockType] - if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' - || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' - || blockType === 'redstone') { - blocktypes.push(`${blockType}_ore`) - } - if (blockType.endsWith('ore')) { - blocktypes.push(`deepslate_${blockType}`) - } - if (blockType === 'dirt') { - blocktypes.push('grass_block') - } - - let collected = 0 - - for (let i = 0; i < num; i++) { - let blocks = world.getNearestBlocks(bot, blocktypes, 64) - if (exclude) { - blocks = blocks.filter( - block => !exclude.some(pos => - pos.x === block.position.x - && pos.y === block.position.y - && pos.z === block.position.z, - ), - ) - } - - const movements = new bot.pathfinder.Movements(bot) - movements.dontMineUnderFallingBlock = false - blocks = blocks.filter(block => movements.safeToBreak(block)) - - if (blocks.length === 0) { - log(bot, collected === 0 - ? `No ${blockType} nearby to collect.` - : `No more ${blockType} nearby to collect.`) - break - } - - const block = blocks[0] - await bot.tool.equipForBlock(block) - const itemId = bot.heldItem ? bot.heldItem.type : null - - if (!block.canHarvest(itemId)) { - log(bot, `Don't have right tools to harvest ${blockType}.`) - return false - } - - try { - await bot.collectBlock.collect(block) - collected++ - await autoLight(bot) - } - catch (err) { - if (err.name === 'NoChests') { - log(bot, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) - break - } - log(bot, `Failed to collect ${blockType}: ${err}.`) - continue - } - - if (bot.interrupt_code) { - break - } - } - - log(bot, `Collected ${collected} ${blockType}.`) - return collected > 0 -} - /** * Place a torch if needed */ -async function autoLight(bot: Bot): Promise { - if (world.shouldPlaceTorch(bot)) { +async function autoLight(ctx: SkillContext): Promise { + if (world.shouldPlaceTorch(ctx.bot)) { try { - const pos = world.getPosition(bot) - return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true) + const pos = world.getPosition(ctx.bot) + return await placeBlock(ctx, 'torch', pos.x, pos.y, pos.z, 'bottom', true) } catch { return false @@ -107,26 +25,49 @@ async function autoLight(bot: Bot): Promise { * Break a block at the specified position */ export async function breakBlockAt( - bot: Bot, + ctx: SkillContext, x: number, y: number, z: number, ): Promise { + const { bot } = ctx + validatePosition(x, y, z) + + const block = bot.blockAt(new Vec3(x, y, z)) + if (isUnbreakableBlock(block)) + return false + + if (ctx.allowCheats) { + return breakWithCheats(ctx, x, y, z) + } + + await moveIntoRange(bot, block) + + if (ctx.isCreative) { + return breakInCreative(ctx, block, x, y, z) + } + + return breakInSurvival(ctx, block, x, y, z) +} + +function validatePosition(x: number, y: number, z: number) { if (x == null || y == null || z == null) { throw new Error('Invalid position to break block at.') } +} - const block = bot.blockAt(new Vec3(x, y, z)) - if (block.name === 'air' || block.name === 'water' || block.name === 'lava') { - return false - } +function isUnbreakableBlock(block: any): boolean { + return block.name === 'air' || block.name === 'water' || block.name === 'lava' +} - if (bot.modes.isOn('cheat')) { - bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) - log(bot, `Used /setblock to break block at ${x}, ${y}, ${z}.`) - return true - } +async function breakWithCheats(ctx: SkillContext, x: number, y: number, z: number): Promise { + const { bot } = ctx + bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + log(ctx, `Used /setblock to break block at ${x}, ${y}, ${z}.`) + return true +} +async function moveIntoRange(bot: any, block: any) { if (bot.entity.position.distanceTo(block.position) > 4.5) { const pos = block.position const movements = new bot.pathfinder.Movements(bot) @@ -135,18 +76,26 @@ export async function breakBlockAt( bot.pathfinder.setMovements(movements) await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) } +} - if (bot.game.gameMode !== 'creative') { - await bot.tool.equipForBlock(block) - const itemId = bot.heldItem?.type - if (!block.canHarvest(itemId)) { - log(bot, `Don't have right tools to break ${block.name}.`) - return false - } +async function breakInCreative(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { + const { bot } = ctx + await bot.dig(block, true) + log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + return true +} + +async function breakInSurvival(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { + const { bot } = ctx + await bot.tool.equipForBlock(block) + const itemId = bot.heldItem?.type + if (!block.canHarvest(itemId)) { + log(ctx, `Don't have right tools to break ${block.name}.`) + return false } await bot.dig(block, true) - log(bot, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) return true } @@ -154,7 +103,7 @@ export async function breakBlockAt( * Place a block at the specified position */ export async function placeBlock( - bot: Bot, + ctx: SkillContext, blockType: string, x: number, y: number, @@ -162,100 +111,157 @@ export async function placeBlock( placeOn: BlockFace = 'bottom', dontCheat = false, ): Promise { + const { bot } = ctx if (!mc.getBlockId(blockType)) { - log(bot, `Invalid block type: ${blockType}.`) + log(ctx, `Invalid block type: ${blockType}.`) return false } const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) - if (bot.modes.isOn('cheat') && !dontCheat) { - // Invert the facing direction - const face = placeOn === 'north' - ? 'south' - : placeOn === 'south' - ? 'north' - : placeOn === 'east' - ? 'west' - : placeOn === 'west' - ? 'east' - : placeOn - - let blockState = blockType - if (blockType.includes('torch') && placeOn !== 'bottom') { - blockState = blockType.replace('torch', 'wall_torch') - if (placeOn !== 'side' && placeOn !== 'top') { - blockState += `[facing=${face}]` - } - } - - if (blockType.includes('button') || blockType === 'lever') { - if (placeOn === 'top') { - blockState += '[face=ceiling]' - } - else if (placeOn === 'bottom') { - blockState += '[face=floor]' - } - else { - blockState += `[facing=${face}]` - } - } - - if (blockType === 'ladder' || blockType === 'repeater' || blockType === 'comparator') { - blockState += `[facing=${face}]` - } - - if (blockType.includes('stairs')) { - blockState += `[facing=${face}]` - } - - bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} ${blockState}`) - - if (blockType.includes('door')) { - bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y + 1)} ${Math.floor(z)} ${blockState}[half=upper]`) - } - - if (blockType.includes('bed')) { - bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z - 1)} ${blockState}[part=head]`) - } - - log(bot, `Used /setblock to place ${blockType} at ${targetDest}.`) - return true + if (ctx.allowCheats && !dontCheat) { + return placeWithCheats(ctx, blockType, targetDest, placeOn) } - let itemName = blockType - if (itemName === 'redstone_wire') { - itemName = 'redstone' + return placeWithoutCheats(ctx, blockType, targetDest, placeOn) +} + +function getBlockState(blockType: string, placeOn: BlockFace): string { + const face = getInvertedFace(placeOn) + let blockState = blockType + + if (blockType.includes('torch') && placeOn !== 'bottom') { + blockState = handleTorchState(blockType, placeOn, face) } + if (blockType.includes('button') || blockType === 'lever') { + blockState = handleButtonLeverState(blockState, placeOn, face) + } + + if (needsFacingState(blockType)) { + blockState += `[facing=${face}]` + } + + return blockState +} + +function getInvertedFace(placeOn: BlockFace): string { + const faceMap = { + north: 'south', + south: 'north', + east: 'west', + west: 'east', + } + return faceMap[placeOn] || placeOn +} + +function handleTorchState(blockType: string, placeOn: BlockFace, face: string): string { + let state = blockType.replace('torch', 'wall_torch') + if (placeOn !== 'side' && placeOn !== 'top') { + state += `[facing=${face}]` + } + return state +} + +function handleButtonLeverState(blockState: string, placeOn: BlockFace, face: string): string { + if (placeOn === 'top') { + return `${blockState}[face=ceiling]` + } + if (placeOn === 'bottom') { + return `${blockState}[face=floor]` + } + return `${blockState}[facing=${face}]` +} + +function needsFacingState(blockType: string): boolean { + return blockType === 'ladder' + || blockType === 'repeater' + || blockType === 'comparator' + || blockType.includes('stairs') +} + +async function placeWithCheats( + ctx: SkillContext, + blockType: string, + targetDest: Vec3, + placeOn: BlockFace, +): Promise { + const { bot } = ctx + const blockState = getBlockState(blockType, placeOn) + + bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) + + if (blockType.includes('door')) { + bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) + } + + if (blockType.includes('bed')) { + bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) + } + + log(ctx, `Used /setblock to place ${blockType} at ${targetDest}.`) + return true +} + +async function placeWithoutCheats( + ctx: SkillContext, + blockType: string, + targetDest: Vec3, + placeOn: BlockFace, +): Promise { + const { bot } = ctx + const itemName = blockType === 'redstone_wire' ? 'redstone' : blockType + let block = bot.inventory.items().find(item => item.name === itemName) - if (!block && bot.game.gameMode === 'creative') { + if (!block && ctx.isCreative) { await bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) block = bot.inventory.items().find(item => item.name === itemName) } if (!block) { - log(bot, `Don't have any ${blockType} to place.`) + log(ctx, `Don't have any ${blockType} to place.`) return false } const targetBlock = bot.blockAt(targetDest) if (targetBlock.name === blockType) { - log(bot, `${blockType} already at ${targetBlock.position}.`) + log(ctx, `${blockType} already at ${targetBlock.position}.`) return false } const emptyBlocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern'] if (!emptyBlocks.includes(targetBlock.name)) { - log(bot, `${blockType} in the way at ${targetBlock.position}.`) - const removed = await breakBlockAt(bot, x, y, z) - if (!removed) { - log(bot, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) + if (!await clearBlockSpace(ctx, targetBlock, blockType)) { return false } - await new Promise(resolve => setTimeout(resolve, 200)) } + const { buildOffBlock, faceVec } = findPlacementSpot(bot, targetDest, placeOn, emptyBlocks) + if (!buildOffBlock) { + log(ctx, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`) + return false + } + + await moveIntoPosition(ctx, blockType, targetBlock) + return await tryPlaceBlock(ctx, block, buildOffBlock, faceVec, blockType, targetDest) +} + +async function clearBlockSpace( + ctx: SkillContext, + targetBlock: any, + blockType: string, +): Promise { + const removed = await breakBlockAt(ctx, targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, + ) + if (!removed) { + log(ctx, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) + return false + } + await new Promise(resolve => setTimeout(resolve, 200)) + return true +} + +function findPlacementSpot(bot: any, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { const dirMap = { top: new Vec3(0, 1, 0), bottom: new Vec3(0, -1, 0), @@ -265,6 +271,22 @@ export async function placeBlock( west: new Vec3(-1, 0, 0), } + const dirs = getPlacementDirections(placeOn, dirMap) + + for (const d of dirs) { + const block = bot.blockAt(targetDest.plus(d)) + if (!emptyBlocks.includes(block.name)) { + return { + buildOffBlock: block, + faceVec: new Vec3(-d.x, -d.y, -d.z), + } + } + } + + return { buildOffBlock: null, faceVec: null } +} + +function getPlacementDirections(placeOn: BlockFace, dirMap: Record): Vec3[] { const dirs = [] if (placeOn === 'side') { dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) @@ -274,29 +296,13 @@ export async function placeBlock( } else { dirs.push(dirMap.bottom) - log(bot, `Unknown placeOn value "${placeOn}". Defaulting to bottom.`) } dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) + return dirs +} - let buildOffBlock = null - let faceVec = null - - for (const d of dirs) { - const block = bot.blockAt(targetDest.plus(d)) - if (!emptyBlocks.includes(block.name)) { - buildOffBlock = block - faceVec = new Vec3(-d.x, -d.y, -d.z) - break - } - } - - if (!buildOffBlock) { - log(bot, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`) - return false - } - - const pos = bot.entity.position - const posAbove = pos.plus(new Vec3(0, 1, 0)) +async function moveIntoPosition(ctx: SkillContext, blockType: string, targetBlock: any) { + const { bot } = ctx const dontMoveFor = [ 'torch', 'redstone_torch', @@ -312,33 +318,61 @@ export async function placeBlock( 'water_bucket', ] + const pos = bot.entity.position + const posAbove = pos.plus(new Vec3(0, 1, 0)) + if (!dontMoveFor.includes(blockType) && (pos.distanceTo(targetBlock.position) < 1 || posAbove.distanceTo(targetBlock.position) < 1)) { - const goal = bot.pathfinder.goals.GoalNear(targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2) - const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) - bot.pathfinder.setMovements(new bot.pathfinder.Movements(bot)) - await bot.pathfinder.goto(invertedGoal) + await moveAwayFromBlock(bot, targetBlock) } if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { - const pos = targetBlock.position - const movements = new bot.pathfinder.Movements(bot) - bot.pathfinder.setMovements(movements) - await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + await moveToBlock(bot, targetBlock) } +} +async function moveAwayFromBlock(bot: any, targetBlock: any) { + const goal = bot.pathfinder.goals.GoalNear( + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 2, + ) + const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + bot.pathfinder.setMovements(new bot.pathfinder.Movements(bot)) + await bot.pathfinder.goto(invertedGoal) +} + +async function moveToBlock(bot: any, targetBlock: any) { + const pos = targetBlock.position + const movements = new bot.pathfinder.Movements(bot) + bot.pathfinder.setMovements(movements) + await bot.pathfinder.goto( + bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4), + ) +} + +async function tryPlaceBlock( + ctx: SkillContext, + block: any, + buildOffBlock: any, + faceVec: Vec3, + blockType: string, + targetDest: Vec3, +): Promise { + const { bot } = ctx await bot.equip(block, 'hand') await bot.lookAt(buildOffBlock.position) try { await bot.placeBlock(buildOffBlock, faceVec) - log(bot, `Placed ${blockType} at ${targetDest}.`) + log(ctx, `Placed ${blockType} at ${targetDest}.`) await new Promise(resolve => setTimeout(resolve, 200)) return true } catch { - log(bot, `Failed to place ${blockType} at ${targetDest}.`) + log(ctx, `Failed to place ${blockType} at ${targetDest}.`) return false } } @@ -346,41 +380,49 @@ export async function placeBlock( /** * Use a door at the specified position */ -export async function useDoor(bot: Bot, doorPos: Vec3 | null = null): Promise { - if (!doorPos) { - const doorTypes = [ - 'oak_door', - 'spruce_door', - 'birch_door', - 'jungle_door', - 'acacia_door', - 'dark_oak_door', - 'mangrove_door', - 'cherry_door', - 'bamboo_door', - 'crimson_door', - 'warped_door', - ] - - for (const doorType of doorTypes) { - const block = world.getNearestBlock(bot, doorType, 16) - if (block) { - doorPos = block.position - break - } - } - } +export async function useDoor(ctx: SkillContext, doorPos: Vec3 | null = null): Promise { + const { bot } = ctx + doorPos = doorPos || await findNearestDoor(bot) if (!doorPos) { - log(bot, 'Could not find a door to use.') + log(ctx, 'Could not find a door to use.') return false } - await goToPosition(bot, doorPos.x, doorPos.y, doorPos.z, 1) + await goToPosition(ctx, doorPos.x, doorPos.y, doorPos.z, 1) while (bot.pathfinder.isMoving()) { await new Promise(resolve => setTimeout(resolve, 100)) } + return await operateDoor(ctx, doorPos) +} + +async function findNearestDoor(bot: any): Promise { + const doorTypes = [ + 'oak_door', + 'spruce_door', + 'birch_door', + 'jungle_door', + 'acacia_door', + 'dark_oak_door', + 'mangrove_door', + 'cherry_door', + 'bamboo_door', + 'crimson_door', + 'warped_door', + ] + + for (const doorType of doorTypes) { + const block = world.getNearestBlock(bot, doorType, 16) + if (block) { + return block.position + } + } + return null +} + +async function operateDoor(ctx: SkillContext, doorPos: Vec3): Promise { + const { bot } = ctx const doorBlock = bot.blockAt(doorPos) await bot.lookAt(doorPos) @@ -393,7 +435,7 @@ export async function useDoor(bot: Bot, doorPos: Vec3 | null = null): Promise { - x = Math.round(x) - y = Math.round(y) - z = Math.round(z) + const { bot } = ctx + const pos = { x: Math.round(x), y: Math.round(y), z: Math.round(z) } - const block = bot.blockAt(new Vec3(x, y, z)) - if (block.name !== 'grass_block' && block.name !== 'dirt' && block.name !== 'farmland') { - log(bot, `Cannot till ${block.name}, must be grass_block or dirt.`) + const block = bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) + if (!canTillBlock(block)) { + log(ctx, `Cannot till ${block.name}, must be grass_block or dirt.`) return false } - const above = bot.blockAt(new Vec3(x, y + 1, z)) - if (above.name !== 'air') { - log(bot, `Cannot till, there is ${above.name} above the block.`) + const above = bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) + if (!isBlockClear(above)) { + log(ctx, `Cannot till, there is ${above.name} above the block.`) return false } - if (bot.entity.position.distanceTo(block.position) > 4.5) { - await goToPosition(bot, block.position.x, block.position.y, block.position.z, 4) - } + await moveIntoRange(bot, block) - if (block.name !== 'farmland') { - const hoe = bot.inventory.items().find(item => item.name.includes('hoe')) - if (!hoe) { - log(bot, 'Cannot till, no hoes.') - return false - } - await bot.equip(hoe, 'hand') - await bot.activateBlock(block) - log(bot, `Tilled block x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + if (!await tillBlock(ctx, block, pos)) { + return false } if (seedType) { - if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) { - seedType += 's' // Fix common mistake - } - - const seeds = bot.inventory.items().find(item => item.name === seedType) - if (!seeds) { - log(bot, `No ${seedType} to plant.`) - return false - } - - await bot.equip(seeds, 'hand') - await bot.placeBlock(block, new Vec3(0, -1, 0)) - log(bot, `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + return await sowSeeds(ctx, block, seedType, pos) } return true } +function canTillBlock(block: any): boolean { + return block.name === 'grass_block' || block.name === 'dirt' || block.name === 'farmland' +} + +function isBlockClear(block: any): boolean { + return block.name === 'air' +} + +async function tillBlock(ctx: SkillContext, block: any, pos: any): Promise { + const { bot } = ctx + if (block.name === 'farmland') { + return true + } + + const hoe = bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + log(ctx, 'Cannot till, no hoes.') + return false + } + + await bot.equip(hoe, 'hand') + await bot.activateBlock(block) + log(ctx, `Tilled block x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + return true +} + +async function sowSeeds(ctx: SkillContext, block: any, seedType: string, pos: any): Promise { + const { bot } = ctx + seedType = fixSeedName(seedType) + + const seeds = bot.inventory.items().find(item => item.name === seedType) + if (!seeds) { + log(ctx, `No ${seedType} to plant.`) + return false + } + + await bot.equip(seeds, 'hand') + await bot.placeBlock(block, new Vec3(0, -1, 0)) + log(ctx, `Planted ${seedType} at x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + return true +} + +function fixSeedName(seedType: string): string { + if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) { + return `${seedType}s` // Fix common mistake + } + return seedType +} + /** * Activate the nearest block of a specific type */ -export async function activateNearestBlock(bot: Bot, type: string): Promise { +export async function activateNearestBlock(ctx: SkillContext, type: string): Promise { + const { bot } = ctx const block = world.getNearestBlock(bot, type, 16) if (!block) { - log(bot, `Could not find any ${type} to activate.`) + log(ctx, `Could not find any ${type} to activate.`) return false } - if (bot.entity.position.distanceTo(block.position) > 4.5) { - await goToPosition(bot, block.position.x, block.position.y, block.position.z, 4) - } - + await moveIntoRange(bot, block) await bot.activateBlock(block) - log(bot, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) + log(ctx, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) return true } + +export async function collectBlock( + ctx: SkillContext, + blockType: string, + num: number = 1, + exclude: typeof Vec3[] | null = null, +): Promise { + const { bot } = ctx + if (num < 1) { + log(ctx, `Invalid number of blocks to collect: ${num}.`) + return false + } + + const blocktypes = getBlockTypes(blockType) + let collected = 0 + + for (let i = 0; i < num; i++) { + const blocks = getValidBlocks(ctx, blocktypes, exclude) + + if (blocks.length === 0) { + logNoBlocksMessage(ctx, blockType, collected) + break + } + + const block = blocks[0] + if (!await canHarvestBlock(ctx, block, blockType)) { + return false + } + + if (!await tryCollectBlock(ctx, block, blockType)) { + break + } + + collected++ + + if (bot.interrupt_code) { + break + } + } + + log(ctx, `Collected ${collected} ${blockType}.`) + return collected > 0 +} + +function getBlockTypes(blockType: string): string[] { + const blocktypes: string[] = [blockType] + + const ores = ['coal', 'diamond', 'emerald', 'iron', 'gold', 'lapis_lazuli', 'redstone'] + if (ores.includes(blockType)) { + blocktypes.push(`${blockType}_ore`) + } + if (blockType.endsWith('ore')) { + blocktypes.push(`deepslate_${blockType}`) + } + if (blockType === 'dirt') { + blocktypes.push('grass_block') + } + + return blocktypes +} + +function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: typeof Vec3[] | null): any[] { + const { bot } = ctx + let blocks = world.getNearestBlocks(bot, blocktypes, 64) + + if (exclude) { + blocks = blocks.filter( + block => !exclude.some(pos => + pos.x === block.position.x + && pos.y === block.position.y + && pos.z === block.position.z, + ), + ) + } + + const movements = new bot.pathfinder.Movements(bot) + movements.dontMineUnderFallingBlock = false + return blocks.filter(block => movements.safeToBreak(block)) +} + +function logNoBlocksMessage(ctx: SkillContext, blockType: string, collected: number): void { + log(ctx, collected === 0 + ? `No ${blockType} nearby to collect.` + : `No more ${blockType} nearby to collect.`) +} + +async function canHarvestBlock(ctx: SkillContext, block: any, blockType: string): Promise { + const { bot } = ctx + await bot.tool.equipForBlock(block) + const itemId = bot.heldItem ? bot.heldItem.type : null + + if (!block.canHarvest(itemId)) { + log(ctx, `Don't have right tools to harvest ${blockType}.`) + return false + } + return true +} + +async function tryCollectBlock(ctx: SkillContext, block: any, blockType: string): Promise { + const { bot } = ctx + try { + await bot.collectBlock.collect(block) + await autoLight(ctx) + return true + } + catch (err) { + if (err instanceof Error && err.name === 'NoChests') { + log(ctx, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) + return false + } + log(ctx, `Failed to collect ${blockType}: ${err}.`) + return true + } +} diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index a6fdc989e..9946efd78 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -1,5 +1,5 @@ -import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' +import type { SkillContext } from './base' import * as world from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' @@ -7,7 +7,8 @@ import { log } from './base' /** * Equip the item with highest attack damage */ -async function equipHighestAttack(bot: Bot): Promise { +async function equipHighestAttack(ctx: SkillContext): Promise { + const { bot } = ctx const weapons = bot.inventory.items().filter(item => item.name.includes('sword') || (item.name.includes('axe') && !item.name.includes('pickaxe')), @@ -37,61 +38,65 @@ async function equipHighestAttack(bot: Bot): Promise { /** * Attack the nearest mob of the given type */ -export async function attackNearest(bot: Bot, mobType: string, kill = true): Promise { - bot.modes.pause('cowardice') - if (mobType === 'drowned' || mobType === 'cod' || mobType === 'salmon' - || mobType === 'tropical_fish' || mobType === 'squid') { - bot.modes.pause('self_preservation') +export async function attackNearest( + ctx: SkillContext, + mobType: string, + kill = true, +): Promise { + const { bot } = ctx + const mob = world.getNearbyEntities(bot, 24).find(entity => entity.name === mobType) + + if (mob) { + return await attackEntity(ctx, mob, kill) } - const mob = world.getNearbyEntities(bot, 24).find(entity => entity.name === mobType) - if (mob) { - return await attackEntity(bot, mob, kill) - } - log(bot, `Could not find any ${mobType} to attack.`) + log(ctx, `Could not find any ${mobType} to attack.`) return false } /** * Attack a specific entity */ -export async function attackEntity(bot: Bot, entity: Entity, kill = true): Promise { +export async function attackEntity( + ctx: SkillContext, + entity: Entity, + kill = true, +): Promise { + const { bot } = ctx const pos = entity.position - await equipHighestAttack(bot) + await equipHighestAttack(ctx) if (!kill) { if (bot.entity.position.distanceTo(pos) > 5) { await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) } await bot.attack(entity) - } - else { - bot.pvp.attack(entity) - while (world.getNearbyEntities(bot, 24).includes(entity)) { - await new Promise(resolve => setTimeout(resolve, 1000)) - if (bot.interrupt_code) { - bot.pvp.stop() - return false - } - } - log(bot, `Successfully killed ${entity.name}.`) return true } + + bot.pvp.attack(entity) + while (world.getNearbyEntities(bot, 24).includes(entity)) { + await new Promise(resolve => setTimeout(resolve, 1000)) + if (ctx.shouldInterrupt) { + bot.pvp.stop() + return false + } + } + + log(ctx, `Successfully killed ${entity.name}.`) return true } /** * Defend against nearby hostile mobs */ -export async function defendSelf(bot: Bot, range = 9): Promise { - bot.modes.pause('self_defense') - bot.modes.pause('cowardice') - +export async function defendSelf(ctx: SkillContext, range = 9): Promise { + const { bot } = ctx let attacked = false let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) while (enemy) { - await equipHighestAttack(bot) + await equipHighestAttack(ctx) if (bot.entity.position.distanceTo(enemy.position) >= 4 && enemy.name !== 'creeper' && enemy.name !== 'phantom') { @@ -103,10 +108,10 @@ export async function defendSelf(bot: Bot, range = 9): Promise { if (bot.entity.position.distanceTo(enemy.position) <= 2) { try { - const inverted_goal = bot.pathfinder.goals.GoalInvert( + const invertedGoal = bot.pathfinder.goals.GoalInvert( bot.pathfinder.goals.GoalFollow(enemy, 2), ) - await bot.pathfinder.goto(inverted_goal, true) + await bot.pathfinder.goto(invertedGoal, true) } catch { /* might error if entity dies, ignore */ } } @@ -116,7 +121,7 @@ export async function defendSelf(bot: Bot, range = 9): Promise { await new Promise(resolve => setTimeout(resolve, 500)) enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) - if (bot.interrupt_code) { + if (ctx.shouldInterrupt) { bot.pvp.stop() return false } @@ -124,10 +129,10 @@ export async function defendSelf(bot: Bot, range = 9): Promise { bot.pvp.stop() if (attacked) { - log(bot, `Successfully defended self.`) + log(ctx, 'Successfully defended self.') } else { - log(bot, `No enemies nearby to defend self from.`) + log(ctx, 'No enemies nearby to defend self from.') } return attacked } diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index b364117e9..2099555b4 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -1,90 +1,102 @@ -import type { Bot } from 'mineflayer' +import type { SkillContext } from './base' import * as world from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' -import { collectBlock } from './blocks' +import { placeBlock } from './blocks' import { goToPosition } from './movement' /** * Craft items from a recipe */ -export async function craftRecipe(bot: Bot, itemName: string, num = 1): Promise { +export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): Promise { + const { bot } = ctx let placedTable = false if (mc.getItemCraftingRecipes(itemName).length === 0) { - log(bot, `${itemName} is either not an item, or it does not have a crafting recipe!`) + log(ctx, `${itemName} is either not an item, or it does not have a crafting recipe!`) return false } // Get recipes that don't require a crafting table - let recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, null) + const itemId = mc.getItemId(itemName) + if (itemId === null) { + log(ctx, `Invalid item name: ${itemName}`) + return false + } + + let recipes = bot.recipesFor(itemId, null, 1, null) let craftingTable = null const craftingTableRange = 32 if (!recipes || recipes.length === 0) { - recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, true) + recipes = bot.recipesFor(itemId, null, 1, true) if (!recipes || recipes.length === 0) { - log(bot, `You do not have the resources to craft a ${itemName}.`) + log(ctx, `You do not have the resources to craft a ${itemName}.`) return false } // Look for crafting table - craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange) + const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) if (!craftingTable) { // Try to place crafting table - const hasTable = world.getInventoryCounts(bot).crafting_table > 0 + const inventory = world.getInventoryCounts(worldCtx) + const hasTable = inventory.crafting_table > 0 if (hasTable) { - const pos = world.getNearestFreeSpace(bot, 1, 6) - await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z) - craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange) - if (craftingTable) { - recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable) - placedTable = true + const pos = world.getNearestFreeSpace(worldCtx, 1, 6) + if (pos) { + await placeBlock(ctx, 'crafting_table', pos.x, pos.y, pos.z) + craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) + if (craftingTable) { + recipes = bot.recipesFor(itemId, null, 1, craftingTable) + placedTable = true + } } } else { - log(bot, `Crafting ${itemName} requires a crafting table.`) + log(ctx, `Crafting ${itemName} requires a crafting table.`) return false } } else { - recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable) + recipes = bot.recipesFor(itemId, null, 1, craftingTable) } } if (!recipes || recipes.length === 0) { - log(bot, `You do not have the resources to craft a ${itemName}. It requires: ${ + log(ctx, `You do not have the resources to craft a ${itemName}. It requires: ${ Object.entries(mc.getItemCraftingRecipes(itemName)[0]) .map(([key, value]) => `${key}: ${value}`) .join(', ') }.`) - if (placedTable) { - await collectBlock(bot, 'crafting_table', 1) + if (placedTable && craftingTable) { + await bot.collectBlock.collect(craftingTable) } return false } if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { - await goToPosition(bot, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) + await goToPosition(ctx, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) } const recipe = recipes[0] // Check that the agent has sufficient items to use the recipe `num` times - const inventory = world.getInventoryCounts(bot) // Items in the agents inventory + const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const inventory = world.getInventoryCounts(worldCtx) // Items in the agents inventory const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe) // Items required to use the recipe once const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients) await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable) if (craftLimit.num < num) { - log(bot, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + log(ctx, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(worldCtx)[itemName]} ${itemName}.`) } else { - log(bot, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(bot)[itemName]} ${itemName}.`) + log(ctx, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(worldCtx)[itemName]} ${itemName}.`) } - if (placedTable) { - await collectBlock(bot, 'crafting_table', 1) + if (placedTable && craftingTable) { + await bot.collectBlock.collect(craftingTable) } // Equip any armor the bot may have crafted @@ -96,57 +108,67 @@ export async function craftRecipe(bot: Bot, itemName: string, num = 1): Promise< /** * Smelt items in a furnace */ -export async function smeltItem(bot: Bot, itemName: string, num = 1): Promise { +export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): Promise { + const { bot } = ctx if (!mc.isSmeltable(itemName)) { - log(bot, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) + log(ctx, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) return false } let placedFurnace = false const furnaceRange = 32 - let furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange) + const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + let furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', furnaceRange) if (!furnaceBlock) { // Try to place furnace - const hasFurnace = world.getInventoryCounts(bot).furnace > 0 + const inventory = world.getInventoryCounts(worldCtx) + const hasFurnace = inventory.furnace > 0 if (hasFurnace) { - const pos = world.getNearestFreeSpace(bot, 1, furnaceRange) - await placeBlock(bot, 'furnace', pos.x, pos.y, pos.z) - furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange) - placedFurnace = true + const pos = world.getNearestFreeSpace(worldCtx, 1, furnaceRange) + if (pos) { + await placeBlock(ctx, 'furnace', pos.x, pos.y, pos.z) + furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', furnaceRange) + placedFurnace = true + } } } if (!furnaceBlock) { - log(bot, 'There is no furnace nearby and you have no furnace.') + log(ctx, 'There is no furnace nearby and you have no furnace.') return false } if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } - bot.modes.pause('unstuck') await bot.lookAt(furnaceBlock.position) const furnace = await bot.openFurnace(furnaceBlock) // Check if the furnace is already smelting something const inputItem = furnace.inputItem() - if (inputItem && inputItem.type !== mc.getItemId(itemName) && inputItem.count > 0) { - log(bot, `The furnace is currently smelting ${mc.getItemName(inputItem.type)}.`) + const itemId = mc.getItemId(itemName) + if (itemId === null) { + log(ctx, `Invalid item name: ${itemName}`) + return false + } + + if (inputItem && inputItem.type !== itemId && inputItem.count > 0) { + log(ctx, `The furnace is currently smelting ${mc.getItemName(inputItem.type) ?? 'unknown'}.`) if (placedFurnace) { - await collectBlock(bot, 'furnace', 1) + await bot.collectBlock.collect(furnaceBlock) } return false } // Check if the bot has enough items to smelt - const invCounts = world.getInventoryCounts(bot) + const invCounts = world.getInventoryCounts(worldCtx) if (!invCounts[itemName] || invCounts[itemName] < num) { - log(bot, `You do not have enough ${itemName} to smelt.`) + log(ctx, `You do not have enough ${itemName} to smelt.`) if (placedFurnace) { - await collectBlock(bot, 'furnace', 1) + await bot.collectBlock.collect(furnaceBlock) } return false } @@ -155,30 +177,30 @@ export async function smeltItem(bot: Bot, itemName: string, num = 1): Promise setTimeout(resolve, 10000)) let collected = false - if (furnace.outputItem()) { + const outputItem = furnace.outputItem() + if (outputItem) { smeltedItem = await furnace.takeOutput() if (smeltedItem) { total += smeltedItem.count @@ -203,7 +226,7 @@ export async function smeltItem(bot: Bot, itemName: string, num = 1): Promise { - const furnaceBlock = world.getNearestBlock(bot, 'furnace', 32) +export async function clearNearestFurnace(ctx: SkillContext): Promise { + const { bot } = ctx + const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', 32) if (!furnaceBlock) { - log(bot, 'No furnace nearby to clear.') + log(ctx, 'No furnace nearby to clear.') return false } if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(bot, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } const furnace = await bot.openFurnace(furnaceBlock) @@ -247,15 +272,18 @@ export async function clearNearestFurnace(bot: Bot): Promise { // Take the items out of the furnace let smeltedItem, inputItem, fuelItem - if (furnace.outputItem()) { + const outputItem = furnace.outputItem() + if (outputItem) { smeltedItem = await furnace.takeOutput() } - if (furnace.inputItem()) { + const furnaceInput = furnace.inputItem() + if (furnaceInput) { inputItem = await furnace.takeInput() } - if (furnace.fuelItem()) { + const furnaceFuel = furnace.fuelItem() + if (furnaceFuel) { fuelItem = await furnace.takeFuel() } @@ -263,6 +291,6 @@ export async function clearNearestFurnace(bot: Bot): Promise { const inputName = inputItem ? `${inputItem.count} ${inputItem.name}` : '0 input items' const fuelName = fuelItem ? `${fuelItem.count} ${fuelItem.name}` : '0 fuel items' - log(bot, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) + log(ctx, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) return true } diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index aef535afe..d82ca2254 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,4 +1,5 @@ import type { Bot } from 'mineflayer' +import type { SkillContext } from './base' import * as world from '../composables/world' import { log } from './base' import { goToPosition } from './movement' @@ -6,7 +7,8 @@ import { goToPosition } from './movement' /** * Pick up nearby items */ -export async function pickupNearbyItems(bot: Bot): Promise { +export async function pickupNearbyItems(ctx: SkillContext): Promise { + const { bot } = ctx const distance = 8 const getNearestItem = (bot: Bot) => bot.nearestEntity(entity => @@ -29,17 +31,18 @@ export async function pickupNearbyItems(bot: Bot): Promise { pickedUp++ } - log(bot, `Picked up ${pickedUp} items.`) + log(ctx, `Picked up ${pickedUp} items.`) return true } /** * Equip an item */ -export async function equip(bot: Bot, itemName: string): Promise { +export async function equip(ctx: SkillContext, itemName: string): Promise { + const { bot } = ctx const item = bot.inventory.slots.find(slot => slot && slot.name === itemName) if (!item) { - log(bot, `You do not have any ${itemName} to equip.`) + log(ctx, `You do not have any ${itemName} to equip.`) return false } @@ -62,14 +65,15 @@ export async function equip(bot: Bot, itemName: string): Promise { await bot.equip(item, 'hand') } - log(bot, `Equipped ${itemName}.`) + log(ctx, `Equipped ${itemName}.`) return true } /** * Discard items */ -export async function discard(bot: Bot, itemName: string, num = -1): Promise { +export async function discard(ctx: SkillContext, itemName: string, num = -1): Promise { + const { bot } = ctx let discarded = 0 while (true) { @@ -88,57 +92,59 @@ export async function discard(bot: Bot, itemName: string, num = -1): Promise { +export async function putInChest(ctx: SkillContext, itemName: string, num = -1): Promise { + const { bot } = ctx const chest = world.getNearestBlock(bot, 'chest', 32) if (!chest) { - log(bot, 'Could not find a chest nearby.') + log(ctx, 'Could not find a chest nearby.') return false } const item = bot.inventory.items().find(item => item.name === itemName) if (!item) { - log(bot, `You do not have any ${itemName} to put in the chest.`) + log(ctx, `You do not have any ${itemName} to put in the chest.`) return false } const toPut = num === -1 ? item.count : Math.min(num, item.count) - await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) const chestContainer = await bot.openContainer(chest) await chestContainer.deposit(item.type, null, toPut) await chestContainer.close() - log(bot, `Successfully put ${toPut} ${itemName} in the chest.`) + log(ctx, `Successfully put ${toPut} ${itemName} in the chest.`) return true } /** * Take items from a chest */ -export async function takeFromChest(bot: Bot, itemName: string, num = -1): Promise { +export async function takeFromChest(ctx: SkillContext, itemName: string, num = -1): Promise { + const { bot } = ctx const chest = world.getNearestBlock(bot, 'chest', 32) if (!chest) { - log(bot, 'Could not find a chest nearby.') + log(ctx, 'Could not find a chest nearby.') return false } - await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) const chestContainer = await bot.openContainer(chest) const item = chestContainer.containerItems().find(item => item.name === itemName) if (!item) { - log(bot, `Could not find any ${itemName} in the chest.`) + log(ctx, `Could not find any ${itemName} in the chest.`) await chestContainer.close() return false } @@ -147,31 +153,32 @@ export async function takeFromChest(bot: Bot, itemName: string, num = -1): Promi await chestContainer.withdraw(item.type, null, toTake) await chestContainer.close() - log(bot, `Successfully took ${toTake} ${itemName} from the chest.`) + log(ctx, `Successfully took ${toTake} ${itemName} from the chest.`) return true } /** * View contents of a chest */ -export async function viewChest(bot: Bot): Promise { +export async function viewChest(ctx: SkillContext): Promise { + const { bot } = ctx const chest = world.getNearestBlock(bot, 'chest', 32) if (!chest) { - log(bot, 'Could not find a chest nearby.') + log(ctx, 'Could not find a chest nearby.') return false } - await goToPosition(bot, chest.position.x, chest.position.y, chest.position.z, 2) + await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) const chestContainer = await bot.openContainer(chest) const items = chestContainer.containerItems() if (items.length === 0) { - log(bot, 'The chest is empty.') + log(ctx, 'The chest is empty.') } else { - log(bot, 'The chest contains:') + log(ctx, 'The chest contains:') for (const item of items) { - log(bot, `${item.count} ${item.name}`) + log(ctx, `${item.count} ${item.name}`) } } @@ -182,7 +189,8 @@ export async function viewChest(bot: Bot): Promise { /** * Consume (eat/drink) an item */ -export async function consume(bot: Bot, itemName = ''): Promise { +export async function consume(ctx: SkillContext, itemName = ''): Promise { + const { bot } = ctx let item let name @@ -192,13 +200,13 @@ export async function consume(bot: Bot, itemName = ''): Promise { } if (!item) { - log(bot, `You do not have any ${name} to eat.`) + log(ctx, `You do not have any ${name} to eat.`) return false } await bot.equip(item, 'hand') await bot.consume() - log(bot, `Consumed ${item.name}.`) + log(ctx, `Consumed ${item.name}.`) return true } @@ -206,21 +214,22 @@ export async function consume(bot: Bot, itemName = ''): Promise { * Give items to a player */ export async function giveToPlayer( - bot: Bot, + ctx: SkillContext, itemType: string, username: string, num = 1, ): Promise { + const { bot } = ctx const player = bot.players[username]?.entity if (!player) { - log(bot, `Could not find ${username}.`) + log(ctx, `Could not find ${username}.`) return false } - await goToPosition(bot, player.position.x, player.position.y, player.position.z, 3) + await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 3) if (bot.entity.position.y < player.position.y - 1) { - await goToPosition(bot, player.position.x, player.position.y, player.position.z, 1) + await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 1) } if (bot.entity.position.distanceTo(player.position) < 2) { @@ -231,17 +240,17 @@ export async function giveToPlayer( await bot.lookAt(player.position) - if (await discard(bot, itemType, num)) { + if (await discard(ctx, itemType, num)) { let given = false bot.once('playerCollect', (collector, collected) => { if (collector.username === username) { - log(bot, `${username} received ${itemType}.`) + log(ctx, `${username} received ${itemType}.`) given = true } }) const start = Date.now() - while (!given && !bot.interrupt_code) { + while (!given && !ctx.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) if (given) { return true @@ -252,6 +261,6 @@ export async function giveToPlayer( } } - log(bot, `Failed to give ${itemType} to ${username}, it was never received.`) + log(ctx, `Failed to give ${itemType} to ${username}, it was never received.`) return false } diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 0e809cc26..88a5846c5 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -1,5 +1,5 @@ -import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' +import type { SkillContext } from './base' import * as world from '../composables/world' import { log } from './base' @@ -7,25 +7,26 @@ import { log } from './base' * Navigate to a specific position */ export async function goToPosition( - bot: Bot, + ctx: SkillContext, x: number, y: number, z: number, minDistance = 2, ): Promise { + const { bot } = ctx if (x == null || y == null || z == null) { - log(bot, `Missing coordinates, given x:${x} y:${y} z:${z}`) + log(ctx, `Missing coordinates, given x:${x} y:${y} z:${z}`) return false } - if (bot.modes.isOn('cheat')) { + if (ctx.allowCheats) { bot.chat(`/tp @s ${x} ${y} ${z}`) - log(bot, `Teleported to ${x}, ${y}, ${z}.`) + log(ctx, `Teleported to ${x}, ${y}, ${z}.`) return true } await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(x, y, z, minDistance)) - log(bot, `You have reached ${x}, ${y}, ${z}.`) + log(ctx, `You have reached ${x}, ${y}, ${z}.`) return true } @@ -33,25 +34,25 @@ export async function goToPosition( * Navigate to the nearest block of a specific type */ export async function goToNearestBlock( - bot: Bot, + ctx: SkillContext, blockType: string, minDistance = 2, range = 64, ): Promise { const MAX_RANGE = 512 if (range > MAX_RANGE) { - log(bot, `Maximum search range capped at ${MAX_RANGE}.`) + log(ctx, `Maximum search range capped at ${MAX_RANGE}.`) range = MAX_RANGE } - const block = world.getNearestBlock(bot, blockType, range) + const block = world.getNearestBlock(ctx.bot, blockType, range) if (!block) { - log(bot, `Could not find any ${blockType} in ${range} blocks.`) + log(ctx, `Could not find any ${blockType} in ${range} blocks.`) return false } - log(bot, `Found ${blockType} at ${block.position}.`) - await goToPosition(bot, block.position.x, block.position.y, block.position.z, minDistance) + log(ctx, `Found ${blockType} at ${block.position}.`) + await goToPosition(ctx, block.position.x, block.position.y, block.position.z, minDistance) return true } @@ -59,11 +60,12 @@ export async function goToNearestBlock( * Navigate to the nearest entity of a specific type */ export async function goToNearestEntity( - bot: Bot, + ctx: SkillContext, entityType: string, minDistance = 2, range = 64, ): Promise { + const { bot } = ctx const entity = world.getNearestEntityWhere( bot, entity => entity.name === entityType, @@ -71,14 +73,14 @@ export async function goToNearestEntity( ) if (!entity) { - log(bot, `Could not find any ${entityType} in ${range} blocks.`) + log(ctx, `Could not find any ${entityType} in ${range} blocks.`) return false } const distance = bot.entity.position.distanceTo(entity.position) - log(bot, `Found ${entityType} ${distance} blocks away.`) + log(ctx, `Found ${entityType} ${distance} blocks away.`) await goToPosition( - bot, + ctx, entity.position.x, entity.position.y, entity.position.z, @@ -90,55 +92,52 @@ export async function goToNearestEntity( /** * Navigate to a specific player */ -export async function goToPlayer(bot: Bot, username: string, distance = 3): Promise { - if (bot.modes.isOn('cheat')) { +export async function goToPlayer( + ctx: SkillContext, + username: string, + distance = 3, +): Promise { + const { bot } = ctx + if (ctx.allowCheats) { bot.chat(`/tp @s ${username}`) - log(bot, `Teleported to ${username}.`) + log(ctx, `Teleported to ${username}.`) return true } - bot.modes.pause('self_defense') - bot.modes.pause('cowardice') - const player = bot.players[username]?.entity if (!player) { - log(bot, `Could not find ${username}.`) + log(ctx, `Could not find ${username}.`) return false } await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(player, distance), true) - log(bot, `You have reached ${username}.`) + log(ctx, `You have reached ${username}.`) return true } /** * Follow a player continuously */ -export async function followPlayer(bot: Bot, username: string, distance = 4): Promise { +export async function followPlayer( + ctx: SkillContext, + username: string, + distance = 4, +): Promise { + const { bot } = ctx const player = bot.players[username]?.entity if (!player) return false bot.pathfinder.setGoal(bot.pathfinder.goals.GoalFollow(player, distance), true) - log(bot, `You are now actively following player ${username}.`) + log(ctx, `You are now actively following player ${username}.`) - while (!bot.interrupt_code) { + while (!ctx.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) - if (bot.modes.isOn('cheat') + if (ctx.allowCheats && bot.entity.position.distanceTo(player.position) > 100 && player.isOnGround) { - await goToPlayer(bot, username) - } - - if (bot.modes.isOn('unstuck')) { - const isNearby = bot.entity.position.distanceTo(player.position) <= distance + 1 - if (isNearby) { - bot.modes.pause('unstuck') - } - else { - bot.modes.unpause('unstuck') - } + await goToPlayer(ctx, username) } } return true @@ -147,12 +146,13 @@ export async function followPlayer(bot: Bot, username: string, distance = 4): Pr /** * Move away from current position */ -export async function moveAway(bot: Bot, distance: number): Promise { +export async function moveAway(ctx: SkillContext, distance: number): Promise { + const { bot } = ctx const pos = bot.entity.position const goal = bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, distance) const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) - if (bot.modes.isOn('cheat')) { + if (ctx.allowCheats) { const move = new bot.pathfinder.Movements(bot) const path = await bot.pathfinder.getPathTo(move, invertedGoal, 10000) const lastMove = path.path[path.path.length - 1] @@ -168,7 +168,7 @@ export async function moveAway(bot: Bot, distance: number): Promise { await bot.pathfinder.goto(invertedGoal) const newPos = bot.entity.position - log(bot, `Moved away from nearest entity to ${newPos}.`) + log(ctx, `Moved away from nearest entity to ${newPos}.`) return true } @@ -176,10 +176,11 @@ export async function moveAway(bot: Bot, distance: number): Promise { * Move away from a specific entity */ export async function moveAwayFromEntity( - bot: Bot, + ctx: SkillContext, entity: Entity, distance = 16, ): Promise { + const { bot } = ctx const goal = bot.pathfinder.goals.GoalFollow(entity, distance) const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) await bot.pathfinder.goto(invertedGoal) @@ -189,51 +190,12 @@ export async function moveAwayFromEntity( /** * Stay in current position */ -export async function stay(bot: Bot, seconds = 30): Promise { - bot.modes.pause('self_preservation') - bot.modes.pause('unstuck') - bot.modes.pause('cowardice') - bot.modes.pause('self_defense') - bot.modes.pause('hunting') - bot.modes.pause('torch_placing') - bot.modes.pause('item_collecting') - +export async function stay(ctx: SkillContext, seconds = 30): Promise { const start = Date.now() - while (!bot.interrupt_code && (seconds === -1 || Date.now() - start < seconds * 1000)) { + while (!ctx.shouldInterrupt && (seconds === -1 || Date.now() - start < seconds * 1000)) { await new Promise(resolve => setTimeout(resolve, 500)) } - log(bot, `Stayed for ${(Date.now() - start) / 1000} seconds.`) - return true -} -/** - * Sleep in the nearest bed within 32 blocks - */ -export async function goToBed(bot: Bot): Promise { - const beds: Vec3[] = bot.findBlocks({ - matching: (block: Block) => block.name.includes('bed'), - maxDistance: 32, - count: 1, - }) - - if (beds.length === 0) { - log(bot, 'Could not find a bed to sleep in.') - return false - } - - const loc: Vec3 = beds[0] - await goToPosition(bot, loc.x, loc.y, loc.z) - - const bed: Block | null = bot.blockAt(loc) - await bot.sleep(bed) - log(bot, 'You are in bed.') - - bot.modes.pause('unstuck') - - while (bot.isSleeping) { - await new Promise(resolve => setTimeout(resolve, 500)) - } - - log(bot, 'You have woken up.') + log(ctx, `Stayed for ${(Date.now() - start) / 1000} seconds.`) return true } From ab34077d4e964c616e3b8bbec07b07441e72631d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 02:37:13 +0800 Subject: [PATCH 23/77] fix: action ctx --- services/minecraft/src/agents/actions.ts | 101 ++++++++++++----------- services/minecraft/src/agents/openai.ts | 3 +- services/minecraft/src/skills/base.ts | 11 +-- 3 files changed, 59 insertions(+), 56 deletions(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 42317c2dd..d20838001 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -1,4 +1,5 @@ import type { BotContext } from 'src/composables/bot' +import type { SkillContext } from '../skills' import { z } from 'zod' import * as skills from '../skills' @@ -8,7 +9,7 @@ interface Action { readonly name: string readonly description: string readonly schema: z.ZodObject - readonly perform: (ctx: BotContext) => (...args: any[]) => ActionResult + readonly perform: (ctx: SkillContext) => (...args: any[]) => ActionResult } export const actionsList: Action[] = [ @@ -88,8 +89,8 @@ export const actionsList: Action[] = [ player_name: z.string().describe('The name of the player to go to.'), closeness: z.number().describe('How close to get to the player.').min(0), }), - perform: (ctx: BotContext) => async (player_name: string, closeness: number) => { - await skills.goToPlayer(ctx.bot, player_name, closeness) + perform: (ctx: SkillContext) => async (player_name: string, closeness: number) => { + await skills.goToPlayer(ctx, player_name, closeness) return 'Moving to player...' }, }, @@ -101,8 +102,8 @@ export const actionsList: Action[] = [ player_name: z.string().describe('name of the player to follow.'), follow_dist: z.number().describe('The distance to follow from.').min(0), }), - perform: (ctx: BotContext) => async (player_name: string, follow_dist: number) => { - await skills.followPlayer(ctx.bot, player_name, follow_dist) + perform: (ctx: SkillContext) => async (player_name: string, follow_dist: number) => { + await skills.followPlayer(ctx, player_name, follow_dist) return 'Following player...' }, }, @@ -116,8 +117,8 @@ export const actionsList: Action[] = [ z: z.number().describe('The z coordinate.'), closeness: z.number().describe('How close to get to the location.').min(0), }), - perform: (ctx: BotContext) => async (x: number, y: number, z: number, closeness: number) => { - await skills.goToPosition(ctx.bot, x, y, z, closeness) + perform: (ctx: SkillContext) => async (x: number, y: number, z: number, closeness: number) => { + await skills.goToPosition(ctx, x, y, z, closeness) return 'Moving to coordinates...' }, }, @@ -129,8 +130,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The block type to go to.'), search_range: z.number().describe('The range to search for the block.').min(32).max(512), }), - perform: (ctx: BotContext) => async (block_type: string, range: number) => { - await skills.goToNearestBlock(ctx.bot, block_type, 4, range) + perform: (ctx: SkillContext) => async (block_type: string, range: number) => { + await skills.goToNearestBlock(ctx, block_type, 4, range) return 'Searching for block...' }, }, @@ -142,8 +143,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The type of entity to go to.'), search_range: z.number().describe('The range to search for the entity.').min(32).max(512), }), - perform: (ctx: BotContext) => async (entity_type: string, range: number) => { - await skills.goToNearestEntity(ctx.bot, entity_type, 4, range) + perform: (ctx: SkillContext) => async (entity_type: string, range: number) => { + await skills.goToNearestEntity(ctx, entity_type, 4, range) return 'Searching for entity...' }, }, @@ -154,8 +155,8 @@ export const actionsList: Action[] = [ schema: z.object({ distance: z.number().describe('The distance to move away.').min(0), }), - perform: (ctx: BotContext) => async (distance: number) => { - await skills.moveAway(ctx.bot, distance) + perform: (ctx: SkillContext) => async (distance: number) => { + await skills.moveAway(ctx, distance) return 'Moving away...' }, }, @@ -168,8 +169,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to give.'), num: z.number().int().describe('The number of items to give.').min(1), }), - perform: (ctx: BotContext) => async (player_name: string, item_name: string, num: number) => { - await skills.giveToPlayer(ctx.bot, item_name, player_name, num) + perform: (ctx: SkillContext) => async (player_name: string, item_name: string, num: number) => { + await skills.giveToPlayer(ctx, item_name, player_name, num) return 'Giving items to player...' }, }, @@ -180,8 +181,8 @@ export const actionsList: Action[] = [ schema: z.object({ item_name: z.string().describe('The name of the item to consume.'), }), - perform: (ctx: BotContext) => async (item_name: string) => { - await skills.consume(ctx.bot, item_name) + perform: (ctx: SkillContext) => async (item_name: string) => { + await skills.consume(ctx, item_name) return 'Consuming item...' }, }, @@ -192,8 +193,8 @@ export const actionsList: Action[] = [ schema: z.object({ item_name: z.string().describe('The name of the item to equip.'), }), - perform: (ctx: BotContext) => async (item_name: string) => { - await skills.equip(ctx.bot, item_name) + perform: (ctx: SkillContext) => async (item_name: string) => { + await skills.equip(ctx, item_name) return 'Equipping item...' }, }, @@ -205,8 +206,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to put in the chest.'), num: z.number().int().describe('The number of items to put in the chest.').min(1), }), - perform: (ctx: BotContext) => async (item_name: string, num: number) => { - await skills.putInChest(ctx.bot, item_name, num) + perform: (ctx: SkillContext) => async (item_name: string, num: number) => { + await skills.putInChest(ctx, item_name, num) return 'Putting items in chest...' }, }, @@ -218,8 +219,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to take.'), num: z.number().int().describe('The number of items to take.').min(1), }), - perform: (ctx: BotContext) => async (item_name: string, num: number) => { - await skills.takeFromChest(ctx.bot, item_name, num) + perform: (ctx: SkillContext) => async (item_name: string, num: number) => { + await skills.takeFromChest(ctx, item_name, num) return 'Taking items from chest...' }, }, @@ -228,8 +229,8 @@ export const actionsList: Action[] = [ name: 'viewChest', description: 'View the items/counts of the nearest chest.', schema: z.object({}), - perform: (ctx: BotContext) => async () => { - await skills.viewChest(ctx.bot) + perform: (ctx: SkillContext) => async () => { + await skills.viewChest(ctx) return 'Viewing chest contents...' }, }, @@ -241,11 +242,11 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to discard.'), num: z.number().int().describe('The number of items to discard.').min(1), }), - perform: (ctx: BotContext) => async (item_name: string, num: number) => { + perform: (ctx: SkillContext) => async (item_name: string, num: number) => { const start_loc = ctx.bot.entity.position - await skills.moveAway(ctx.bot, 5) - await skills.discard(ctx.bot, item_name, num) - await skills.goToPosition(ctx.bot, start_loc.x, start_loc.y, start_loc.z, 0) + await skills.moveAway(ctx, 5) + await skills.discard(ctx, item_name, num) + await skills.goToPosition(ctx, start_loc.x, start_loc.y, start_loc.z, 0) return 'Discarding items...' }, }, @@ -257,8 +258,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The block type to collect.'), num: z.number().int().describe('The number of blocks to collect.').min(1), }), - perform: (ctx: BotContext) => async (type: string, num: number) => { - await skills.collectBlock(ctx.bot, type, num) + perform: (ctx: SkillContext) => async (type: string, num: number) => { + await skills.collectBlock(ctx, type, num) return 'Collecting blocks...' }, }, @@ -270,8 +271,8 @@ export const actionsList: Action[] = [ recipe_name: z.string().describe('The name of the output item to craft.'), num: z.number().int().describe('The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.').min(1), }), - perform: (ctx: BotContext) => async (recipe_name: string, num: number) => { - await skills.craftRecipe(ctx.bot, recipe_name, num) + perform: (ctx: SkillContext) => async (recipe_name: string, num: number) => { + await skills.craftRecipe(ctx, recipe_name, num) return 'Crafting items...' }, }, @@ -283,8 +284,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the input item to smelt.'), num: z.number().int().describe('The number of times to smelt the item.').min(1), }), - perform: (ctx: BotContext) => async (item_name: string, num: number) => { - await skills.smeltItem(ctx.bot, item_name, num) + perform: (ctx: SkillContext) => async (item_name: string, num: number) => { + await skills.smeltItem(ctx, item_name, num) return 'Smelting items...' }, }, @@ -293,8 +294,8 @@ export const actionsList: Action[] = [ name: 'clearFurnace', description: 'Take all items out of the nearest furnace.', schema: z.object({}), - perform: (ctx: BotContext) => async () => { - await skills.clearNearestFurnace(ctx.bot) + perform: (ctx: SkillContext) => async () => { + await skills.clearNearestFurnace(ctx) return 'Clearing furnace...' }, }, @@ -305,9 +306,9 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The block type to place.'), }), - perform: (ctx: BotContext) => async (type: string) => { + perform: (ctx: SkillContext) => async (type: string) => { const pos = ctx.bot.entity.position - await skills.placeBlock(ctx.bot, type, pos.x, pos.y, pos.z) + await skills.placeBlock(ctx, type, pos.x, pos.y, pos.z) return 'Placing block...' }, }, @@ -318,8 +319,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The type of entity to attack.'), }), - perform: (ctx: BotContext) => async (type: string) => { - await skills.attackNearest(ctx.bot, type, true) + perform: (ctx: SkillContext) => async (type: string) => { + await skills.attackNearest(ctx, type, true) return 'Attacking entity...' }, }, @@ -330,13 +331,13 @@ export const actionsList: Action[] = [ schema: z.object({ player_name: z.string().describe('The name of the player to attack.'), }), - perform: (ctx: BotContext) => async (player_name: string) => { + perform: (ctx: SkillContext) => async (player_name: string) => { const player = ctx.bot.players[player_name]?.entity if (!player) { - skills.log(ctx.bot, `Could not find player ${player_name}.`) + skills.log(ctx, `Could not find player ${player_name}.`) return 'Player not found' } - await skills.attackEntity(ctx.bot, player, true) + await skills.attackEntity(ctx, player, true) return 'Attacking player...' }, }, @@ -345,8 +346,8 @@ export const actionsList: Action[] = [ name: 'goToBed', description: 'Go to the nearest bed and sleep.', schema: z.object({}), - perform: (ctx: BotContext) => async () => { - await skills.goToBed(ctx.bot) + perform: (ctx: SkillContext) => async () => { + await skills.goToBed(ctx) return 'Going to bed...' }, }, @@ -357,8 +358,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The type of object to activate.'), }), - perform: (ctx: BotContext) => async (type: string) => { - await skills.activateNearestBlock(ctx.bot, type) + perform: (ctx: SkillContext) => async (type: string) => { + await skills.activateNearestBlock(ctx, type) return 'Activating block...' }, }, @@ -369,8 +370,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), }), - perform: (ctx: BotContext) => async (seconds: number) => { - await skills.stay(ctx.bot, seconds) + perform: (ctx: SkillContext) => async (seconds: number) => { + await skills.stay(ctx, seconds) return 'Staying in place...' }, }, diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 865f293ba..ab1772265 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -3,6 +3,7 @@ import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' +import { createSkillContext } from '../skills' import { actionsList } from './actions' import { queriesList } from './queries' @@ -51,7 +52,7 @@ export async function initActionAgent(ctx: BotContext): Promise { actionAgent = actionAgent.tool( action.name, action.schema, - action.perform(ctx), + action.perform(createSkillContext(ctx)), { description: action.description }, ) }) diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index e20c02400..1eda6529f 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,5 +1,5 @@ import type { Bot } from 'mineflayer' - +import type { BotContext } from 'src/composables/bot' /** * Context for skill execution */ @@ -18,10 +18,10 @@ export interface SkillContext { /** * Create a new skill context */ -export function createContext(bot: Bot): SkillContext { +export function createSkillContext(ctx: BotContext): SkillContext { return { - bot, - isCreative: bot.game.gameMode === 'creative', + bot: ctx.bot, + isCreative: ctx.bot.game?.gameMode === 'creative', allowCheats: false, shouldInterrupt: false, output: [], @@ -32,7 +32,8 @@ export function createContext(bot: Bot): SkillContext { * Log a message to the context's output buffer */ export function log(ctx: SkillContext, message: string): void { - ctx.output.push(message) + ctx.output.push(message) // TODO: remove this + ctx.bot.chat(message) } /** From d7a9b92623e9e85965d0d4a361e9d388cd2430a0 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Tue, 7 Jan 2025 03:22:23 +0800 Subject: [PATCH 24/77] feat: conversation & conversation manager & action manager --- services/minecraft/src/composables/action.ts | 168 ++++++++ services/minecraft/src/composables/agent.ts | 36 ++ .../minecraft/src/composables/conversation.ts | 383 ++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 services/minecraft/src/composables/action.ts create mode 100644 services/minecraft/src/composables/agent.ts create mode 100644 services/minecraft/src/composables/conversation.ts diff --git a/services/minecraft/src/composables/action.ts b/services/minecraft/src/composables/action.ts new file mode 100644 index 000000000..d22b43a8b --- /dev/null +++ b/services/minecraft/src/composables/action.ts @@ -0,0 +1,168 @@ +import type { Agent } from './agent' +import { useLogg } from '@guiiai/logg' + +type Fn = (...args: any[]) => void + +export function useActionManager(agent: Agent) { + const executing: { value: boolean } = { value: false } + const currentActionLabel: { value: string | undefined } = { value: '' } + const currentActionFn: { value: (Fn) | undefined } = { value: undefined } + const timedout: { value: boolean } = { value: false } + const resume_func: { value: (Fn) | undefined } = { value: undefined } + const resume_name: { value: string | undefined } = { value: undefined } + const log = useLogg('ActionManager').useGlobalConfig() + + async function resumeAction(actionLabel: string, actionFn: Fn, timeout: number) { + return _executeResume(actionLabel, actionFn, timeout) + } + + async function runAction(actionLabel: string, actionFn: Fn, options: { timeout: number, resume: boolean } = { timeout: 10, resume: false }) { + if (options.resume) { + return _executeResume(actionLabel, actionFn, options.timeout) + } + else { + return _executeAction(actionLabel, actionFn, options.timeout) + } + } + + async function stop() { + if (!executing.value) + return + const timeout = setTimeout(() => { + agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.') + }, 10000) + while (executing.value) { + agent.requestInterrupt() + log.log('waiting for code to finish executing...') + await new Promise(resolve => setTimeout(resolve, 300)) + } + clearTimeout(timeout) + } + + function cancelResume() { + resume_func.value = undefined + resume_name.value = undefined + } + + async function _executeResume(actionLabel?: string, actionFn?: Fn, timeout = 10) { + const new_resume = actionFn != null + if (new_resume) { // start new resume + resume_func.value = actionFn + if (actionLabel == null) { + throw new Error('actionLabel is required for new resume') + } + resume_name.value = actionLabel + } + if (resume_func.value != null && (agent.isIdle() || new_resume) && (!agent.self_prompter.on || new_resume)) { + currentActionLabel.value = resume_name.value + const res = await _executeAction(resume_name.value, resume_func.value, timeout) + currentActionLabel.value = '' + return res + } + else { + return { success: false, message: null, interrupted: false, timedout: false } + } + } + + async function _executeAction(actionLabel?: string, actionFn?: Fn, timeout = 10) { + let TIMEOUT + try { + log.log('executing code...\n') + + // await current action to finish (executing=false), with 10 seconds timeout + // also tell agent.bot to stop various actions + if (executing.value) { + log.log(`action "${actionLabel}" trying to interrupt current action "${currentActionLabel.value}"`) + } + await stop() + + // clear bot logs and reset interrupt code + agent.clearBotLogs() + + executing.value = true + currentActionLabel.value = actionLabel + currentActionFn.value = actionFn + + // timeout in minutes + if (timeout > 0) { + TIMEOUT = _startTimeout(timeout) + } + + // start the action + await actionFn?.() + + // mark action as finished + cleanup + executing.value = false + currentActionLabel.value = '' + currentActionFn.value = undefined + clearTimeout(TIMEOUT) + + // get bot activity summary + const output = _getBotOutputSummary() + const interrupted = agent.bot.interrupt_code + agent.clearBotLogs() + + // if not interrupted and not generating, emit idle event + if (!interrupted && !agent.coder.generating) { + agent.bot.emit('idle') + } + + // return action status report + return { success: true, message: output, interrupted, timedout } + } + catch (err) { + executing.value = false + currentActionLabel.value = '' + currentActionFn.value = undefined + clearTimeout(TIMEOUT) + cancelResume() + log.withError(err).error('Code execution triggered catch') + await stop() + + const message = `${_getBotOutputSummary() + }!!Code threw exception!!\n` + + `Error: ${err}\n` + + `Stack trace:\n${(err as Error).stack}` + + const interrupted = agent.bot.interrupt_code + agent.clearBotLogs() + if (!interrupted && !agent.coder.generating) { + agent.bot.emit('idle') + } + return { success: false, message, interrupted, timedout: false } + } + } + + function _getBotOutputSummary() { + const { bot } = agent + if (bot.interrupt_code && !timedout.value) + return '' + let output = bot.output + const MAX_OUT = 500 + if (output.length > MAX_OUT) { + output = `Code output is very long (${output.length} chars) and has been shortened.\n + First outputs:\n${output.substring(0, MAX_OUT / 2)}\n...skipping many lines.\nFinal outputs:\n ${output.substring(output.length - MAX_OUT / 2)}` + } + else { + output = `Code output:\n${output}` + } + + return output + } + + function _startTimeout(TIMEOUT_MINS = 10) { + return setTimeout(async () => { + log.warn(`Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`) + timedout.value = true + agent.history.add('system', `Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`) + await stop() // last attempt to stop + }, TIMEOUT_MINS * 60 * 1000) + } + + return { + runAction, + resumeAction, + stop, + cancelResume, + } +} diff --git a/services/minecraft/src/composables/agent.ts b/services/minecraft/src/composables/agent.ts new file mode 100644 index 000000000..bee29c559 --- /dev/null +++ b/services/minecraft/src/composables/agent.ts @@ -0,0 +1,36 @@ +export interface Agent { + name: string + history: { + add: (name: string, message: string) => void + } + lastSender?: string + isIdle: () => boolean + handleMessage: (sender: string, message: string) => void + openChat: (message: string) => void + self_prompter: { + on: boolean + stop: () => Promise + stopLoop: () => Promise + start: () => Promise + promptShouldRespondToBot: (message: string) => Promise + } + actions: { + currentActionLabel: string + } + prompter: { + promptShouldRespondToBot: (message: string) => Promise + } + shut_up: boolean + in_game: boolean + cleanKill: (message: string) => void + clearBotLogs: () => void + bot: { + interrupt_code: boolean + output: string + emit: (event: string) => void + } + coder: { + generating: boolean + } + requestInterrupt: () => void +} diff --git a/services/minecraft/src/composables/conversation.ts b/services/minecraft/src/composables/conversation.ts new file mode 100644 index 000000000..5d61f6590 --- /dev/null +++ b/services/minecraft/src/composables/conversation.ts @@ -0,0 +1,383 @@ +import type { Agent } from './agent' +import { useLogg } from '@guiiai/logg' + +let self_prompter_paused = false + +interface ConversationMessage { + message: string + start: boolean + end: boolean +} + +function compileInMessages(inQueue: ConversationMessage[]) { + let pack: ConversationMessage | undefined + let fullMessage = '' + while (inQueue.length > 0) { + pack = inQueue.shift() + if (!pack) + continue + + fullMessage += pack.message + } + if (pack) { + pack.message = fullMessage + } + + return pack +} + +type Conversation = ReturnType + +function useConversations(name: string, agent: Agent) { + const active = { value: false } + const ignoreUntilStart = { value: false } + const blocked = { value: false } + let inQueue: ConversationMessage[] = [] + const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined } + + function reset() { + active.value = false + ignoreUntilStart.value = false + inQueue = [] + } + + function end() { + active.value = false + ignoreUntilStart.value = true + const fullMessage = compileInMessages(inQueue) + if (!fullMessage) + return + + if (fullMessage.message.trim().length > 0) { + agent.history.add(name, fullMessage.message) + } + + if (agent.lastSender === name) { + agent.lastSender = undefined + } + } + + function queue(message: ConversationMessage) { + inQueue.push(message) + } + + return { + reset, + end, + queue, + name, + inMessageTimer, + blocked, + active, + ignoreUntilStart, + inQueue, + } +} + +const WAIT_TIME_START = 30000 + +export type ConversationStore = ReturnType + +export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) { + const conversations: Record = {} + const activeConversation: { value: Conversation | undefined } = { value: undefined } + const awaitingResponse = { value: false } + const waitTimeLimit = { value: WAIT_TIME_START } + const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined } + const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined } + const agent = options.agent + let agentsInGame = options.agentNames || [] + const log = useLogg('ConversationStore').useGlobalConfig() + + const conversationStore = { + getConvo: (name: string) => { + if (!conversations[name]) + conversations[name] = useConversations(name, agent) + return conversations[name] + }, + startMonitor: () => { + clearInterval(connectionMonitor.value) + let waitTime = 0 + let lastTime = Date.now() + connectionMonitor.value = setInterval(() => { + if (!activeConversation.value) { + conversationStore.stopMonitor() + return // will clean itself up + } + + const delta = Date.now() - lastTime + lastTime = Date.now() + const convo_partner = activeConversation.value.name + + if (awaitingResponse.value && agent.isIdle()) { + waitTime += delta + if (waitTime > waitTimeLimit.value) { + agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`) + waitTime = 0 + waitTimeLimit.value *= 2 + } + } + else if (!awaitingResponse.value) { + waitTimeLimit.value = WAIT_TIME_START + waitTime = 0 + } + + if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) { + connectionTimeout.value = setTimeout(() => { + if (conversationStore.otherAgentInGame(convo_partner)) { + conversationStore.clearMonitorTimeouts() + return + } + if (!self_prompter_paused) { + conversationStore.endConversation(convo_partner) + agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`) + } + else { + conversationStore.endConversation(convo_partner) + } + }, 10000) + } + }, 1000) + }, + stopMonitor: () => { + clearInterval(connectionMonitor.value) + connectionMonitor.value = undefined + conversationStore.clearMonitorTimeouts() + }, + clearMonitorTimeouts: () => { + awaitingResponse.value = false + clearTimeout(connectionTimeout.value) + connectionTimeout.value = undefined + }, + startConversation: (send_to: string, message: string) => { + const convo = conversationStore.getConvo(send_to) + convo.reset() + + if (agent.self_prompter.on) { + agent.self_prompter.stop() + self_prompter_paused = true + } + if (convo.active.value) + return + + convo.active.value = true + activeConversation.value = convo + conversationStore.startMonitor() + conversationStore.sendToBot(send_to, message, true, false) + }, + startConversationFromOtherBot: (name: string) => { + const convo = conversationStore.getConvo(name) + convo.active.value = true + activeConversation.value = convo + conversationStore.startMonitor() + }, + sendToBot: (send_to: string, message: string, start = false, open_chat = true) => { + if (!conversationStore.isOtherAgent(send_to)) { + console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`) + return + } + const convo = conversationStore.getConvo(send_to) + + if (options.chatBotMessages && open_chat) + agent.openChat(`(To ${send_to}) ${message}`) + + if (convo.ignoreUntilStart.value) + return + convo.active.value = true + + const end = message.includes('!endConversation') + const json = { + message, + start, + end, + } + + awaitingResponse.value = true + // TODO: + // sendBotChatToServer(send_to, json) + log.withField('json', json).log(`Sending message to ${send_to}`) + }, + receiveFromBot: async (sender: string, received: ConversationMessage) => { + const convo = conversationStore.getConvo(sender) + + if (convo.ignoreUntilStart.value && !received.start) + return + + // check if any convo is active besides the sender + if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) { + conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false) + conversationStore.endConversation(sender) + return + } + + if (received.start) { + convo.reset() + conversationStore.startConversationFromOtherBot(sender) + } + + conversationStore.clearMonitorTimeouts() + convo.queue(received) + + // responding to conversation takes priority over self prompting + if (agent.self_prompter.on) { + await agent.self_prompter.stopLoop() + self_prompter_paused = true + } + + _scheduleProcessInMessage(agent, conversationStore, sender, received, convo) + }, + responseScheduledFor: (sender: string) => { + if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender)) + return false + const convo = conversationStore.getConvo(sender) + return !!convo.inMessageTimer + }, + isOtherAgent: (name: string) => { + return !!options.agentNames?.includes(name) + }, + otherAgentInGame: (name: string) => { + return agentsInGame.includes(name) + }, + updateAgents: (agents: Agent[]) => { + options.agentNames = agents.map(a => a.name) + agentsInGame = agents.filter(a => a.in_game).map(a => a.name) + }, + getInGameAgents: () => { + return agentsInGame + }, + inConversation: (other_agent?: string) => { + if (other_agent) + return conversations[other_agent]?.active + return Object.values(conversations).some(c => c.active) + }, + endConversation: (sender: string) => { + if (conversations[sender]) { + conversations[sender].end() + if (activeConversation.value?.name === sender) { + conversationStore.stopMonitor() + activeConversation.value = undefined + if (self_prompter_paused && !conversationStore.inConversation()) { + _resumeSelfPrompter(agent, conversationStore) + } + } + } + }, + endAllConversations: () => { + for (const sender in conversations) { + conversationStore.endConversation(sender) + } + if (self_prompter_paused) { + _resumeSelfPrompter(agent, conversationStore) + } + }, + forceEndCurrentConversation: () => { + if (activeConversation.value) { + const sender = activeConversation.value.name + conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false) + conversationStore.endConversation(sender) + } + }, + scheduleSelfPrompter: () => { + self_prompter_paused = true + }, + cancelSelfPrompter: () => { + self_prompter_paused = false + }, + } + + return conversationStore +} + +function containsCommand(message: string) { + // TODO: mock + return message +} + +/* +This function controls conversation flow by deciding when the bot responds. +The logic is as follows: +- If neither bot is busy, respond quickly with a small delay. +- If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory) +- If I'm busy but other bot isn't, let LLM decide whether to respond +- If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses +- New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk +*/ +const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions +const fastDelay = 200 +const longDelay = 5000 + +async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) { + if (convo.inMessageTimer) + clearTimeout(convo.inMessageTimer.value) + const otherAgentBusy = containsCommand(received.message) + + const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay) + + if (!agent.isIdle() && otherAgentBusy) { + // both are busy + const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) + if (canTalkOver) + scheduleResponse(fastDelay) + // otherwise don't respond + } + else if (otherAgentBusy) { + // other bot is busy but I'm not + scheduleResponse(longDelay) + } + else if (!agent.isIdle()) { + // I'm busy but other bot isn't + const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) + if (canTalkOver) { + scheduleResponse(fastDelay) + } + else { + const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message) + useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`) + if (shouldRespond) + scheduleResponse(fastDelay) + } + } + else { + // neither are busy + scheduleResponse(fastDelay) + } +} + +function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) { + const convo = conversationStore.getConvo(name) + _handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue)) +} + +function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) { + if (!received) + return + + useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`) + + const convo = conversationStore.getConvo(sender) + convo.active.value = true + + let message = _tagMessage(received.message) + if (received.end) { + conversationStore.endConversation(sender) + message = `Conversation with ${sender} ended with message: "${message}"` + sender = 'system' // bot will respond to system instead of the other bot + } + else if (received.start) { + agent.shut_up = false + } + convo.inMessageTimer.value = undefined + agent.handleMessage(sender, message) +} + +function _tagMessage(message: string) { + return `(FROM OTHER BOT)${message}` +} + +async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) { + await new Promise(resolve => setTimeout(resolve, 5000)) + if (self_prompter_paused && !conversationStore.inConversation()) { + self_prompter_paused = false + agent.self_prompter.start() + } +} From fbd19aed0228eddfbc7b8efdebefe338fbc34d41 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 03:34:52 +0800 Subject: [PATCH 25/77] chore: actions unit test --- services/minecraft/src/agents/actions.test.ts | 43 ++++++++++++++++++ services/minecraft/src/agents/openai.test.ts | 44 ++++++++++--------- services/minecraft/src/composables/bot.ts | 14 ++++++ services/minecraft/src/composables/world.ts | 7 ++- services/minecraft/src/prompts/agent.ts | 23 +++++----- 5 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 services/minecraft/src/agents/actions.test.ts diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts new file mode 100644 index 000000000..198eef5a9 --- /dev/null +++ b/services/minecraft/src/agents/actions.test.ts @@ -0,0 +1,43 @@ +import { messages, system, user } from 'neuri/openai' +import { beforeAll, describe, it } from 'vitest' +import { createBot, useBot } from '../composables/bot' +import { botConfig, initEnv } from '../composables/config' +import { genActionAgentPrompt } from '../prompts/agent' +import { initLogger } from '../utils/logger' +import { initAgent } from './openai' + +describe('actions agent', { timeout: 0 }, () => { + beforeAll(() => { + initLogger() + initEnv() + createBot(botConfig) + }) + + it('should split question into actions', async () => { + const { ctx } = useBot() + const agent = await initAgent(ctx) + + function testFn() { + return new Promise((resolve) => { + ctx.bot.on('spawn', async () => { + const text = await agent.handle(messages( + system(genActionAgentPrompt(ctx)), + user('Help me to cut down the tree'), + ), async (c) => { + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + + console.log(completion) + + return await completion?.firstContent() + }) + + console.log(text) + + resolve() + }) + }) + } + + await testFn() + }) +}) diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index c7ea1644b..724bc577d 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -2,7 +2,7 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { createBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' -import { basicSystemPrompt, genQueryAgentPrompt } from '../prompts/agent' +import { genQueryAgentPrompt, genSystemBasicPrompt } from '../prompts/agent' import { initLogger } from '../utils/logger' import { initAgent } from './openai' @@ -17,32 +17,36 @@ describe('openAI agent', { timeout: 10000 }, () => { const { ctx } = useBot() const agent = await initAgent(ctx) - const text = await agent.handle( - messages( - system(basicSystemPrompt('airi')), - user('Hello, who are you?'), - ), - async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() - }, - ) + ctx.bot.once('spawn', async () => { + const text = await agent.handle( + messages( + system(genSystemBasicPrompt('airi')), + user('Hello, who are you?'), + ), + async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + return await completion?.firstContent() + }, + ) - expect(text?.toLowerCase()).toContain('airi') + expect(text?.toLowerCase()).toContain('airi') + }) }) it('should choose right query command', async () => { const { ctx } = useBot() const agent = await initAgent(ctx) - const text = await agent.handle(messages( - system(genQueryAgentPrompt(ctx)), - user('What are you status?'), - ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() - }) + ctx.bot.once('spawn', async () => { + const text = await agent.handle(messages( + system(genQueryAgentPrompt(ctx)), + user('What are you status?'), + ), async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + return await completion?.firstContent() + }) - expect(text?.toLowerCase()).toContain('position') + expect(text?.toLowerCase()).toContain('position') + }) }) }) diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 42079d112..2cc62e238 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,3 +1,4 @@ +import type { Position } from './../skills/base' import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' @@ -9,6 +10,7 @@ let ctx: BotContext | undefined export interface BotContext { bot: Bot botName: string + ready: boolean components: Map @@ -21,6 +23,12 @@ export interface BotContext { } status: Map + // status: { + // position: Position + // health: number + // weather: string + // timeOfDay: string + // } health: { value: number @@ -43,6 +51,7 @@ export interface ComponentLifecycle { export function createBot(options: BotOptions): Bot { logger.withFields({ options }).log('Creating bot') ctx = { + ready: false, bot: mineflayer.createBot({ host: options.host, port: options.port, @@ -112,6 +121,11 @@ export function createBot(options: BotOptions): Bot { ctx.health.value = ctx.bot.health }) + ctx.bot.once('spawn', () => { + ctx!.ready = true + logger.log('Bot ready') + }) + ctx.bot.on('death', () => { logger.error('Bot died') }) diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index 9a83f0339..0592bdeb6 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -36,7 +36,7 @@ export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distanc export function getNearestBlocks(ctx: WorldContext, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { const blockIds = blockTypes === null ? mc.getAllBlockIds(['air']) - : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId) + : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId).filter((id): id is number => id !== null) const positions = ctx.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) @@ -97,9 +97,8 @@ export function getInventoryCounts(ctx: WorldContext): Record { export function getCraftableItems(ctx: WorldContext): string[] { const table = getNearestBlock(ctx, 'crafting_table') || getInventoryStacks(ctx).find(item => item.name === 'crafting_table') - return mc.getAllItems() - .filter(item => ctx.bot.recipesFor(item.id, null, 1, table).length > 0) + .filter(item => ctx.bot.recipesFor(item.id, null, 1, table as Block | null).length > 0) .map(item => item.name) } @@ -136,7 +135,7 @@ export function getNearbyBlockTypes(ctx: WorldContext, distance: number = 16): s export async function isClearPath(ctx: WorldContext, target: Entity): Promise { const movements = new pf.Movements(ctx.bot) movements.canDig = false - movements.canPlaceOn = false + // movements.canPlaceOn = false // TODO: fix this const goal = new pf.goals.GoalNear( target.position.x, diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 8c41887c0..bc7d23dfa 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,15 +1,14 @@ import type { BotContext } from '../composables/bot' import { getStatusToString } from '../components/status' -export function basicSystemPrompt(botName: string): string { +export function genSystemBasicPrompt(botName: string): string { return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, mine, build, and interact with the world by using commands.` } -export function genSystemPrompt(ctx: BotContext): string { - return `${basicSystemPrompt(ctx.botName)} - -${ctx.prompt.selfPrompt} +export function genActionAgentPrompt(ctx: BotContext): string { + // ${ctx.prompt.selfPrompt} + return `${genSystemBasicPrompt(ctx.botName)} Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless @@ -24,19 +23,21 @@ Respond only as ${ctx.botName}, never output '(FROM OTHER BOT)'or pretend to be If you have nothing to say or do, respond with an just a tab '\t'. This is extremely important to me, take a deep breath and have fun :) -Summarized memory: '${ctx.memory.getSummary()}' +I will give you the following information: +${getStatusToString(ctx)} +` + +/** + * Summarized memory: '${ctx.memory.getSummary()}' $STATS $INVENTORY $COMMAND_DOCS $EXAMPLES - -Conversation Begin: -` + */ } export function genQueryAgentPrompt(ctx: BotContext): string { - const prompt = ` -You are a helpful assistant that asks questions to help me decide the next immediate + const prompt = `You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world. From bae100d5f3914a8d13ca2f56b96c1a9243eb0a75 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 14:14:05 +0800 Subject: [PATCH 26/77] feat: ai chat --- services/minecraft/src/agents/openai.ts | 12 +++++- services/minecraft/src/components/aichat.ts | 44 +++++++++++++++++++++ services/minecraft/src/main.ts | 2 + services/minecraft/src/prompts/agent.ts | 6 +-- 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 services/minecraft/src/components/aichat.ts diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index ab1772265..7607fadef 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -7,6 +7,7 @@ import { createSkillContext } from '../skills' import { actionsList } from './actions' import { queriesList } from './queries' +let neuriAgent: Neuri | undefined const agents = new Set>() const logger = useLogg('openai').useGlobalConfig() @@ -20,12 +21,21 @@ export async function initAgent(ctx: BotContext): Promise { agents.forEach(agent => n = n.agent(agent)) - return n.build({ + neuriAgent = await n.build({ provider: { apiKey: openaiConfig.apiKey, baseURL: openaiConfig.baseUrl, }, }) + + return neuriAgent +} + +export function getAgent(): Neuri { + if (!neuriAgent) { + throw new Error('Agent not initialized') + } + return neuriAgent } export async function initQueryAgent(ctx: BotContext): Promise { diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts new file mode 100644 index 000000000..2b0eb6060 --- /dev/null +++ b/services/minecraft/src/components/aichat.ts @@ -0,0 +1,44 @@ +import type { messages } from 'neuri/openai' +import type { BotContext, ComponentLifecycle } from 'src/composables/bot' +import { useLogg } from '@guiiai/logg' +import { assistant, system, user } from 'neuri/openai' +import { getAgent } from 'src/agents/openai' +import { formBotChat } from 'src/middlewares/chat' +import { genSystemBasicPrompt } from 'src/prompts/agent' + +export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { + const logger = useLogg('aichat').useGlobalConfig() + logger.log('Loading aichat plugin') + + const history: ReturnType = [] + history.push(system(genSystemBasicPrompt('airi'))) + + const onChat = formBotChat(ctx, async (username, message) => { + logger.withFields({ username, message }).log('Chat message received') + + history.push(user(message)) + + const agent = getAgent() + const content = await agent.handle(history.concat(user(message)), async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + const content = await completion?.firstContent() + if (content) { + history.push(assistant(content)) + } + + return content + }) + + if (content) { + ctx.bot.chat(content) + } + }) + + ctx.bot.on('chat', onChat) + + return { + cleanup: () => { + ctx.bot.removeListener('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index f1134bea7..2111cb9f7 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -3,6 +3,7 @@ import process, { exit } from 'node:process' import { useLogg } from '@guiiai/logg' import { initAgent } from './agents/openai' +import { createAiChatComponent } from './components/aichat' import { createCommandComponent } from './components/command' import { createFollowComponent } from './components/follow' import { createPathFinderComponent } from './components/pathfinder' @@ -27,6 +28,7 @@ async function main() { registerComponent('pathfinder', createPathFinderComponent) registerComponent('follow', createFollowComponent) registerComponent('command', createCommandComponent) + registerComponent('aichat', createAiChatComponent) }) await initAgent(ctx) diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index bc7d23dfa..72124f2d5 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -17,11 +17,7 @@ asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. -Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer("playername", 3)'. -Respond only as ${ctx.botName}, never output '(FROM OTHER BOT)'or pretend to be someone else. - -If you have nothing to say or do, respond with an just a tab '\t'. -This is extremely important to me, take a deep breath and have fun :) +Just call the function given you. I will give you the following information: ${getStatusToString(ctx)} From 604cf7f9e7781132fb78241588e1ff5fc5554481 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 16:32:09 +0800 Subject: [PATCH 27/77] refactor: merge query and action to actions --- services/minecraft/src/agents/actions.test.ts | 98 ++++++++++++++----- services/minecraft/src/agents/actions.ts | 79 ++++++++++++++- services/minecraft/src/agents/openai.test.ts | 45 +++------ services/minecraft/src/agents/openai.ts | 18 ---- services/minecraft/src/agents/queries.ts | 97 ------------------ services/minecraft/src/components/aichat.ts | 23 +++-- services/minecraft/src/composables/bot.ts | 1 - services/minecraft/src/composables/world.ts | 7 ++ services/minecraft/src/prompts/agent.ts | 5 +- services/minecraft/src/skills/base.ts | 2 + services/minecraft/src/utils/helper.ts | 1 + 11 files changed, 197 insertions(+), 179 deletions(-) delete mode 100644 services/minecraft/src/agents/queries.ts create mode 100644 services/minecraft/src/utils/helper.ts diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts index 198eef5a9..350ab6386 100644 --- a/services/minecraft/src/agents/actions.test.ts +++ b/services/minecraft/src/agents/actions.test.ts @@ -1,8 +1,9 @@ import { messages, system, user } from 'neuri/openai' -import { beforeAll, describe, it } from 'vitest' +import { sleep } from 'src/utils/helper' +import { beforeAll, describe, expect, it } from 'vitest' import { createBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' -import { genActionAgentPrompt } from '../prompts/agent' +import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent' import { initLogger } from '../utils/logger' import { initAgent } from './openai' @@ -13,31 +14,82 @@ describe('actions agent', { timeout: 0 }, () => { createBot(botConfig) }) - it('should split question into actions', async () => { + it('should choose right query command', async () => { const { ctx } = useBot() const agent = await initAgent(ctx) - function testFn() { - return new Promise((resolve) => { - ctx.bot.on('spawn', async () => { - const text = await agent.handle(messages( - system(genActionAgentPrompt(ctx)), - user('Help me to cut down the tree'), - ), async (c) => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - - console.log(completion) - - return await completion?.firstContent() - }) - - console.log(text) - - resolve() + await new Promise((resolve) => { + ctx.bot.once('spawn', async () => { + const text = await agent.handle(messages( + system(genQueryAgentPrompt(ctx)), + user('What are you status?'), + ), async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + console.log(JSON.stringify(completion, null, 2)) + return await completion?.firstContent() }) - }) - } - await testFn() + expect(text?.toLowerCase()).toContain('position') + + resolve() + }) + }) }) + + it('should choose right action command', async () => { + const { ctx } = useBot() + const agent = await initAgent(ctx) + + // console.log(JSON.stringify(agent, null, 2)) + + await new Promise((resolve) => { + ctx.bot.on('spawn', async () => { + const text = await agent.handle(messages( + system(genActionAgentPrompt(ctx)), + user('goToPlayer: luoling8192'), + ), async (c) => { + console.log(JSON.stringify(c, null, 2)) + + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + + console.log(JSON.stringify(completion, null, 2)) + + return await completion?.firstContent() + }) + + console.log(JSON.stringify(text, null, 2)) + + await sleep(10000) + resolve() + }) + }) + }) + + // it('should split question into actions', async () => { + // const { ctx } = useBot() + // const agent = await initAgent(ctx) + + // function testFn() { + // return new Promise((resolve) => { + // ctx.bot.on('spawn', async () => { + // const text = await agent.handle(messages( + // system(genActionAgentPrompt(ctx)), + // user('Help me to cut down the tree'), + // ), async (c) => { + // const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + + // console.log(completion) + + // return await completion?.firstContent() + // }) + + // console.log(text) + + // resolve() + // }) + // }) + // } + + // await testFn() + // }) }) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index d20838001..a9a9d51b5 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -1,6 +1,7 @@ -import type { BotContext } from 'src/composables/bot' import type { SkillContext } from '../skills' import { z } from 'zod' +import { getStatusToString } from '../components/status' +import * as world from '../composables/world' import * as skills from '../skills' type ActionResult = string | Promise @@ -12,7 +13,83 @@ interface Action { readonly perform: (ctx: SkillContext) => (...args: any[]) => ActionResult } +// Utils +const pad = (str: string): string => `\n${str}\n` + +function formatInventoryItem(item: string, count: number): string { + return count > 0 ? `\n- ${item}: ${count}` : '' +} + +function formatWearingItem(slot: string, item: string | undefined): string { + return item ? `\n${slot}: ${item}` : '' +} + export const actionsList: Action[] = [ + { + name: 'stats', + description: 'Get your bot\'s location, health, hunger, and time of day.', + schema: z.object({}), + perform: (ctx: SkillContext) => (): string => getStatusToString(ctx.botCtx), + }, + { + name: 'inventory', + description: 'Get your bot\'s inventory.', + schema: z.object({}), + perform: (ctx: SkillContext) => (): string => { + const { bot } = ctx + const inventory = world.getInventoryCounts(world.createWorldContext(ctx.botCtx)) + const items = Object.entries(inventory) + .map(([item, count]) => formatInventoryItem(item, count)) + .join('') + + const wearing = [ + formatWearingItem('Head', bot.inventory.slots[5]?.name), + formatWearingItem('Torso', bot.inventory.slots[6]?.name), + formatWearingItem('Legs', bot.inventory.slots[7]?.name), + formatWearingItem('Feet', bot.inventory.slots[8]?.name), + ].filter(Boolean).join('') + + return pad(`INVENTORY${items || ': Nothing'} + ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} + WEARING: ${wearing || 'Nothing'}`) + }, + }, + { + name: 'nearbyBlocks', + description: 'Get the blocks near the bot.', + schema: z.object({}), + perform: (ctx: SkillContext) => (): string => { + const blocks = world.getNearbyBlockTypes(world.createWorldContext(ctx.botCtx)) + return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) + }, + }, + { + name: 'craftable', + description: 'Get the craftable items with the bot\'s inventory.', + schema: z.object({}), + perform: (ctx: SkillContext) => (): string => { + const craftable = world.getCraftableItems(world.createWorldContext(ctx.botCtx)) + return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) + }, + }, + { + name: 'entities', + description: 'Get the nearby players and entities.', + schema: z.object({}), + perform: (ctx: SkillContext) => (): string => { + const worldCtx = world.createWorldContext(ctx.botCtx) + const players = world.getNearbyPlayerNames(worldCtx) + const entities = world.getNearbyEntityTypes(worldCtx) + .filter((e: string) => e !== 'player' && e !== 'item') + + const result = [ + ...players.map((p: string) => `- Human player: ${p}`), + ...entities.map((e: string) => `- entities: ${e}`), + ] + + return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) + }, + }, // getNewAction(): Action { // return { // name: 'newAction', diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index 724bc577d..be16e442c 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -2,11 +2,11 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { createBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' -import { genQueryAgentPrompt, genSystemBasicPrompt } from '../prompts/agent' +import { genSystemBasicPrompt } from '../prompts/agent' import { initLogger } from '../utils/logger' import { initAgent } from './openai' -describe('openAI agent', { timeout: 10000 }, () => { +describe('openAI agent', { timeout: 0 }, () => { beforeAll(() => { initLogger() initEnv() @@ -17,36 +17,23 @@ describe('openAI agent', { timeout: 10000 }, () => { const { ctx } = useBot() const agent = await initAgent(ctx) - ctx.bot.once('spawn', async () => { - const text = await agent.handle( - messages( - system(genSystemBasicPrompt('airi')), - user('Hello, who are you?'), - ), - async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() - }, - ) + await new Promise((resolve) => { + ctx.bot.once('spawn', async () => { + const text = await agent.handle( + messages( + system(genSystemBasicPrompt('airi')), + user('Hello, who are you?'), + ), + async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + return await completion?.firstContent() + }, + ) - expect(text?.toLowerCase()).toContain('airi') - }) - }) + expect(text?.toLowerCase()).toContain('airi') - it('should choose right query command', async () => { - const { ctx } = useBot() - const agent = await initAgent(ctx) - - ctx.bot.once('spawn', async () => { - const text = await agent.handle(messages( - system(genQueryAgentPrompt(ctx)), - user('What are you status?'), - ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() + resolve() }) - - expect(text?.toLowerCase()).toContain('position') }) }) }) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 7607fadef..ee7638c7f 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -5,7 +5,6 @@ import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' import { createSkillContext } from '../skills' import { actionsList } from './actions' -import { queriesList } from './queries' let neuriAgent: Neuri | undefined const agents = new Set>() @@ -16,7 +15,6 @@ export async function initAgent(ctx: BotContext): Promise { logger.log('Initializing agent') let n = neuri() - agents.add(initQueryAgent(ctx)) agents.add(initActionAgent(ctx)) agents.forEach(agent => n = n.agent(agent)) @@ -38,22 +36,6 @@ export function getAgent(): Neuri { return neuriAgent } -export async function initQueryAgent(ctx: BotContext): Promise { - logger.log('Initializing query agent') - let queryAgent = agent('query') - - Object.values(queriesList).forEach((query) => { - queryAgent = queryAgent.tool( - query.name, - query.schema, - query.perform(ctx), - { description: query.description }, - ) - }) - - return queryAgent.build() -} - export async function initActionAgent(ctx: BotContext): Promise { logger.log('Initializing action agent') let actionAgent = agent('action') diff --git a/services/minecraft/src/agents/queries.ts b/services/minecraft/src/agents/queries.ts deleted file mode 100644 index 075d887c0..000000000 --- a/services/minecraft/src/agents/queries.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { BotContext } from '../composables/bot' -import { z } from 'zod' -import { getStatusToString } from '../components/status' -import * as world from '../composables/world' - -// Core types -type QueryResult = string | Promise - -interface Query { - readonly name: string - readonly description: string - readonly schema: z.ZodObject - readonly perform: (ctx: BotContext) => () => QueryResult -} - -// Utils -const pad = (str: string): string => `\n${str}\n` - -function formatInventoryItem(item: string, count: number): string { - return count > 0 ? `\n- ${item}: ${count}` : '' -} - -function formatWearingItem(slot: string, item: string | undefined): string { - return item ? `\n${slot}: ${item}` : '' -} - -export const queriesList: Query[] = [ - { - name: 'stats', - description: 'Get your bot\'s location, health, hunger, and time of day.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => getStatusToString(ctx), - }, - { - name: 'inventory', - description: 'Get your bot\'s inventory.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const { bot } = ctx - const worldCtx = { bot, botCtx: ctx } - const inventory = world.getInventoryCounts(worldCtx) - const items = Object.entries(inventory) - .map(([item, count]) => formatInventoryItem(item, count)) - .join('') - - const wearing = [ - formatWearingItem('Head', bot.inventory.slots[5]?.name), - formatWearingItem('Torso', bot.inventory.slots[6]?.name), - formatWearingItem('Legs', bot.inventory.slots[7]?.name), - formatWearingItem('Feet', bot.inventory.slots[8]?.name), - ].filter(Boolean).join('') - - return pad(`INVENTORY${items || ': Nothing'} - ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} - WEARING: ${wearing || 'Nothing'}`) - }, - }, - { - name: 'nearbyBlocks', - description: 'Get the blocks near the bot.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const worldCtx = { bot: ctx.bot, botCtx: ctx } - const blocks = world.getNearbyBlockTypes(worldCtx) - return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) - }, - }, - { - name: 'craftable', - description: 'Get the craftable items with the bot\'s inventory.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const worldCtx = { bot: ctx.bot, botCtx: ctx } - const craftable = world.getCraftableItems(worldCtx) - return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) - }, - }, - { - name: 'entities', - description: 'Get the nearby players and entities.', - schema: z.object({}), - perform: (ctx: BotContext) => (): string => { - const { bot } = ctx - const worldCtx = { bot, botCtx: ctx } - const players = world.getNearbyPlayerNames(worldCtx) - const entities = world.getNearbyEntityTypes(worldCtx) - .filter((e: string) => e !== 'player' && e !== 'item') - - const result = [ - ...players.map((p: string) => `- Human player: ${p}`), - ...entities.map((e: string) => `- entities: ${e}`), - ] - - return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`) - }, - }, -] diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index 2b0eb6060..a52f20d18 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -20,16 +20,27 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { const agent = getAgent() const content = await agent.handle(history.concat(user(message)), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - const content = await completion?.firstContent() - if (content) { - history.push(assistant(content)) - } + logger.log('Generate response') - return content + try { + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + + logger.withFields({ completion }).log('Completion') + + const content = await completion?.firstContent() + if (content) { + history.push(assistant(content)) + } + + return content + } + catch (e) { + logger.errorWithError('Generate response error', e) + } }) if (content) { + logger.withFields({ content }).log('Bot response') ctx.bot.chat(content) } }) diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 2cc62e238..c47920818 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,4 +1,3 @@ -import type { Position } from './../skills/base' import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index 0592bdeb6..095444c55 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -12,6 +12,13 @@ interface WorldContext { botCtx: BotContext } +export function createWorldContext(ctx: BotContext): WorldContext { + return { + bot: ctx.bot, + botCtx: ctx, + } +} + export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distance: number = 8): Vec3 | undefined { const emptyPositions = ctx.bot.findBlocks({ matching: (block: Block) => block?.name === 'air', diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 72124f2d5..803539c9c 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -14,10 +14,7 @@ Act human-like as if you were a typical Minecraft player, rather than an AI. Be your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. -Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', -instead say this: 'Sure, I'll stop. !stop'. - -Just call the function given you. +Do not use any emojis. Just call the function given you. I will give you the following information: ${getStatusToString(ctx)} diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index 1eda6529f..e10c69e82 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -5,6 +5,7 @@ import type { BotContext } from 'src/composables/bot' */ export interface SkillContext { bot: Bot + botCtx: BotContext // Whether the bot is in creative mode isCreative: boolean // Whether the bot should use cheats (like /tp, /setblock) @@ -21,6 +22,7 @@ export interface SkillContext { export function createSkillContext(ctx: BotContext): SkillContext { return { bot: ctx.bot, + botCtx: ctx, isCreative: ctx.bot.game?.gameMode === 'creative', allowCheats: false, shouldInterrupt: false, diff --git a/services/minecraft/src/utils/helper.ts b/services/minecraft/src/utils/helper.ts new file mode 100644 index 000000000..c1eb515da --- /dev/null +++ b/services/minecraft/src/utils/helper.ts @@ -0,0 +1 @@ +export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) From e2613f33ea2bd7b09a11d665959b2bc195f99551 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 17:06:32 +0800 Subject: [PATCH 28/77] fix: movement --- services/minecraft/src/agents/openai.ts | 5 +++- services/minecraft/src/components/aichat.ts | 19 ++++--------- services/minecraft/src/main.ts | 2 +- services/minecraft/src/skills/movement.ts | 31 +++++++++++++-------- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index ee7638c7f..f49ff41e3 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -44,7 +44,10 @@ export async function initActionAgent(ctx: BotContext): Promise { actionAgent = actionAgent.tool( action.name, action.schema, - action.perform(createSkillContext(ctx)), + async ({ parameters }) => { + logger.withFields({ name: action.name, parameters }).log('Calling action') + return action.perform(createSkillContext(ctx))(...Object.values(parameters)) + }, { description: action.description }, ) }) diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index a52f20d18..421dc6cce 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -1,25 +1,22 @@ -import type { messages } from 'neuri/openai' import type { BotContext, ComponentLifecycle } from 'src/composables/bot' import { useLogg } from '@guiiai/logg' -import { assistant, system, user } from 'neuri/openai' +import { messages, system, user } from 'neuri/openai' import { getAgent } from 'src/agents/openai' import { formBotChat } from 'src/middlewares/chat' -import { genSystemBasicPrompt } from 'src/prompts/agent' +import { genActionAgentPrompt } from 'src/prompts/agent' export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { const logger = useLogg('aichat').useGlobalConfig() logger.log('Loading aichat plugin') - const history: ReturnType = [] - history.push(system(genSystemBasicPrompt('airi'))) - const onChat = formBotChat(ctx, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') - history.push(user(message)) - const agent = getAgent() - const content = await agent.handle(history.concat(user(message)), async (c) => { + const content = await agent.handle(messages( + system(genActionAgentPrompt(ctx)), + user(`${username}: ${message}`), + ), async (c) => { logger.log('Generate response') try { @@ -28,10 +25,6 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { logger.withFields({ completion }).log('Completion') const content = await completion?.firstContent() - if (content) { - history.push(assistant(content)) - } - return content } catch (e) { diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 2111cb9f7..909bc1249 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -35,7 +35,7 @@ async function main() { const ticker = createTicker() ticker.on('tick', async ({ delta }) => { - logger.log(`Tick ${delta}ms`) + // logger.log(`Tick ${delta}ms`) }) process.on('SIGINT', () => { diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 88a5846c5..cfc0227eb 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -1,8 +1,11 @@ import type { Entity } from 'prismarine-entity' import type { SkillContext } from './base' +import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' +const { goals, Movements } = pathfinderModel + /** * Navigate to a specific position */ @@ -25,7 +28,7 @@ export async function goToPosition( return true } - await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(x, y, z, minDistance)) + await bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) log(ctx, `You have reached ${x}, ${y}, ${z}.`) return true } @@ -45,7 +48,8 @@ export async function goToNearestBlock( range = MAX_RANGE } - const block = world.getNearestBlock(ctx.bot, blockType, range) + const worldCtx = world.createWorldContext(ctx.botCtx) + const block = world.getNearestBlock(worldCtx, blockType, range) if (!block) { log(ctx, `Could not find any ${blockType} in ${range} blocks.`) return false @@ -66,8 +70,9 @@ export async function goToNearestEntity( range = 64, ): Promise { const { bot } = ctx + const worldCtx = world.createWorldContext(ctx.botCtx) const entity = world.getNearestEntityWhere( - bot, + worldCtx, entity => entity.name === entityType, range, ) @@ -110,7 +115,7 @@ export async function goToPlayer( return false } - await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(player, distance), true) + await bot.pathfinder.goto(new goals.GoalFollow(player, distance)) log(ctx, `You have reached ${username}.`) return true } @@ -128,7 +133,7 @@ export async function followPlayer( if (!player) return false - bot.pathfinder.setGoal(bot.pathfinder.goals.GoalFollow(player, distance), true) + bot.pathfinder.setGoal(new goals.GoalFollow(player, distance)) log(ctx, `You are now actively following player ${username}.`) while (!ctx.shouldInterrupt) { @@ -136,7 +141,7 @@ export async function followPlayer( if (ctx.allowCheats && bot.entity.position.distanceTo(player.position) > 100 - && player.isOnGround) { + && player.onGround) { await goToPlayer(ctx, username) } } @@ -149,11 +154,11 @@ export async function followPlayer( export async function moveAway(ctx: SkillContext, distance: number): Promise { const { bot } = ctx const pos = bot.entity.position - const goal = bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, distance) - const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + const goal = new goals.GoalNear(pos.x, pos.y, pos.z, distance) + const invertedGoal = new goals.GoalInvert(goal) if (ctx.allowCheats) { - const move = new bot.pathfinder.Movements(bot) + const move = new Movements(bot) const path = await bot.pathfinder.getPathTo(move, invertedGoal, 10000) const lastMove = path.path[path.path.length - 1] @@ -181,8 +186,8 @@ export async function moveAwayFromEntity( distance = 16, ): Promise { const { bot } = ctx - const goal = bot.pathfinder.goals.GoalFollow(entity, distance) - const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) + const goal = new goals.GoalFollow(entity, distance) + const invertedGoal = new goals.GoalInvert(goal) await bot.pathfinder.goto(invertedGoal) return true } @@ -192,7 +197,9 @@ export async function moveAwayFromEntity( */ export async function stay(ctx: SkillContext, seconds = 30): Promise { const start = Date.now() - while (!ctx.shouldInterrupt && (seconds === -1 || Date.now() - start < seconds * 1000)) { + const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 + + while (!ctx.shouldInterrupt && Date.now() < targetTime) { await new Promise(resolve => setTimeout(resolve, 500)) } From 46b04c2863eba8c904fef206155735b203c2e77d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 21:28:43 +0800 Subject: [PATCH 29/77] fix: chat history --- services/minecraft/src/components/aichat.ts | 27 ++++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index 421dc6cce..f58fb40c2 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -1,30 +1,39 @@ import type { BotContext, ComponentLifecycle } from 'src/composables/bot' import { useLogg } from '@guiiai/logg' -import { messages, system, user } from 'neuri/openai' -import { getAgent } from 'src/agents/openai' -import { formBotChat } from 'src/middlewares/chat' -import { genActionAgentPrompt } from 'src/prompts/agent' +import { assistant, type Message, messages, system, user } from 'neuri/openai' +import { getAgent } from '../agents/openai' +import { formBotChat } from '../middlewares/chat' +import { genActionAgentPrompt } from '../prompts/agent' export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { const logger = useLogg('aichat').useGlobalConfig() logger.log('Loading aichat plugin') + const historyMessage: Message[] = [] + historyMessage.push(system(genActionAgentPrompt(ctx))) + const onChat = formBotChat(ctx, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') + historyMessage.push(user(`${username}: ${message}`)) + const agent = getAgent() - const content = await agent.handle(messages( - system(genActionAgentPrompt(ctx)), - user(`${username}: ${message}`), - ), async (c) => { + const content = await agent.handleStateless(messages(...historyMessage), async (c) => { logger.log('Generate response') try { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } logger.withFields({ completion }).log('Completion') + if (!completion || 'error' in completion) { + logger.withFields(c).error('Completion') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + const content = await completion?.firstContent() + historyMessage.push(assistant(content)) + return content } catch (e) { From 0ab46ba96356c4999a465150214665e642688c88 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 7 Jan 2025 21:35:20 +0800 Subject: [PATCH 30/77] fix: follow player --- services/minecraft/src/components/aichat.ts | 6 ++-- services/minecraft/src/skills/movement.ts | 34 +++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index f58fb40c2..8d9cb68e6 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -1,6 +1,6 @@ -import type { BotContext, ComponentLifecycle } from 'src/composables/bot' +import type { BotContext, ComponentLifecycle } from '../composables/bot' import { useLogg } from '@guiiai/logg' -import { assistant, type Message, messages, system, user } from 'neuri/openai' +import { assistant, type Message, system, user } from 'neuri/openai' import { getAgent } from '../agents/openai' import { formBotChat } from '../middlewares/chat' import { genActionAgentPrompt } from '../prompts/agent' @@ -18,7 +18,7 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { historyMessage.push(user(`${username}: ${message}`)) const agent = getAgent() - const content = await agent.handleStateless(messages(...historyMessage), async (c) => { + const content = await agent.handleStateless([...historyMessage], async (c) => { logger.log('Generate response') try { diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index cfc0227eb..259190a22 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -120,9 +120,6 @@ export async function goToPlayer( return true } -/** - * Follow a player continuously - */ export async function followPlayer( ctx: SkillContext, username: string, @@ -130,21 +127,40 @@ export async function followPlayer( ): Promise { const { bot } = ctx const player = bot.players[username]?.entity - if (!player) + if (!player) { + log(ctx, `Could not find player ${username}`) return false + } - bot.pathfinder.setGoal(new goals.GoalFollow(player, distance)) - log(ctx, `You are now actively following player ${username}.`) + const movements = new Movements(bot) + bot.pathfinder.setMovements(movements) + bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) + + log(ctx, `Started following ${username}`) + + const followInterval = setInterval(() => { + const target = bot.players[username]?.entity + if (!target) { + log(ctx, 'Lost sight of player') + clearInterval(followInterval) + return + } + + const { x, y, z } = target.position + bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) + }, 1000) while (!ctx.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) - if (ctx.allowCheats - && bot.entity.position.distanceTo(player.position) > 100 - && player.onGround) { + if (ctx.allowCheats && bot.entity.position.distanceTo(player.position) > 100) { await goToPlayer(ctx, username) } } + + // TODO: need global status management + // clearInterval(followInterval) + // bot.pathfinder.stop() return true } From 7b534dc7605c7876e5f230ee38919c17a40aaea1 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 22:03:58 +0800 Subject: [PATCH 31/77] feat: global memory --- services/minecraft/src/agents/openai.ts | 1 + services/minecraft/src/components/aichat.ts | 12 ++++++------ services/minecraft/src/composables/bot.ts | 8 ++++++-- services/minecraft/src/prompts/agent.ts | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index f49ff41e3..7efc10168 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -46,6 +46,7 @@ export async function initActionAgent(ctx: BotContext): Promise { action.schema, async ({ parameters }) => { logger.withFields({ name: action.name, parameters }).log('Calling action') + ctx.memory.actions.push(action) return action.perform(createSkillContext(ctx))(...Object.values(parameters)) }, { description: action.description }, diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index 8d9cb68e6..5463db2bb 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -9,16 +9,15 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { const logger = useLogg('aichat').useGlobalConfig() logger.log('Loading aichat plugin') - const historyMessage: Message[] = [] - historyMessage.push(system(genActionAgentPrompt(ctx))) + ctx.memory.chatHistory.push(system(genActionAgentPrompt(ctx))) const onChat = formBotChat(ctx, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') - historyMessage.push(user(`${username}: ${message}`)) + ctx.memory.chatHistory.push(user(`${username}: ${message}`)) const agent = getAgent() - const content = await agent.handleStateless([...historyMessage], async (c) => { + const content = await agent.handleStateless([...ctx.memory.chatHistory], async (c) => { logger.log('Generate response') try { @@ -28,11 +27,12 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { if (!completion || 'error' in completion) { logger.withFields(c).error('Completion') - throw new Error(completion?.error?.message ?? 'Unknown error') + return + // throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() - historyMessage.push(assistant(content)) + ctx.memory.chatHistory.push(assistant(content)) return content } diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index c47920818..aca5dbcc9 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,3 +1,5 @@ +import type { Message } from 'neuri/openai' +import type { Action } from 'src/agents/actions' import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' @@ -18,7 +20,9 @@ export interface BotContext { } memory: { - getSummary: () => string + chatHistory: Message[] + actions: Action[] + // getSummary: () => string } status: Map @@ -63,7 +67,7 @@ export function createBot(options: BotOptions): Bot { selfPrompt: '', }, memory: { - getSummary: () => '', + chatHistory: [], }, status: new Map(), health: { diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 803539c9c..a6623537b 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -14,7 +14,7 @@ Act human-like as if you were a typical Minecraft player, rather than an AI. Be your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. -Do not use any emojis. Just call the function given you. +Do not use any emojis. Just call the function given you if needed. I will give you the following information: ${getStatusToString(ctx)} From a811ce41eee01688ac31d7a6017c076c02751e6f Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 22:05:36 +0800 Subject: [PATCH 32/77] feat(skill): movement --- services/minecraft/src/skills/movement.ts | 102 ++++++++++++---------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 259190a22..1dcac8974 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -6,9 +6,6 @@ import { log } from './base' const { goals, Movements } = pathfinderModel -/** - * Navigate to a specific position - */ export async function goToPosition( ctx: SkillContext, x: number, @@ -16,26 +13,22 @@ export async function goToPosition( z: number, minDistance = 2, ): Promise { - const { bot } = ctx if (x == null || y == null || z == null) { log(ctx, `Missing coordinates, given x:${x} y:${y} z:${z}`) return false } if (ctx.allowCheats) { - bot.chat(`/tp @s ${x} ${y} ${z}`) + ctx.bot.chat(`/tp @s ${x} ${y} ${z}`) log(ctx, `Teleported to ${x}, ${y}, ${z}.`) return true } - await bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) + await ctx.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) log(ctx, `You have reached ${x}, ${y}, ${z}.`) return true } -/** - * Navigate to the nearest block of a specific type - */ export async function goToNearestBlock( ctx: SkillContext, blockType: string, @@ -60,16 +53,12 @@ export async function goToNearestBlock( return true } -/** - * Navigate to the nearest entity of a specific type - */ export async function goToNearestEntity( ctx: SkillContext, entityType: string, minDistance = 2, range = 64, ): Promise { - const { bot } = ctx const worldCtx = world.createWorldContext(ctx.botCtx) const entity = world.getNearestEntityWhere( worldCtx, @@ -82,7 +71,7 @@ export async function goToNearestEntity( return false } - const distance = bot.entity.position.distanceTo(entity.position) + const distance = ctx.bot.entity.position.distanceTo(entity.position) log(ctx, `Found ${entityType} ${distance} blocks away.`) await goToPosition( ctx, @@ -94,28 +83,24 @@ export async function goToNearestEntity( return true } -/** - * Navigate to a specific player - */ export async function goToPlayer( ctx: SkillContext, username: string, distance = 3, ): Promise { - const { bot } = ctx if (ctx.allowCheats) { - bot.chat(`/tp @s ${username}`) + ctx.bot.chat(`/tp @s ${username}`) log(ctx, `Teleported to ${username}.`) return true } - const player = bot.players[username]?.entity + const player = ctx.bot.players[username]?.entity if (!player) { log(ctx, `Could not find ${username}.`) return false } - await bot.pathfinder.goto(new goals.GoalFollow(player, distance)) + await ctx.bot.pathfinder.goto(new goals.GoalFollow(player, distance)) log(ctx, `You have reached ${username}.`) return true } @@ -125,21 +110,20 @@ export async function followPlayer( username: string, distance = 4, ): Promise { - const { bot } = ctx - const player = bot.players[username]?.entity + const player = ctx.bot.players[username]?.entity if (!player) { log(ctx, `Could not find player ${username}`) return false } - const movements = new Movements(bot) - bot.pathfinder.setMovements(movements) - bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) + const movements = new Movements(ctx.bot) + ctx.bot.pathfinder.setMovements(movements) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) log(ctx, `Started following ${username}`) const followInterval = setInterval(() => { - const target = bot.players[username]?.entity + const target = ctx.bot.players[username]?.entity if (!target) { log(ctx, 'Lost sight of player') clearInterval(followInterval) @@ -147,70 +131,60 @@ export async function followPlayer( } const { x, y, z } = target.position - bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) + ctx.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) }, 1000) while (!ctx.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) - if (ctx.allowCheats && bot.entity.position.distanceTo(player.position) > 100) { + if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100) { await goToPlayer(ctx, username) } } // TODO: need global status management // clearInterval(followInterval) - // bot.pathfinder.stop() + // ctx.bot.pathfinder.stop() return true } -/** - * Move away from current position - */ export async function moveAway(ctx: SkillContext, distance: number): Promise { - const { bot } = ctx - const pos = bot.entity.position + const pos = ctx.bot.entity.position const goal = new goals.GoalNear(pos.x, pos.y, pos.z, distance) const invertedGoal = new goals.GoalInvert(goal) if (ctx.allowCheats) { - const move = new Movements(bot) - const path = await bot.pathfinder.getPathTo(move, invertedGoal, 10000) + const move = new Movements(ctx.bot) + const path = await ctx.bot.pathfinder.getPathTo(move, invertedGoal, 10000) const lastMove = path.path[path.path.length - 1] if (lastMove) { const x = Math.floor(lastMove.x) const y = Math.floor(lastMove.y) const z = Math.floor(lastMove.z) - bot.chat(`/tp @s ${x} ${y} ${z}`) + ctx.bot.chat(`/tp @s ${x} ${y} ${z}`) return true } } - await bot.pathfinder.goto(invertedGoal) - const newPos = bot.entity.position + await ctx.bot.pathfinder.goto(invertedGoal) + const newPos = ctx.bot.entity.position log(ctx, `Moved away from nearest entity to ${newPos}.`) return true } -/** - * Move away from a specific entity - */ export async function moveAwayFromEntity( ctx: SkillContext, entity: Entity, distance = 16, ): Promise { - const { bot } = ctx const goal = new goals.GoalFollow(entity, distance) const invertedGoal = new goals.GoalInvert(goal) - await bot.pathfinder.goto(invertedGoal) + await ctx.bot.pathfinder.goto(invertedGoal) return true } -/** - * Stay in current position - */ + export async function stay(ctx: SkillContext, seconds = 30): Promise { const start = Date.now() const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 @@ -222,3 +196,35 @@ export async function stay(ctx: SkillContext, seconds = 30): Promise { log(ctx, `Stayed for ${(Date.now() - start) / 1000} seconds.`) return true } + +export async function goToBed(ctx: SkillContext): Promise { + const beds = ctx.bot.findBlocks({ + matching: block => block.name.includes('bed'), + maxDistance: 32, + count: 1, + }) + + if (beds.length === 0) { + log(ctx, 'Could not find a bed to sleep in.') + return false + } + + const loc = beds[0] + await goToPosition(ctx, loc.x, loc.y, loc.z) + + const bed = ctx.bot.blockAt(loc) + if (!bed) { + log(ctx, 'Could not find bed block.') + return false + } + + await ctx.bot.sleep(bed) + log(ctx, 'You are in bed.') + + while (ctx.bot.isSleeping) { + await new Promise(resolve => setTimeout(resolve, 500)) + } + + log(ctx, 'You have woken up.') + return true +} From dfd888b4b44be13d7db603be976589bbb081f6f7 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 22:35:14 +0800 Subject: [PATCH 33/77] fix: useSkillContext --- services/minecraft/src/agents/actions.test.ts | 2 +- services/minecraft/src/agents/actions.ts | 35 +++++---- services/minecraft/src/agents/openai.ts | 4 +- services/minecraft/src/components/aichat.ts | 1 + services/minecraft/src/composables/bot.ts | 4 +- services/minecraft/src/main.ts | 2 +- services/minecraft/src/skills/base.ts | 16 ++++- services/minecraft/src/skills/movement.ts | 71 +++++++++++++------ 8 files changed, 89 insertions(+), 46 deletions(-) diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts index 350ab6386..fea5b5da2 100644 --- a/services/minecraft/src/agents/actions.test.ts +++ b/services/minecraft/src/agents/actions.test.ts @@ -1,9 +1,9 @@ import { messages, system, user } from 'neuri/openai' -import { sleep } from 'src/utils/helper' import { beforeAll, describe, expect, it } from 'vitest' import { createBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent' +import { sleep } from '../utils/helper' import { initLogger } from '../utils/logger' import { initAgent } from './openai' diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index a9a9d51b5..74324c1c2 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -6,7 +6,7 @@ import * as skills from '../skills' type ActionResult = string | Promise -interface Action { +export interface Action { readonly name: string readonly description: string readonly schema: z.ZodObject @@ -105,23 +105,22 @@ export const actionsList: Action[] = [ // } // }, - // getStopAction(): Action { - // return { - // name: 'stop', - // description: 'Force stop all actions and commands that are currently executing.', - // schema: z.object({}), - // perform: (ctx: BotContext) => async () => { - // await ctx.actions.stop() - // ctx.clearBotLogs() - // ctx.actions.cancelResume() - // ctx.bot.emit('idle') - // let msg = 'Agent stopped.' - // if (ctx.self_prompter.on) - // msg += ' Self-prompting still active.' - // return msg - // }, - // } - // }, + { + name: 'stop', + description: 'Force stop all actions and commands that are currently executing.', + schema: z.object({}), + perform: (ctx: SkillContext) => async () => { + // await ctx.actions.stop() + // ctx.clearBotLogs() + // ctx.actions.cancelResume() + // ctx.bot.emit('idle') + ctx.shouldInterrupt = true + const msg = 'Agent stopped.' + // if (ctx.self_prompter.on) + // msg += ' Self-prompting still active.' + return msg + }, + }, // getStfuAction(): Action { // return { diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 7efc10168..b94623317 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -3,7 +3,7 @@ import type { BotContext } from '../composables/bot' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { createSkillContext } from '../skills' +import { useSkillContext } from '../skills' import { actionsList } from './actions' let neuriAgent: Neuri | undefined @@ -47,7 +47,7 @@ export async function initActionAgent(ctx: BotContext): Promise { async ({ parameters }) => { logger.withFields({ name: action.name, parameters }).log('Calling action') ctx.memory.actions.push(action) - return action.perform(createSkillContext(ctx))(...Object.values(parameters)) + return action.perform(useSkillContext(ctx))(...Object.values(parameters)) }, { description: action.description }, ) diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts index 5463db2bb..7f6aef685 100644 --- a/services/minecraft/src/components/aichat.ts +++ b/services/minecraft/src/components/aichat.ts @@ -11,6 +11,7 @@ export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { ctx.memory.chatHistory.push(system(genActionAgentPrompt(ctx))) + // todo: get system message const onChat = formBotChat(ctx, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index aca5dbcc9..f2e4a0128 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,5 +1,5 @@ import type { Message } from 'neuri/openai' -import type { Action } from 'src/agents/actions' +import type { Action } from '../agents/actions' import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' @@ -51,6 +51,7 @@ export interface ComponentLifecycle { cleanup: () => void } +// todo: reconnect export function createBot(options: BotOptions): Bot { logger.withFields({ options }).log('Creating bot') ctx = { @@ -68,6 +69,7 @@ export function createBot(options: BotOptions): Bot { }, memory: { chatHistory: [], + actions: [], }, status: new Map(), health: { diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 909bc1249..90695593b 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -16,7 +16,7 @@ import { createTicker } from './utils/ticker' const logger = useLogg('main').useGlobalConfig() async function main() { - initLogger() + initLogger() // todo: save logs to file initEnv() createBot(botConfig) diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index e10c69e82..8bbcdd9bb 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,5 +1,19 @@ import type { Bot } from 'mineflayer' -import type { BotContext } from 'src/composables/bot' +import type { BotContext } from '../composables/bot' +import { useLogg } from '@guiiai/logg' + +let ctx: SkillContext | undefined +const logger = useLogg('skills').useGlobalConfig() + +export function useSkillContext(botCtx: BotContext): SkillContext { + if (!ctx) { + logger.log('Creating skill context') + ctx = createSkillContext(botCtx) + } + + return ctx +} + /** * Context for skill execution */ diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 1dcac8974..115d054aa 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -110,41 +110,69 @@ export async function followPlayer( username: string, distance = 4, ): Promise { + // const player = ctx.bot.players[username]?.entity + // if (!player) { + // log(ctx, `Could not find player ${username}`) + // return false + // } + + // const movements = new Movements(ctx.bot) + // ctx.bot.pathfinder.setMovements(movements) + // ctx.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) + + // log(ctx, `Started following ${username}`) + + // const followInterval = setInterval(() => { + // const target = ctx.bot.players[username]?.entity + // if (!target) { + // log(ctx, 'Lost sight of player') + // clearInterval(followInterval) + // return + // } + + // const { x, y, z } = target.position + // ctx.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) + // }, 1000) + + // while (!ctx.shouldInterrupt) { + // await new Promise(resolve => setTimeout(resolve, 500)) + + // if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100) { + // await goToPlayer(ctx, username) + // } + // } + + // // TODO: need global status management + // clearInterval(followInterval) + // ctx.bot.pathfinder.stop() + // return true + const player = ctx.bot.players[username]?.entity if (!player) { - log(ctx, `Could not find player ${username}`) return false } const movements = new Movements(ctx.bot) ctx.bot.pathfinder.setMovements(movements) - ctx.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) - - log(ctx, `Started following ${username}`) - - const followInterval = setInterval(() => { - const target = ctx.bot.players[username]?.entity - if (!target) { - log(ctx, 'Lost sight of player') - clearInterval(followInterval) - return - } - - const { x, y, z } = target.position - ctx.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) - }, 1000) + ctx.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) + log(ctx, `You are now actively following player ${username}.`) while (!ctx.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) - if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100) { + if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { await goToPlayer(ctx, username) } - } - // TODO: need global status management - // clearInterval(followInterval) - // ctx.bot.pathfinder.stop() + // if (ctx.bot.modes?.isOn('unstuck')) { + // const isNearby = ctx.bot.entity.position.distanceTo(player.position) <= distance + 1 + // if (isNearby) { + // ctx.bot.modes.pause('unstuck') + // } else { + // ctx.bot.modes.unpause('unstuck') + // } + // } + } return true } @@ -184,7 +212,6 @@ export async function moveAwayFromEntity( return true } - export async function stay(ctx: SkillContext, seconds = 30): Promise { const start = Date.now() const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 From 252cc1e81a953e9fcd649b8673987bacfd809ff0 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 22:35:28 +0800 Subject: [PATCH 34/77] feat(skill): inventory --- services/minecraft/src/skills/inventory.ts | 95 ++++++++-------------- 1 file changed, 33 insertions(+), 62 deletions(-) diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index d82ca2254..981126363 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,14 +1,13 @@ import type { Bot } from 'mineflayer' import type { SkillContext } from './base' +import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' import { goToPosition } from './movement' -/** - * Pick up nearby items - */ +const { goals } = pathfinderModel + export async function pickupNearbyItems(ctx: SkillContext): Promise { - const { bot } = ctx const distance = 8 const getNearestItem = (bot: Bot) => bot.nearestEntity(entity => @@ -16,15 +15,15 @@ export async function pickupNearbyItems(ctx: SkillContext): Promise { && bot.entity.position.distanceTo(entity.position) < distance, ) - let nearestItem = getNearestItem(bot) + let nearestItem = getNearestItem(ctx.bot) let pickedUp = 0 while (nearestItem) { - await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(nearestItem, 0.8), true) + await ctx.bot.pathfinder.goto(new goals.GoalFollow(nearestItem, 0.8)) await new Promise(resolve => setTimeout(resolve, 200)) const prev = nearestItem - nearestItem = getNearestItem(bot) + nearestItem = getNearestItem(ctx.bot) if (prev === nearestItem) { break } @@ -35,55 +34,47 @@ export async function pickupNearbyItems(ctx: SkillContext): Promise { return true } -/** - * Equip an item - */ export async function equip(ctx: SkillContext, itemName: string): Promise { - const { bot } = ctx - const item = bot.inventory.slots.find(slot => slot && slot.name === itemName) + const item = ctx.bot.inventory.slots.find(slot => slot && slot.name === itemName) if (!item) { log(ctx, `You do not have any ${itemName} to equip.`) return false } if (itemName.includes('leggings')) { - await bot.equip(item, 'legs') + await ctx.bot.equip(item, 'legs') } else if (itemName.includes('boots')) { - await bot.equip(item, 'feet') + await ctx.bot.equip(item, 'feet') } else if (itemName.includes('helmet')) { - await bot.equip(item, 'head') + await ctx.bot.equip(item, 'head') } else if (itemName.includes('chestplate') || itemName.includes('elytra')) { - await bot.equip(item, 'torso') + await ctx.bot.equip(item, 'torso') } else if (itemName.includes('shield')) { - await bot.equip(item, 'off-hand') + await ctx.bot.equip(item, 'off-hand') } else { - await bot.equip(item, 'hand') + await ctx.bot.equip(item, 'hand') } log(ctx, `Equipped ${itemName}.`) return true } -/** - * Discard items - */ export async function discard(ctx: SkillContext, itemName: string, num = -1): Promise { - const { bot } = ctx let discarded = 0 while (true) { - const item = bot.inventory.items().find(item => item.name === itemName) + const item = ctx.bot.inventory.items().find(item => item.name === itemName) if (!item) { break } const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) - await bot.toss(item.type, null, toDiscard) + await ctx.bot.toss(item.type, null, toDiscard) discarded += toDiscard if (num !== -1 && discarded >= num) { @@ -100,18 +91,14 @@ export async function discard(ctx: SkillContext, itemName: string, num = -1): Pr return true } -/** - * Put items in a chest - */ export async function putInChest(ctx: SkillContext, itemName: string, num = -1): Promise { - const { bot } = ctx - const chest = world.getNearestBlock(bot, 'chest', 32) + const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) if (!chest) { log(ctx, 'Could not find a chest nearby.') return false } - const item = bot.inventory.items().find(item => item.name === itemName) + const item = ctx.bot.inventory.items().find(item => item.name === itemName) if (!item) { log(ctx, `You do not have any ${itemName} to put in the chest.`) return false @@ -120,7 +107,7 @@ export async function putInChest(ctx: SkillContext, itemName: string, num = -1): const toPut = num === -1 ? item.count : Math.min(num, item.count) await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await bot.openContainer(chest) + const chestContainer = await ctx.bot.openContainer(chest) await chestContainer.deposit(item.type, null, toPut) await chestContainer.close() @@ -128,19 +115,15 @@ export async function putInChest(ctx: SkillContext, itemName: string, num = -1): return true } -/** - * Take items from a chest - */ export async function takeFromChest(ctx: SkillContext, itemName: string, num = -1): Promise { - const { bot } = ctx - const chest = world.getNearestBlock(bot, 'chest', 32) + const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) if (!chest) { log(ctx, 'Could not find a chest nearby.') return false } await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await bot.openContainer(chest) + const chestContainer = await ctx.bot.openContainer(chest) const item = chestContainer.containerItems().find(item => item.name === itemName) if (!item) { @@ -157,19 +140,15 @@ export async function takeFromChest(ctx: SkillContext, itemName: string, num = - return true } -/** - * View contents of a chest - */ export async function viewChest(ctx: SkillContext): Promise { - const { bot } = ctx - const chest = world.getNearestBlock(bot, 'chest', 32) + const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) if (!chest) { log(ctx, 'Could not find a chest nearby.') return false } await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await bot.openContainer(chest) + const chestContainer = await ctx.bot.openContainer(chest) const items = chestContainer.containerItems() if (items.length === 0) { @@ -186,16 +165,12 @@ export async function viewChest(ctx: SkillContext): Promise { return true } -/** - * Consume (eat/drink) an item - */ export async function consume(ctx: SkillContext, itemName = ''): Promise { - const { bot } = ctx let item let name if (itemName) { - item = bot.inventory.items().find(item => item.name === itemName) + item = ctx.bot.inventory.items().find(item => item.name === itemName) name = itemName } @@ -204,23 +179,19 @@ export async function consume(ctx: SkillContext, itemName = ''): Promise { - const { bot } = ctx - const player = bot.players[username]?.entity + const player = ctx.bot.players[username]?.entity if (!player) { log(ctx, `Could not find ${username}.`) return false @@ -228,21 +199,21 @@ export async function giveToPlayer( await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 3) - if (bot.entity.position.y < player.position.y - 1) { + if (ctx.bot.entity.position.y < player.position.y - 1) { await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 1) } - if (bot.entity.position.distanceTo(player.position) < 2) { - const goal = bot.pathfinder.goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) - const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) - await bot.pathfinder.goto(invertedGoal) + if (ctx.bot.entity.position.distanceTo(player.position) < 2) { + const goal = new goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) + const invertedGoal = new goals.GoalInvert(goal) + await ctx.bot.pathfinder.goto(invertedGoal) } - await bot.lookAt(player.position) + await ctx.bot.lookAt(player.position) if (await discard(ctx, itemType, num)) { let given = false - bot.once('playerCollect', (collector, collected) => { + ctx.bot.once('playerCollect', (collector, _collected) => { if (collector.username === username) { log(ctx, `${username} received ${itemType}.`) given = true From f997fe13ad3a31e4cc1ace725bd9477035a11802 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 23:25:06 +0800 Subject: [PATCH 35/77] chore: load plugins --- services/minecraft/src/components/follow.ts | 4 +--- services/minecraft/src/components/pathfinder.ts | 5 +---- services/minecraft/src/composables/bot.ts | 16 ++++++++++++++++ services/minecraft/src/main.ts | 5 +---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts index 6ac5004e9..466d7e7d1 100644 --- a/services/minecraft/src/components/follow.ts +++ b/services/minecraft/src/components/follow.ts @@ -4,15 +4,13 @@ import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' import { registerCommand } from '../composables/command' -const { goals, Movements, pathfinder } = pathfinderModel +const { goals, Movements } = pathfinderModel export function createFollowComponent(ctx: BotContext, config?: { rangeGoal: number }): ComponentLifecycle { const logger = useLogg('follow').useGlobalConfig() - ctx.bot.loadPlugin(pathfinder) - const state = { following: undefined as string | undefined, movements: new Movements(ctx.bot), diff --git a/services/minecraft/src/components/pathfinder.ts b/services/minecraft/src/components/pathfinder.ts index e5fc3d32e..589a57120 100644 --- a/services/minecraft/src/components/pathfinder.ts +++ b/services/minecraft/src/components/pathfinder.ts @@ -4,15 +4,12 @@ import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' import { registerCommand } from '../composables/command' -const { goals, Movements, pathfinder } = pathfinderModel +const { goals, Movements } = pathfinderModel export function createPathFinderComponent(ctx: BotContext, config?: { rangeGoal: number }): ComponentLifecycle { const logger = useLogg('pathfinder').useGlobalConfig() - logger.log('Loading pathfinder plugin') - - ctx.bot.loadPlugin(pathfinder) let defaultMove: any diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index f2e4a0128..1afdde0e1 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -3,6 +3,11 @@ import type { Action } from '../agents/actions' import type { BotInternalEventHandlers, BotInternalEvents } from './events' import { useLogg } from '@guiiai/logg' import mineflayer, { type Bot, type BotOptions } from 'mineflayer' +import armorManager from 'mineflayer-armor-manager' +import { loader as autoEat } from 'mineflayer-auto-eat' +import { plugin as collectblock } from 'mineflayer-collectblock' +import { pathfinder } from 'mineflayer-pathfinder' +import { plugin as pvp } from 'mineflayer-pvp' const logger = useLogg('bot').useGlobalConfig() @@ -93,6 +98,17 @@ export function createBot(options: BotOptions): Bot { }, } + logger.log('Loading plugins') + ctx.bot.loadPlugin(pathfinder) + ctx.bot.loadPlugin(pvp) + ctx.bot.loadPlugin(collectblock) + ctx.bot.loadPlugin(autoEat) + ctx.bot.loadPlugin(armorManager) // auto equip armor + ctx.bot.once('resourcePack', () => { + ctx?.bot.acceptResourcePack() + }) + logger.log('Plugins loaded') + ctx.bot.on('time', () => { if (!ctx) return diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 90695593b..84729b0ce 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -33,10 +33,7 @@ async function main() { await initAgent(ctx) - const ticker = createTicker() - ticker.on('tick', async ({ delta }) => { - // logger.log(`Tick ${delta}ms`) - }) + createTicker() process.on('SIGINT', () => { cleanup() From 405da9c3adbaae5cc60fd803f7d74d4018a2ac30 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 23:26:21 +0800 Subject: [PATCH 36/77] feat(skill): crafting --- services/minecraft/src/skills/crafting.ts | 69 ++++++++++------------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 2099555b4..5e3824ab1 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -1,18 +1,15 @@ import type { SkillContext } from './base' import * as world from '../composables/world' +import { createWorldContext } from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' -import { placeBlock } from './blocks' +import { collectBlock, placeBlock } from './blocks' import { goToPosition } from './movement' -/** - * Craft items from a recipe - */ export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): Promise { - const { bot } = ctx let placedTable = false - if (mc.getItemCraftingRecipes(itemName).length === 0) { + if (mc.getItemCraftingRecipes(itemName)?.length === 0) { log(ctx, `${itemName} is either not an item, or it does not have a crafting recipe!`) return false } @@ -24,19 +21,19 @@ export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): return false } - let recipes = bot.recipesFor(itemId, null, 1, null) + let recipes = ctx.bot.recipesFor(itemId, null, 1, null) let craftingTable = null const craftingTableRange = 32 if (!recipes || recipes.length === 0) { - recipes = bot.recipesFor(itemId, null, 1, true) + recipes = ctx.bot.recipesFor(itemId, null, 1, true) if (!recipes || recipes.length === 0) { log(ctx, `You do not have the resources to craft a ${itemName}.`) return false } // Look for crafting table - const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const worldCtx = createWorldContext(ctx.botCtx) craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) if (!craftingTable) { // Try to place crafting table @@ -48,7 +45,7 @@ export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): await placeBlock(ctx, 'crafting_table', pos.x, pos.y, pos.z) craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) if (craftingTable) { - recipes = bot.recipesFor(itemId, null, 1, craftingTable) + recipes = ctx.bot.recipesFor(itemId, null, 1, craftingTable) placedTable = true } } @@ -59,34 +56,34 @@ export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): } } else { - recipes = bot.recipesFor(itemId, null, 1, craftingTable) + recipes = ctx.bot.recipesFor(itemId, null, 1, craftingTable) } } if (!recipes || recipes.length === 0) { log(ctx, `You do not have the resources to craft a ${itemName}. It requires: ${ - Object.entries(mc.getItemCraftingRecipes(itemName)[0]) + Object.entries(mc.getItemCraftingRecipes(itemName)?.[0] ?? {}) .map(([key, value]) => `${key}: ${value}`) .join(', ') }.`) if (placedTable && craftingTable) { - await bot.collectBlock.collect(craftingTable) + await collectBlock(ctx, 'crafting_table', 1) } return false } - if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { + if (craftingTable && ctx.bot.entity.position.distanceTo(craftingTable.position) > 4) { await goToPosition(ctx, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) } const recipe = recipes[0] // Check that the agent has sufficient items to use the recipe `num` times - const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const worldCtx = createWorldContext(ctx.botCtx) const inventory = world.getInventoryCounts(worldCtx) // Items in the agents inventory const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe) // Items required to use the recipe once const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients) - await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable) + await ctx.bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable ?? undefined) if (craftLimit.num < num) { log(ctx, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(worldCtx)[itemName]} ${itemName}.`) @@ -96,20 +93,16 @@ export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): } if (placedTable && craftingTable) { - await bot.collectBlock.collect(craftingTable) + await collectBlock(ctx, 'crafting_table', 1) } // Equip any armor the bot may have crafted - bot.armorManager.equipAll() + ctx.bot.armorManager.equipAll() return true } -/** - * Smelt items in a furnace - */ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): Promise { - const { bot } = ctx if (!mc.isSmeltable(itemName)) { log(ctx, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) return false @@ -117,7 +110,7 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P let placedFurnace = false const furnaceRange = 32 - const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const worldCtx = createWorldContext(ctx.botCtx) let furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', furnaceRange) if (!furnaceBlock) { @@ -139,13 +132,13 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P return false } - if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + if (ctx.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } - await bot.lookAt(furnaceBlock.position) + await ctx.bot.lookAt(furnaceBlock.position) - const furnace = await bot.openFurnace(furnaceBlock) + const furnace = await ctx.bot.openFurnace(furnaceBlock) // Check if the furnace is already smelting something const inputItem = furnace.inputItem() @@ -158,7 +151,7 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P if (inputItem && inputItem.type !== itemId && inputItem.count > 0) { log(ctx, `The furnace is currently smelting ${mc.getItemName(inputItem.type) ?? 'unknown'}.`) if (placedFurnace) { - await bot.collectBlock.collect(furnaceBlock) + await collectBlock(ctx, 'furnace', 1) } return false } @@ -168,18 +161,18 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P if (!invCounts[itemName] || invCounts[itemName] < num) { log(ctx, `You do not have enough ${itemName} to smelt.`) if (placedFurnace) { - await bot.collectBlock.collect(furnaceBlock) + await collectBlock(ctx, 'furnace', 1) } return false } // Fuel the furnace if (!furnace.fuelItem()) { - const fuel = mc.getSmeltingFuel(bot) + const fuel = mc.getSmeltingFuel(ctx.bot) if (!fuel) { log(ctx, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`) if (placedFurnace) { - await bot.collectBlock.collect(furnaceBlock) + await collectBlock(ctx, 'furnace', 1) } return false } @@ -190,7 +183,7 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P if (fuel.count < putFuel) { log(ctx, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) if (placedFurnace) { - await bot.collectBlock.collect(furnaceBlock) + await collectBlock(ctx, 'furnace', 1) } return false } @@ -231,10 +224,10 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P } } - await bot.closeWindow(furnace) + await ctx.bot.closeWindow(furnace) if (placedFurnace) { - await bot.collectBlock.collect(furnaceBlock) + await collectBlock(ctx, 'furnace', 1) } if (total === 0) { @@ -251,23 +244,19 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P return true } -/** - * Clear the nearest furnace - */ export async function clearNearestFurnace(ctx: SkillContext): Promise { - const { bot } = ctx - const worldCtx = { bot, botCtx: { bot, botName: bot.username } } + const worldCtx = createWorldContext(ctx.botCtx) const furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', 32) if (!furnaceBlock) { log(ctx, 'No furnace nearby to clear.') return false } - if (bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + if (ctx.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } - const furnace = await bot.openFurnace(furnaceBlock) + const furnace = await ctx.bot.openFurnace(furnaceBlock) // Take the items out of the furnace let smeltedItem, inputItem, fuelItem From 3e92b1e945a3accd779805847314bb430d9e6311 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 23:26:46 +0800 Subject: [PATCH 37/77] chore: simplify --- services/minecraft/src/utils/mcdata.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 60340e4c8..1ebc0906f 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -55,28 +55,6 @@ export const WOOL_COLORS: string[] = [ 'black', ] -export function initBot(username: string): Bot { - const bot = createBot({ - username, - - host: botConfig.host, - port: botConfig.port, - auth: 'offline', - - version: mc_version, - }) - bot.loadPlugin(pathfinder) - bot.loadPlugin(pvp) - bot.loadPlugin(collectblock) - bot.loadPlugin(autoEat) - bot.loadPlugin(armorManager) // auto equip armor - bot.once('resourcePack', () => { - bot.acceptResourcePack() - }) - - return bot -} - export function isHuntable(mob: { name?: string, metadata: any[] }): boolean { if (!mob || !mob.name) return false @@ -172,6 +150,7 @@ export function getItemCraftingRecipes(itemName: string): Record return null } + // todo: fix this const recipes: Record[] = [] for (const r of mcdata.recipes[itemId]) { const recipe: Record = {} From 6baa1888910146a471e4c83f0915a942f713d494 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 8 Jan 2025 23:28:21 +0800 Subject: [PATCH 38/77] feat(skill): combat --- services/minecraft/src/skills/combat.ts | 50 ++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 9946efd78..2ae048487 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -1,24 +1,30 @@ import type { Entity } from 'prismarine-entity' +import type { Item } from 'prismarine-item' import type { SkillContext } from './base' +import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' -/** - * Equip the item with highest attack damage - */ +const { goals } = pathfinderModel + +interface WeaponItem extends Item { + attackDamage: number +} + async function equipHighestAttack(ctx: SkillContext): Promise { const { bot } = ctx const weapons = bot.inventory.items().filter(item => item.name.includes('sword') || (item.name.includes('axe') && !item.name.includes('pickaxe')), - ) + ) as WeaponItem[] if (weapons.length === 0) { const tools = bot.inventory.items().filter(item => item.name.includes('pickaxe') || item.name.includes('shovel'), - ) + ) as WeaponItem[] + if (tools.length === 0) return @@ -35,16 +41,13 @@ async function equipHighestAttack(ctx: SkillContext): Promise { await bot.equip(weapon, 'hand') } -/** - * Attack the nearest mob of the given type - */ export async function attackNearest( ctx: SkillContext, mobType: string, kill = true, ): Promise { - const { bot } = ctx - const mob = world.getNearbyEntities(bot, 24).find(entity => entity.name === mobType) + const worldCtx = world.createWorldContext(ctx.botCtx) + const mob = world.getNearbyEntities(worldCtx, 24).find(entity => entity.name === mobType) if (mob) { return await attackEntity(ctx, mob, kill) @@ -54,9 +57,6 @@ export async function attackNearest( return false } -/** - * Attack a specific entity - */ export async function attackEntity( ctx: SkillContext, entity: Entity, @@ -68,14 +68,16 @@ export async function attackEntity( if (!kill) { if (bot.entity.position.distanceTo(pos) > 5) { - await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4) + await bot.pathfinder.goto(goal) } await bot.attack(entity) return true } bot.pvp.attack(entity) - while (world.getNearbyEntities(bot, 24).includes(entity)) { + const worldCtx = world.createWorldContext(ctx.botCtx) + while (world.getNearbyEntities(worldCtx, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)) if (ctx.shouldInterrupt) { bot.pvp.stop() @@ -87,13 +89,11 @@ export async function attackEntity( return true } -/** - * Defend against nearby hostile mobs - */ export async function defendSelf(ctx: SkillContext, range = 9): Promise { const { bot } = ctx let attacked = false - let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) + const worldCtx = world.createWorldContext(ctx.botCtx) + let enemy = world.getNearestEntityWhere(worldCtx, entity => mc.isHostile(entity), range) while (enemy) { await equipHighestAttack(ctx) @@ -101,17 +101,17 @@ export async function defendSelf(ctx: SkillContext, range = 9): Promise if (bot.entity.position.distanceTo(enemy.position) >= 4 && enemy.name !== 'creeper' && enemy.name !== 'phantom') { try { - await bot.pathfinder.goto(bot.pathfinder.goals.GoalFollow(enemy, 3.5), true) + const goal = new goals.GoalFollow(enemy, 3.5) + await bot.pathfinder.goto(goal) } catch { /* might error if entity dies, ignore */ } } if (bot.entity.position.distanceTo(enemy.position) <= 2) { try { - const invertedGoal = bot.pathfinder.goals.GoalInvert( - bot.pathfinder.goals.GoalFollow(enemy, 2), - ) - await bot.pathfinder.goto(invertedGoal, true) + const followGoal = new goals.GoalFollow(enemy, 2) + const invertedGoal = new goals.GoalInvert(followGoal) + await bot.pathfinder.goto(invertedGoal) } catch { /* might error if entity dies, ignore */ } } @@ -119,7 +119,7 @@ export async function defendSelf(ctx: SkillContext, range = 9): Promise bot.pvp.attack(enemy) attacked = true await new Promise(resolve => setTimeout(resolve, 500)) - enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), range) + enemy = world.getNearestEntityWhere(worldCtx, entity => mc.isHostile(entity), range) if (ctx.shouldInterrupt) { bot.pvp.stop() From c97ac0b120b419ebc11a0a21f82c4c70f90a3846 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 01:29:36 +0800 Subject: [PATCH 39/77] feat(skill): blocks --- services/minecraft/src/agents/actions.ts | 1 + services/minecraft/src/composables/bot.ts | 2 + services/minecraft/src/skills/blocks.ts | 210 +++++++++++----------- 3 files changed, 111 insertions(+), 102 deletions(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 74324c1c2..2bc91da67 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -105,6 +105,7 @@ export const actionsList: Action[] = [ // } // }, + // todo: must 'stop now' can be used to stop the agent { name: 'stop', description: 'Force stop all actions and commands that are currently executing.', diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 1afdde0e1..34cd302bd 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -8,6 +8,7 @@ import { loader as autoEat } from 'mineflayer-auto-eat' import { plugin as collectblock } from 'mineflayer-collectblock' import { pathfinder } from 'mineflayer-pathfinder' import { plugin as pvp } from 'mineflayer-pvp' +import { plugin as tool } from 'mineflayer-tool' const logger = useLogg('bot').useGlobalConfig() @@ -104,6 +105,7 @@ export function createBot(options: BotOptions): Bot { ctx.bot.loadPlugin(collectblock) ctx.bot.loadPlugin(autoEat) ctx.bot.loadPlugin(armorManager) // auto equip armor + ctx.bot.loadPlugin(tool) ctx.bot.once('resourcePack', () => { ctx?.bot.acceptResourcePack() }) diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 657101cc8..59d5d691d 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,17 +1,23 @@ +import type { TypeOf } from 'zod' import type { BlockFace, SkillContext } from './base' +import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import * as world from '../composables/world' +import { posEqual } from '../utils/helper' import * as mc from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' +const { goals, Movements } = pathfinderModel + /** * Place a torch if needed */ async function autoLight(ctx: SkillContext): Promise { - if (world.shouldPlaceTorch(ctx.bot)) { + const worldCtx = world.createWorldContext(ctx.botCtx) + if (world.shouldPlaceTorch(worldCtx)) { try { - const pos = world.getPosition(ctx.bot) + const pos = world.getPosition(worldCtx) return await placeBlock(ctx, 'torch', pos.x, pos.y, pos.z, 'bottom', true) } catch { @@ -30,10 +36,9 @@ export async function breakBlockAt( y: number, z: number, ): Promise { - const { bot } = ctx validatePosition(x, y, z) - const block = bot.blockAt(new Vec3(x, y, z)) + const block = ctx.bot.blockAt(new Vec3(x, y, z)) if (isUnbreakableBlock(block)) return false @@ -41,7 +46,7 @@ export async function breakBlockAt( return breakWithCheats(ctx, x, y, z) } - await moveIntoRange(bot, block) + await moveIntoRange(ctx, block) if (ctx.isCreative) { return breakInCreative(ctx, block, x, y, z) @@ -61,40 +66,38 @@ function isUnbreakableBlock(block: any): boolean { } async function breakWithCheats(ctx: SkillContext, x: number, y: number, z: number): Promise { - const { bot } = ctx - bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + ctx.bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) log(ctx, `Used /setblock to break block at ${x}, ${y}, ${z}.`) return true } -async function moveIntoRange(bot: any, block: any) { - if (bot.entity.position.distanceTo(block.position) > 4.5) { +async function moveIntoRange(ctx: SkillContext, block: any) { + if (ctx.bot.entity.position.distanceTo(block.position) > 4.5) { const pos = block.position - const movements = new bot.pathfinder.Movements(bot) + const movements = new Movements(ctx.bot) movements.allowParkour = false movements.allowSprinting = false - bot.pathfinder.setMovements(movements) - await bot.pathfinder.goto(bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + ctx.bot.pathfinder.setMovements(movements) + await ctx.bot.pathfinder.goto(new goals.GoalNear(pos.x, pos.y, pos.z, 4)) } } async function breakInCreative(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { - const { bot } = ctx - await bot.dig(block, true) + await ctx.bot.dig(block, true) log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) return true } async function breakInSurvival(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { - const { bot } = ctx - await bot.tool.equipForBlock(block) - const itemId = bot.heldItem?.type + await ctx.bot.tool.equipForBlock(block) + + const itemId = ctx.bot.heldItem?.type if (!block.canHarvest(itemId)) { log(ctx, `Don't have right tools to break ${block.name}.`) return false } - await bot.dig(block, true) + await ctx.bot.dig(block, true) log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) return true } @@ -111,7 +114,6 @@ export async function placeBlock( placeOn: BlockFace = 'bottom', dontCheat = false, ): Promise { - const { bot } = ctx if (!mc.getBlockId(blockType)) { log(ctx, `Invalid block type: ${blockType}.`) return false @@ -146,7 +148,7 @@ function getBlockState(blockType: string, placeOn: BlockFace): string { } function getInvertedFace(placeOn: BlockFace): string { - const faceMap = { + const faceMap: Record = { north: 'south', south: 'north', east: 'west', @@ -186,17 +188,16 @@ async function placeWithCheats( targetDest: Vec3, placeOn: BlockFace, ): Promise { - const { bot } = ctx const blockState = getBlockState(blockType, placeOn) - bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) + ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) if (blockType.includes('door')) { - bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) + ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) } if (blockType.includes('bed')) { - bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) + ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) } log(ctx, `Used /setblock to place ${blockType} at ${targetDest}.`) @@ -209,13 +210,12 @@ async function placeWithoutCheats( targetDest: Vec3, placeOn: BlockFace, ): Promise { - const { bot } = ctx const itemName = blockType === 'redstone_wire' ? 'redstone' : blockType - let block = bot.inventory.items().find(item => item.name === itemName) + let block = ctx.bot.inventory.items().find(item => item.name === itemName) if (!block && ctx.isCreative) { - await bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) - block = bot.inventory.items().find(item => item.name === itemName) + await ctx.bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) + block = ctx.bot.inventory.items().find(item => item.name === itemName) } if (!block) { @@ -223,22 +223,27 @@ async function placeWithoutCheats( return false } - const targetBlock = bot.blockAt(targetDest) - if (targetBlock.name === blockType) { + const targetBlock = ctx.bot.blockAt(targetDest) + if (targetBlock?.name === blockType) { log(ctx, `${blockType} already at ${targetBlock.position}.`) return false } const emptyBlocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern'] - if (!emptyBlocks.includes(targetBlock.name)) { + if (!emptyBlocks.includes(targetBlock?.name ?? '')) { if (!await clearBlockSpace(ctx, targetBlock, blockType)) { return false } } - const { buildOffBlock, faceVec } = findPlacementSpot(bot, targetDest, placeOn, emptyBlocks) + const { buildOffBlock, faceVec } = findPlacementSpot(ctx, targetDest, placeOn, emptyBlocks) if (!buildOffBlock) { - log(ctx, `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`) + log(ctx, `Cannot place ${blockType} at ${targetBlock?.position}: nothing to place on.`) + return false + } + + if (!faceVec) { + log(ctx, `Cannot place ${blockType} at ${targetBlock?.position}: no valid face to place on.`) return false } @@ -261,7 +266,7 @@ async function clearBlockSpace( return true } -function findPlacementSpot(bot: any, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { +function findPlacementSpot(ctx: SkillContext, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { const dirMap = { top: new Vec3(0, 1, 0), bottom: new Vec3(0, -1, 0), @@ -274,8 +279,8 @@ function findPlacementSpot(bot: any, targetDest: Vec3, placeOn: BlockFace, empty const dirs = getPlacementDirections(placeOn, dirMap) for (const d of dirs) { - const block = bot.blockAt(targetDest.plus(d)) - if (!emptyBlocks.includes(block.name)) { + const block = ctx.bot.blockAt(targetDest.plus(d)) + if (!emptyBlocks.includes(block?.name ?? '')) { return { buildOffBlock: block, faceVec: new Vec3(-d.x, -d.y, -d.z), @@ -287,7 +292,7 @@ function findPlacementSpot(bot: any, targetDest: Vec3, placeOn: BlockFace, empty } function getPlacementDirections(placeOn: BlockFace, dirMap: Record): Vec3[] { - const dirs = [] + const dirs: Vec3[] = [] if (placeOn === 'side') { dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) } @@ -302,7 +307,6 @@ function getPlacementDirections(placeOn: BlockFace, dirMap: Record } async function moveIntoPosition(ctx: SkillContext, blockType: string, targetBlock: any) { - const { bot } = ctx const dontMoveFor = [ 'torch', 'redstone_torch', @@ -318,38 +322,38 @@ async function moveIntoPosition(ctx: SkillContext, blockType: string, targetBloc 'water_bucket', ] - const pos = bot.entity.position + const pos = ctx.bot.entity.position const posAbove = pos.plus(new Vec3(0, 1, 0)) if (!dontMoveFor.includes(blockType) && (pos.distanceTo(targetBlock.position) < 1 || posAbove.distanceTo(targetBlock.position) < 1)) { - await moveAwayFromBlock(bot, targetBlock) + await moveAwayFromBlock(ctx, targetBlock) } - if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { - await moveToBlock(bot, targetBlock) + if (ctx.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + await moveToBlock(ctx, targetBlock) } } -async function moveAwayFromBlock(bot: any, targetBlock: any) { - const goal = bot.pathfinder.goals.GoalNear( +async function moveAwayFromBlock(ctx: SkillContext, targetBlock: any) { + const goal = new goals.GoalNear( targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2, ) - const invertedGoal = bot.pathfinder.goals.GoalInvert(goal) - bot.pathfinder.setMovements(new bot.pathfinder.Movements(bot)) - await bot.pathfinder.goto(invertedGoal) + const invertedGoal = new goals.GoalInvert(goal) + ctx.bot.pathfinder.setMovements(new Movements(ctx.bot)) + await ctx.bot.pathfinder.goto(invertedGoal) } -async function moveToBlock(bot: any, targetBlock: any) { +async function moveToBlock(ctx: SkillContext, targetBlock: any) { const pos = targetBlock.position - const movements = new bot.pathfinder.Movements(bot) - bot.pathfinder.setMovements(movements) - await bot.pathfinder.goto( - bot.pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4), + const movements = new Movements(ctx.bot) + ctx.bot.pathfinder.setMovements(movements) + await ctx.bot.pathfinder.goto( + new goals.GoalNear(pos.x, pos.y, pos.z, 4), ) } @@ -361,12 +365,11 @@ async function tryPlaceBlock( blockType: string, targetDest: Vec3, ): Promise { - const { bot } = ctx - await bot.equip(block, 'hand') - await bot.lookAt(buildOffBlock.position) + await ctx.bot.equip(block, 'hand') + await ctx.bot.lookAt(buildOffBlock.position) try { - await bot.placeBlock(buildOffBlock, faceVec) + await ctx.bot.placeBlock(buildOffBlock, faceVec) log(ctx, `Placed ${blockType} at ${targetDest}.`) await new Promise(resolve => setTimeout(resolve, 200)) return true @@ -381,8 +384,7 @@ async function tryPlaceBlock( * Use a door at the specified position */ export async function useDoor(ctx: SkillContext, doorPos: Vec3 | null = null): Promise { - const { bot } = ctx - doorPos = doorPos || await findNearestDoor(bot) + doorPos = doorPos || await findNearestDoor(ctx.bot) if (!doorPos) { log(ctx, 'Could not find a door to use.') @@ -390,7 +392,7 @@ export async function useDoor(ctx: SkillContext, doorPos: Vec3 | null = null): P } await goToPosition(ctx, doorPos.x, doorPos.y, doorPos.z, 1) - while (bot.pathfinder.isMoving()) { + while (ctx.bot.pathfinder.isMoving()) { await new Promise(resolve => setTimeout(resolve, 100)) } @@ -422,26 +424,27 @@ async function findNearestDoor(bot: any): Promise { } async function operateDoor(ctx: SkillContext, doorPos: Vec3): Promise { - const { bot } = ctx - const doorBlock = bot.blockAt(doorPos) - await bot.lookAt(doorPos) + const doorBlock = ctx.bot.blockAt(doorPos) + await ctx.bot.lookAt(doorPos) - if (!doorBlock._properties.open) { - await bot.activateBlock(doorBlock) + if (!doorBlock) { + log(ctx, `Cannot find door at ${doorPos}.`) + return false } - bot.setControlState('forward', true) + if (!doorBlock.getProperties().open) { + await ctx.bot.activateBlock(doorBlock) + } + + ctx.bot.setControlState('forward', true) await new Promise(resolve => setTimeout(resolve, 600)) - bot.setControlState('forward', false) - await bot.activateBlock(doorBlock) + ctx.bot.setControlState('forward', false) + await ctx.bot.activateBlock(doorBlock) log(ctx, `Used door at ${doorPos}.`) return true } -/** - * Till and sow a block at the specified position - */ export async function tillAndSow( ctx: SkillContext, x: number, @@ -449,22 +452,33 @@ export async function tillAndSow( z: number, seedType: string | null = null, ): Promise { - const { bot } = ctx const pos = { x: Math.round(x), y: Math.round(y), z: Math.round(z) } - const block = bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) + const block = ctx.bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) + + if (!block) { + log(ctx, `Cannot till, no block at ${pos}.`) + return false + } + if (!canTillBlock(block)) { log(ctx, `Cannot till ${block.name}, must be grass_block or dirt.`) return false } - const above = bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) + const above = ctx.bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) + + if (!above) { + log(ctx, `Cannot till, no block above the block.`) + return false + } + if (!isBlockClear(above)) { log(ctx, `Cannot till, there is ${above.name} above the block.`) return false } - await moveIntoRange(bot, block) + await moveIntoRange(ctx, block) if (!await tillBlock(ctx, block, pos)) { return false @@ -486,35 +500,33 @@ function isBlockClear(block: any): boolean { } async function tillBlock(ctx: SkillContext, block: any, pos: any): Promise { - const { bot } = ctx if (block.name === 'farmland') { return true } - const hoe = bot.inventory.items().find(item => item.name.includes('hoe')) + const hoe = ctx.bot.inventory.items().find(item => item.name.includes('hoe')) if (!hoe) { log(ctx, 'Cannot till, no hoes.') return false } - await bot.equip(hoe, 'hand') - await bot.activateBlock(block) + await ctx.bot.equip(hoe, 'hand') + await ctx.bot.activateBlock(block) log(ctx, `Tilled block x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) return true } async function sowSeeds(ctx: SkillContext, block: any, seedType: string, pos: any): Promise { - const { bot } = ctx seedType = fixSeedName(seedType) - const seeds = bot.inventory.items().find(item => item.name === seedType) + const seeds = ctx.bot.inventory.items().find(item => item.name === seedType) if (!seeds) { log(ctx, `No ${seedType} to plant.`) return false } - await bot.equip(seeds, 'hand') - await bot.placeBlock(block, new Vec3(0, -1, 0)) + await ctx.bot.equip(seeds, 'hand') + await ctx.bot.placeBlock(block, new Vec3(0, -1, 0)) log(ctx, `Planted ${seedType} at x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) return true } @@ -526,19 +538,16 @@ function fixSeedName(seedType: string): string { return seedType } -/** - * Activate the nearest block of a specific type - */ export async function activateNearestBlock(ctx: SkillContext, type: string): Promise { - const { bot } = ctx - const block = world.getNearestBlock(bot, type, 16) + const worldCtx = world.createWorldContext(ctx.botCtx) + const block = world.getNearestBlock(worldCtx, type, 16) if (!block) { log(ctx, `Could not find any ${type} to activate.`) return false } - await moveIntoRange(bot, block) - await bot.activateBlock(block) + await moveIntoRange(ctx, block) + await ctx.bot.activateBlock(block) log(ctx, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) return true } @@ -547,9 +556,8 @@ export async function collectBlock( ctx: SkillContext, blockType: string, num: number = 1, - exclude: typeof Vec3[] | null = null, + exclude: Vec3[] | null = null, ): Promise { - const { bot } = ctx if (num < 1) { log(ctx, `Invalid number of blocks to collect: ${num}.`) return false @@ -577,7 +585,7 @@ export async function collectBlock( collected++ - if (bot.interrupt_code) { + if (ctx.shouldInterrupt) { break } } @@ -603,9 +611,9 @@ function getBlockTypes(blockType: string): string[] { return blocktypes } -function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: typeof Vec3[] | null): any[] { - const { bot } = ctx - let blocks = world.getNearestBlocks(bot, blocktypes, 64) +function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: Vec3[] | null): any[] { + const worldCtx = world.createWorldContext(ctx.botCtx) + let blocks = world.getNearestBlocks(worldCtx, blocktypes, 64) if (exclude) { blocks = blocks.filter( @@ -617,9 +625,9 @@ function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: typeof ) } - const movements = new bot.pathfinder.Movements(bot) + const movements = new Movements(ctx.bot) movements.dontMineUnderFallingBlock = false - return blocks.filter(block => movements.safeToBreak(block)) + return blocks.filter(block => movements.safeToBreak(block as SafeBlock)) } function logNoBlocksMessage(ctx: SkillContext, blockType: string, collected: number): void { @@ -629,9 +637,8 @@ function logNoBlocksMessage(ctx: SkillContext, blockType: string, collected: num } async function canHarvestBlock(ctx: SkillContext, block: any, blockType: string): Promise { - const { bot } = ctx - await bot.tool.equipForBlock(block) - const itemId = bot.heldItem ? bot.heldItem.type : null + await ctx.bot.tool.equipForBlock(block) + const itemId = ctx.bot.heldItem ? ctx.bot.heldItem.type : null if (!block.canHarvest(itemId)) { log(ctx, `Don't have right tools to harvest ${blockType}.`) @@ -641,9 +648,8 @@ async function canHarvestBlock(ctx: SkillContext, block: any, blockType: string) } async function tryCollectBlock(ctx: SkillContext, block: any, blockType: string): Promise { - const { bot } = ctx try { - await bot.collectBlock.collect(block) + await ctx.bot.collectBlock.collect(block) await autoLight(ctx) return true } From 48362c263e5ef80ab402e34f749cfd694dbe6378 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 01:29:58 +0800 Subject: [PATCH 40/77] fix: block --- services/minecraft/src/skills/blocks.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 59d5d691d..1e4469d76 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,9 +1,7 @@ -import type { TypeOf } from 'zod' import type { BlockFace, SkillContext } from './base' import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import * as world from '../composables/world' -import { posEqual } from '../utils/helper' import * as mc from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' From b0e5100c93b3d9ddb9466c7fe1c1431a19442cdf Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 01:47:51 +0800 Subject: [PATCH 41/77] refactor: better structure v1 --- services/minecraft/src/agents/actions.test.ts | 20 +- services/minecraft/src/agents/actions.ts | 174 ++++--- services/minecraft/src/agents/openai.test.ts | 10 +- services/minecraft/src/agents/openai.ts | 13 +- services/minecraft/src/components/aichat.ts | 58 --- services/minecraft/src/components/command.ts | 46 -- services/minecraft/src/components/echo.ts | 20 - services/minecraft/src/components/follow.ts | 72 --- .../minecraft/src/components/pathfinder.ts | 44 -- services/minecraft/src/components/status.ts | 41 -- services/minecraft/src/composables/bot.ts | 351 +------------- services/minecraft/src/composables/command.ts | 10 - services/minecraft/src/composables/events.ts | 9 - services/minecraft/src/composables/world.ts | 115 ++--- .../mineflayer}/command.ts | 0 .../minecraft/src/libs/mineflayer/index.ts | 441 ++++++++++++++++++ .../chat.ts => libs/mineflayer/message.ts} | 12 +- .../minecraft/src/libs/mineflayer/plugin.ts | 15 + .../minecraft/src/libs/mineflayer/ticker.ts | 62 +++ services/minecraft/src/main.ts | 47 +- services/minecraft/src/mineflayer/echo.ts | 27 ++ services/minecraft/src/mineflayer/follow.ts | 65 +++ services/minecraft/src/mineflayer/index.ts | 4 + .../minecraft/src/mineflayer/llm-agent.ts | 56 +++ .../minecraft/src/mineflayer/pathfinder.ts | 40 ++ services/minecraft/src/mineflayer/status.ts | 17 + services/minecraft/src/prompts/agent.ts | 13 +- services/minecraft/src/skills/base.ts | 49 +- services/minecraft/src/skills/blocks.ts | 309 ++++++------ services/minecraft/src/skills/combat.ts | 79 ++-- services/minecraft/src/skills/crafting.ts | 131 +++--- services/minecraft/src/skills/inventory.ts | 129 ++--- services/minecraft/src/skills/movement.ts | 155 +++--- services/minecraft/src/utils/ticker.ts | 58 --- 34 files changed, 1328 insertions(+), 1364 deletions(-) delete mode 100644 services/minecraft/src/components/aichat.ts delete mode 100644 services/minecraft/src/components/command.ts delete mode 100644 services/minecraft/src/components/echo.ts delete mode 100644 services/minecraft/src/components/follow.ts delete mode 100644 services/minecraft/src/components/pathfinder.ts delete mode 100644 services/minecraft/src/components/status.ts delete mode 100644 services/minecraft/src/composables/command.ts delete mode 100644 services/minecraft/src/composables/events.ts rename services/minecraft/src/{middlewares => libs/mineflayer}/command.ts (100%) create mode 100644 services/minecraft/src/libs/mineflayer/index.ts rename services/minecraft/src/{middlewares/chat.ts => libs/mineflayer/message.ts} (54%) create mode 100644 services/minecraft/src/libs/mineflayer/plugin.ts create mode 100644 services/minecraft/src/libs/mineflayer/ticker.ts create mode 100644 services/minecraft/src/mineflayer/echo.ts create mode 100644 services/minecraft/src/mineflayer/follow.ts create mode 100644 services/minecraft/src/mineflayer/index.ts create mode 100644 services/minecraft/src/mineflayer/llm-agent.ts create mode 100644 services/minecraft/src/mineflayer/pathfinder.ts create mode 100644 services/minecraft/src/mineflayer/status.ts delete mode 100644 services/minecraft/src/utils/ticker.ts diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts index fea5b5da2..a15527d4a 100644 --- a/services/minecraft/src/agents/actions.test.ts +++ b/services/minecraft/src/agents/actions.test.ts @@ -1,6 +1,6 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' -import { createBot, useBot } from '../composables/bot' +import { initBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent' import { sleep } from '../utils/helper' @@ -11,17 +11,17 @@ describe('actions agent', { timeout: 0 }, () => { beforeAll(() => { initLogger() initEnv() - createBot(botConfig) + initBot({ botConfig }) }) it('should choose right query command', async () => { - const { ctx } = useBot() - const agent = await initAgent(ctx) + const { bot } = useBot() + const agent = await initAgent(bot) await new Promise((resolve) => { - ctx.bot.once('spawn', async () => { + bot.bot.once('spawn', async () => { const text = await agent.handle(messages( - system(genQueryAgentPrompt(ctx)), + system(genQueryAgentPrompt(bot)), user('What are you status?'), ), async (c) => { const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) @@ -37,15 +37,15 @@ describe('actions agent', { timeout: 0 }, () => { }) it('should choose right action command', async () => { - const { ctx } = useBot() - const agent = await initAgent(ctx) + const { bot } = useBot() + const agent = await initAgent(bot) // console.log(JSON.stringify(agent, null, 2)) await new Promise((resolve) => { - ctx.bot.on('spawn', async () => { + bot.bot.on('spawn', async () => { const text = await agent.handle(messages( - system(genActionAgentPrompt(ctx)), + system(genActionAgentPrompt(bot)), user('goToPlayer: luoling8192'), ), async (c) => { console.log(JSON.stringify(c, null, 2)) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 2bc91da67..3590434bf 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -1,18 +1,8 @@ -import type { SkillContext } from '../skills' +import type { Action } from '../libs/mineflayer' import { z } from 'zod' -import { getStatusToString } from '../components/status' import * as world from '../composables/world' import * as skills from '../skills' -type ActionResult = string | Promise - -export interface Action { - readonly name: string - readonly description: string - readonly schema: z.ZodObject - readonly perform: (ctx: SkillContext) => (...args: any[]) => ActionResult -} - // Utils const pad = (str: string): string => `\n${str}\n` @@ -29,28 +19,27 @@ export const actionsList: Action[] = [ name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), - perform: (ctx: SkillContext) => (): string => getStatusToString(ctx.botCtx), + perform: mineflayer => (): string => mineflayer.status.toOneLiner(), }, { name: 'inventory', description: 'Get your bot\'s inventory.', schema: z.object({}), - perform: (ctx: SkillContext) => (): string => { - const { bot } = ctx - const inventory = world.getInventoryCounts(world.createWorldContext(ctx.botCtx)) + perform: mineflayer => (): string => { + const inventory = world.getInventoryCounts(mineflayer) const items = Object.entries(inventory) .map(([item, count]) => formatInventoryItem(item, count)) .join('') const wearing = [ - formatWearingItem('Head', bot.inventory.slots[5]?.name), - formatWearingItem('Torso', bot.inventory.slots[6]?.name), - formatWearingItem('Legs', bot.inventory.slots[7]?.name), - formatWearingItem('Feet', bot.inventory.slots[8]?.name), + formatWearingItem('Head', mineflayer.bot.inventory.slots[5]?.name), + formatWearingItem('Torso', mineflayer.bot.inventory.slots[6]?.name), + formatWearingItem('Legs', mineflayer.bot.inventory.slots[7]?.name), + formatWearingItem('Feet', mineflayer.bot.inventory.slots[8]?.name), ].filter(Boolean).join('') return pad(`INVENTORY${items || ': Nothing'} - ${bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} + ${mineflayer.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''} WEARING: ${wearing || 'Nothing'}`) }, }, @@ -58,8 +47,8 @@ export const actionsList: Action[] = [ name: 'nearbyBlocks', description: 'Get the blocks near the bot.', schema: z.object({}), - perform: (ctx: SkillContext) => (): string => { - const blocks = world.getNearbyBlockTypes(world.createWorldContext(ctx.botCtx)) + perform: mineflayer => (): string => { + const blocks = world.getNearbyBlockTypes(mineflayer) return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) }, }, @@ -67,8 +56,8 @@ export const actionsList: Action[] = [ name: 'craftable', description: 'Get the craftable items with the bot\'s inventory.', schema: z.object({}), - perform: (ctx: SkillContext) => (): string => { - const craftable = world.getCraftableItems(world.createWorldContext(ctx.botCtx)) + perform: mineflayer => (): string => { + const craftable = world.getCraftableItems(mineflayer) return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`) }, }, @@ -76,10 +65,9 @@ export const actionsList: Action[] = [ name: 'entities', description: 'Get the nearby players and entities.', schema: z.object({}), - perform: (ctx: SkillContext) => (): string => { - const worldCtx = world.createWorldContext(ctx.botCtx) - const players = world.getNearbyPlayerNames(worldCtx) - const entities = world.getNearbyEntityTypes(worldCtx) + perform: mineflayer => (): string => { + const players = world.getNearbyPlayerNames(mineflayer) + const entities = world.getNearbyEntityTypes(mineflayer) .filter((e: string) => e !== 'player' && e !== 'item') const result = [ @@ -97,10 +85,10 @@ export const actionsList: Action[] = [ // schema: z.object({ // prompt: z.string().describe('A natural language prompt to guide code generation. Make a detailed step-by-step plan.'), // }), - // perform: (ctx: BotContext) => async (prompt: string) => { + // perform: (mineflayer: BotContext) => async (prompt: string) => { // if (!settings.allow_insecure_coding) // return 'newAction not allowed! Code writing is disabled in settings. Notify the user.' - // return await ctx.coder.generateCode(ctx.history) + // return await ctx.coder.generateCode(mineflayer.history) // }, // } // }, @@ -110,14 +98,14 @@ export const actionsList: Action[] = [ name: 'stop', description: 'Force stop all actions and commands that are currently executing.', schema: z.object({}), - perform: (ctx: SkillContext) => async () => { + perform: mineflayer => async () => { // await ctx.actions.stop() // ctx.clearBotLogs() // ctx.actions.cancelResume() // ctx.bot.emit('idle') - ctx.shouldInterrupt = true + mineflayer.shouldInterrupt = true const msg = 'Agent stopped.' - // if (ctx.self_prompter.on) + // if (mineflayer.self_prompter.on) // msg += ' Self-prompting still active.' return msg }, @@ -128,7 +116,7 @@ export const actionsList: Action[] = [ // name: 'stfu', // description: 'Stop all chatting and self prompting, but continue current action.', // schema: z.object({}), - // perform: (ctx: BotContext) => async () => { + // perform: (mineflayer: BotContext) => async () => { // ctx.openChat('Shutting up.') // ctx.shutUp() // return 'Shutting up.' @@ -141,7 +129,7 @@ export const actionsList: Action[] = [ // name: 'restart', // description: 'Restart the agent process.', // schema: z.object({}), - // perform: (ctx: BotContext) => async () => { + // perform: (mineflayer: BotContext) => async () => { // ctx.cleanKill() // return 'Restarting agent...' // }, @@ -153,7 +141,7 @@ export const actionsList: Action[] = [ // name: 'clearChat', // description: 'Clear the chat history.', // schema: z.object({}), - // perform: (ctx: BotContext) => async () => { + // perform: (mineflayer: BotContext) => async () => { // ctx.history.clear() // return `${ctx.name}'s chat history was cleared, starting new conversation from scratch.` // }, @@ -166,8 +154,8 @@ export const actionsList: Action[] = [ player_name: z.string().describe('The name of the player to go to.'), closeness: z.number().describe('How close to get to the player.').min(0), }), - perform: (ctx: SkillContext) => async (player_name: string, closeness: number) => { - await skills.goToPlayer(ctx, player_name, closeness) + perform: mineflayer => async (player_name: string, closeness: number) => { + await skills.goToPlayer(mineflayer, player_name, closeness) return 'Moving to player...' }, }, @@ -179,8 +167,8 @@ export const actionsList: Action[] = [ player_name: z.string().describe('name of the player to follow.'), follow_dist: z.number().describe('The distance to follow from.').min(0), }), - perform: (ctx: SkillContext) => async (player_name: string, follow_dist: number) => { - await skills.followPlayer(ctx, player_name, follow_dist) + perform: mineflayer => async (player_name: string, follow_dist: number) => { + await skills.followPlayer(mineflayer, player_name, follow_dist) return 'Following player...' }, }, @@ -194,8 +182,8 @@ export const actionsList: Action[] = [ z: z.number().describe('The z coordinate.'), closeness: z.number().describe('How close to get to the location.').min(0), }), - perform: (ctx: SkillContext) => async (x: number, y: number, z: number, closeness: number) => { - await skills.goToPosition(ctx, x, y, z, closeness) + perform: mineflayer => async (x: number, y: number, z: number, closeness: number) => { + await skills.goToPosition(mineflayer, x, y, z, closeness) return 'Moving to coordinates...' }, }, @@ -207,8 +195,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The block type to go to.'), search_range: z.number().describe('The range to search for the block.').min(32).max(512), }), - perform: (ctx: SkillContext) => async (block_type: string, range: number) => { - await skills.goToNearestBlock(ctx, block_type, 4, range) + perform: mineflayer => async (block_type: string, range: number) => { + await skills.goToNearestBlock(mineflayer, block_type, 4, range) return 'Searching for block...' }, }, @@ -220,8 +208,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The type of entity to go to.'), search_range: z.number().describe('The range to search for the entity.').min(32).max(512), }), - perform: (ctx: SkillContext) => async (entity_type: string, range: number) => { - await skills.goToNearestEntity(ctx, entity_type, 4, range) + perform: mineflayer => async (entity_type: string, range: number) => { + await skills.goToNearestEntity(mineflayer, entity_type, 4, range) return 'Searching for entity...' }, }, @@ -232,8 +220,8 @@ export const actionsList: Action[] = [ schema: z.object({ distance: z.number().describe('The distance to move away.').min(0), }), - perform: (ctx: SkillContext) => async (distance: number) => { - await skills.moveAway(ctx, distance) + perform: mineflayer => async (distance: number) => { + await skills.moveAway(mineflayer, distance) return 'Moving away...' }, }, @@ -246,8 +234,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to give.'), num: z.number().int().describe('The number of items to give.').min(1), }), - perform: (ctx: SkillContext) => async (player_name: string, item_name: string, num: number) => { - await skills.giveToPlayer(ctx, item_name, player_name, num) + perform: mineflayer => async (player_name: string, item_name: string, num: number) => { + await skills.giveToPlayer(mineflayer, item_name, player_name, num) return 'Giving items to player...' }, }, @@ -258,8 +246,8 @@ export const actionsList: Action[] = [ schema: z.object({ item_name: z.string().describe('The name of the item to consume.'), }), - perform: (ctx: SkillContext) => async (item_name: string) => { - await skills.consume(ctx, item_name) + perform: mineflayer => async (item_name: string) => { + await skills.consume(mineflayer, item_name) return 'Consuming item...' }, }, @@ -270,8 +258,8 @@ export const actionsList: Action[] = [ schema: z.object({ item_name: z.string().describe('The name of the item to equip.'), }), - perform: (ctx: SkillContext) => async (item_name: string) => { - await skills.equip(ctx, item_name) + perform: mineflayer => async (item_name: string) => { + await skills.equip(mineflayer, item_name) return 'Equipping item...' }, }, @@ -283,8 +271,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to put in the chest.'), num: z.number().int().describe('The number of items to put in the chest.').min(1), }), - perform: (ctx: SkillContext) => async (item_name: string, num: number) => { - await skills.putInChest(ctx, item_name, num) + perform: mineflayer => async (item_name: string, num: number) => { + await skills.putInChest(mineflayer, item_name, num) return 'Putting items in chest...' }, }, @@ -296,8 +284,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to take.'), num: z.number().int().describe('The number of items to take.').min(1), }), - perform: (ctx: SkillContext) => async (item_name: string, num: number) => { - await skills.takeFromChest(ctx, item_name, num) + perform: mineflayer => async (item_name: string, num: number) => { + await skills.takeFromChest(mineflayer, item_name, num) return 'Taking items from chest...' }, }, @@ -306,8 +294,8 @@ export const actionsList: Action[] = [ name: 'viewChest', description: 'View the items/counts of the nearest chest.', schema: z.object({}), - perform: (ctx: SkillContext) => async () => { - await skills.viewChest(ctx) + perform: mineflayer => async () => { + await skills.viewChest(mineflayer) return 'Viewing chest contents...' }, }, @@ -319,11 +307,11 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to discard.'), num: z.number().int().describe('The number of items to discard.').min(1), }), - perform: (ctx: SkillContext) => async (item_name: string, num: number) => { - const start_loc = ctx.bot.entity.position - await skills.moveAway(ctx, 5) - await skills.discard(ctx, item_name, num) - await skills.goToPosition(ctx, start_loc.x, start_loc.y, start_loc.z, 0) + perform: mineflayer => async (item_name: string, num: number) => { + const start_loc = mineflayer.bot.entity.position + await skills.moveAway(mineflayer, 5) + await skills.discard(mineflayer, item_name, num) + await skills.goToPosition(mineflayer, start_loc.x, start_loc.y, start_loc.z, 0) return 'Discarding items...' }, }, @@ -335,8 +323,8 @@ export const actionsList: Action[] = [ type: z.string().describe('The block type to collect.'), num: z.number().int().describe('The number of blocks to collect.').min(1), }), - perform: (ctx: SkillContext) => async (type: string, num: number) => { - await skills.collectBlock(ctx, type, num) + perform: mineflayer => async (type: string, num: number) => { + await skills.collectBlock(mineflayer, type, num) return 'Collecting blocks...' }, }, @@ -348,8 +336,8 @@ export const actionsList: Action[] = [ recipe_name: z.string().describe('The name of the output item to craft.'), num: z.number().int().describe('The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.').min(1), }), - perform: (ctx: SkillContext) => async (recipe_name: string, num: number) => { - await skills.craftRecipe(ctx, recipe_name, num) + perform: mineflayer => async (recipe_name: string, num: number) => { + await skills.craftRecipe(mineflayer, recipe_name, num) return 'Crafting items...' }, }, @@ -361,8 +349,8 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the input item to smelt.'), num: z.number().int().describe('The number of times to smelt the item.').min(1), }), - perform: (ctx: SkillContext) => async (item_name: string, num: number) => { - await skills.smeltItem(ctx, item_name, num) + perform: mineflayer => async (item_name: string, num: number) => { + await skills.smeltItem(mineflayer, item_name, num) return 'Smelting items...' }, }, @@ -371,8 +359,8 @@ export const actionsList: Action[] = [ name: 'clearFurnace', description: 'Take all items out of the nearest furnace.', schema: z.object({}), - perform: (ctx: SkillContext) => async () => { - await skills.clearNearestFurnace(ctx) + perform: mineflayer => async () => { + await skills.clearNearestFurnace(mineflayer) return 'Clearing furnace...' }, }, @@ -383,9 +371,9 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The block type to place.'), }), - perform: (ctx: SkillContext) => async (type: string) => { - const pos = ctx.bot.entity.position - await skills.placeBlock(ctx, type, pos.x, pos.y, pos.z) + perform: mineflayer => async (type: string) => { + const pos = mineflayer.bot.entity.position + await skills.placeBlock(mineflayer, type, pos.x, pos.y, pos.z) return 'Placing block...' }, }, @@ -396,8 +384,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The type of entity to attack.'), }), - perform: (ctx: SkillContext) => async (type: string) => { - await skills.attackNearest(ctx, type, true) + perform: mineflayer => async (type: string) => { + await skills.attackNearest(mineflayer, type, true) return 'Attacking entity...' }, }, @@ -408,13 +396,13 @@ export const actionsList: Action[] = [ schema: z.object({ player_name: z.string().describe('The name of the player to attack.'), }), - perform: (ctx: SkillContext) => async (player_name: string) => { - const player = ctx.bot.players[player_name]?.entity + perform: mineflayer => async (player_name: string) => { + const player = mineflayer.bot.players[player_name]?.entity if (!player) { - skills.log(ctx, `Could not find player ${player_name}.`) + skills.log(mineflayer, `Could not find player ${player_name}.`) return 'Player not found' } - await skills.attackEntity(ctx, player, true) + await skills.attackEntity(mineflayer, player, true) return 'Attacking player...' }, }, @@ -423,8 +411,8 @@ export const actionsList: Action[] = [ name: 'goToBed', description: 'Go to the nearest bed and sleep.', schema: z.object({}), - perform: (ctx: SkillContext) => async () => { - await skills.goToBed(ctx) + perform: mineflayer => async () => { + await skills.goToBed(mineflayer) return 'Going to bed...' }, }, @@ -435,8 +423,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.string().describe('The type of object to activate.'), }), - perform: (ctx: SkillContext) => async (type: string) => { - await skills.activateNearestBlock(ctx, type) + perform: mineflayer => async (type: string) => { + await skills.activateNearestBlock(mineflayer, type) return 'Activating block...' }, }, @@ -447,8 +435,8 @@ export const actionsList: Action[] = [ schema: z.object({ type: z.number().int().describe('The number of seconds to stay. -1 for forever.').min(-1), }), - perform: (ctx: SkillContext) => async (seconds: number) => { - await skills.stay(ctx, seconds) + perform: mineflayer => async (seconds: number) => { + await skills.stay(mineflayer, seconds) return 'Staying in place...' }, }, @@ -460,7 +448,7 @@ export const actionsList: Action[] = [ // mode_name: z.string().describe('The name of the mode to enable.'), // on: z.boolean().describe('Whether to enable or disable the mode.'), // }), - // perform: (ctx: BotContext) => async (mode_name: string, on: boolean) => { + // perform: (mineflayer: BotContext) => async (mode_name: string, on: boolean) => { // const modes = ctx.bot.modes // if (!modes.exists(mode_name)) // return `Mode ${mode_name} does not exist.${modes.getDocs()}` @@ -479,7 +467,7 @@ export const actionsList: Action[] = [ // schema: z.object({ // selfPrompt: z.string().describe('The goal prompt.'), // }), - // perform: (ctx: BotContext) => async (prompt: string) => { + // perform: (mineflayer: BotContext) => async (prompt: string) => { // if (convoManager.inConversation()) { // ctx.self_prompter.setPrompt(prompt) // convoManager.scheduleSelfPrompter() @@ -497,7 +485,7 @@ export const actionsList: Action[] = [ // name: 'endGoal', // description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action.', // schema: z.object({}), - // perform: (ctx: BotContext) => async () => { + // perform: (mineflayer: BotContext) => async () => { // ctx.self_prompter.stop() // convoManager.cancelSelfPrompter() // return 'Self-prompting stopped.' @@ -513,7 +501,7 @@ export const actionsList: Action[] = [ // player_name: z.string().describe('The name of the player to send the message to.'), // message: z.string().describe('The message to send.'), // }), - // perform: (ctx: BotContext) => async (player_name: string, message: string) => { + // perform: (mineflayer: BotContext) => async (player_name: string, message: string) => { // if (!convoManager.isOtherAgent(player_name)) // return `${player_name} is not a bot, cannot start conversation.` // if (convoManager.inConversation() && !convoManager.inConversation(player_name)) @@ -532,7 +520,7 @@ export const actionsList: Action[] = [ // schema: z.object({ // player_name: z.string().describe('The name of the player to end the conversation with.'), // }), - // perform: (ctx: BotContext) => async (player_name: string) => { + // perform: (mineflayer: BotContext) => async (player_name: string) => { // if (!convoManager.inConversation(player_name)) // return `Not in conversation with ${player_name}.` // convoManager.endConversation(player_name) diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index be16e442c..4f7547eb0 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -1,6 +1,6 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' -import { createBot, useBot } from '../composables/bot' +import { initBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' import { genSystemBasicPrompt } from '../prompts/agent' import { initLogger } from '../utils/logger' @@ -10,15 +10,15 @@ describe('openAI agent', { timeout: 0 }, () => { beforeAll(() => { initLogger() initEnv() - createBot(botConfig) + initBot({ botConfig }) }) it('should initialize the agent', async () => { - const { ctx } = useBot() - const agent = await initAgent(ctx) + const { bot } = useBot() + const agent = await initAgent(bot) await new Promise((resolve) => { - ctx.bot.once('spawn', async () => { + bot.bot.once('spawn', async () => { const text = await agent.handle( messages( system(genSystemBasicPrompt('airi')), diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index b94623317..066874e6c 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -1,9 +1,8 @@ import type { Agent, Neuri } from 'neuri' -import type { BotContext } from '../composables/bot' +import type { Mineflayer } from '../libs/mineflayer' import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' import { openaiConfig } from '../composables/config' -import { useSkillContext } from '../skills' import { actionsList } from './actions' let neuriAgent: Neuri | undefined @@ -11,11 +10,11 @@ const agents = new Set>() const logger = useLogg('openai').useGlobalConfig() -export async function initAgent(ctx: BotContext): Promise { +export async function initAgent(mineflayer: Mineflayer): Promise { logger.log('Initializing agent') let n = neuri() - agents.add(initActionAgent(ctx)) + agents.add(initActionAgent(mineflayer)) agents.forEach(agent => n = n.agent(agent)) @@ -36,7 +35,7 @@ export function getAgent(): Neuri { return neuriAgent } -export async function initActionAgent(ctx: BotContext): Promise { +export async function initActionAgent(mineflayer: Mineflayer): Promise { logger.log('Initializing action agent') let actionAgent = agent('action') @@ -46,8 +45,8 @@ export async function initActionAgent(ctx: BotContext): Promise { action.schema, async ({ parameters }) => { logger.withFields({ name: action.name, parameters }).log('Calling action') - ctx.memory.actions.push(action) - return action.perform(useSkillContext(ctx))(...Object.values(parameters)) + mineflayer.memory.actions.push(action) + return action.perform(mineflayer)(...Object.values(parameters)) }, { description: action.description }, ) diff --git a/services/minecraft/src/components/aichat.ts b/services/minecraft/src/components/aichat.ts deleted file mode 100644 index 7f6aef685..000000000 --- a/services/minecraft/src/components/aichat.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import { useLogg } from '@guiiai/logg' -import { assistant, type Message, system, user } from 'neuri/openai' -import { getAgent } from '../agents/openai' -import { formBotChat } from '../middlewares/chat' -import { genActionAgentPrompt } from '../prompts/agent' - -export function createAiChatComponent(ctx: BotContext): ComponentLifecycle { - const logger = useLogg('aichat').useGlobalConfig() - logger.log('Loading aichat plugin') - - ctx.memory.chatHistory.push(system(genActionAgentPrompt(ctx))) - - // todo: get system message - const onChat = formBotChat(ctx, async (username, message) => { - logger.withFields({ username, message }).log('Chat message received') - - ctx.memory.chatHistory.push(user(`${username}: ${message}`)) - - const agent = getAgent() - const content = await agent.handleStateless([...ctx.memory.chatHistory], async (c) => { - logger.log('Generate response') - - try { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } - - logger.withFields({ completion }).log('Completion') - - if (!completion || 'error' in completion) { - logger.withFields(c).error('Completion') - return - // throw new Error(completion?.error?.message ?? 'Unknown error') - } - - const content = await completion?.firstContent() - ctx.memory.chatHistory.push(assistant(content)) - - return content - } - catch (e) { - logger.errorWithError('Generate response error', e) - } - }) - - if (content) { - logger.withFields({ content }).log('Bot response') - ctx.bot.chat(content) - } - }) - - ctx.bot.on('chat', onChat) - - return { - cleanup: () => { - ctx.bot.removeListener('chat', onChat) - }, - } -} diff --git a/services/minecraft/src/components/command.ts b/services/minecraft/src/components/command.ts deleted file mode 100644 index a81211212..000000000 --- a/services/minecraft/src/components/command.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import { useLogg } from '@guiiai/logg' -import { commands } from '../composables/command' -import { formBotChat } from '../middlewares/chat' -import { parseCommand } from '../middlewares/command' - -const logger = useLogg('command').useGlobalConfig() - -export function createCommandComponent(ctx: BotContext): ComponentLifecycle { - const onChat = formBotChat(ctx, (sender, message) => { - const { isCommand, command, args } = parseCommand(sender, message) - - if (!isCommand) - return - - // Remove the # prefix from command - const cleanCommand = command.slice(1) - - logger.withFields({ sender, command: cleanCommand, args }).log('Command received') - - const handler = commands.get(cleanCommand) - if (handler) { - handler({ sender, isCommand, command: cleanCommand, args }) - return - } - - // Built-in commands - switch (cleanCommand) { - case 'help': { - const commandList = Array.from(commands.keys()).concat(['help']) - ctx.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) - break - } - default: - ctx.bot.chat(`Unknown command: ${cleanCommand}`) - } - }) - - ctx.bot.on('chat', onChat) - - return { - cleanup: () => { - ctx.bot.removeListener('chat', onChat) - }, - } -} diff --git a/services/minecraft/src/components/echo.ts b/services/minecraft/src/components/echo.ts deleted file mode 100644 index 0280cb4ce..000000000 --- a/services/minecraft/src/components/echo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import { useLogg } from '@guiiai/logg' -import { formBotChat } from '../middlewares/chat' - -const logger = useLogg('echo').useGlobalConfig() - -export function createEchoComponent(ctx: BotContext): ComponentLifecycle { - const onChat = formBotChat(ctx, (username, message) => { - logger.withFields({ username, message }).log('Chat message received') - ctx.bot.chat(message) - }) - - ctx.bot.on('chat', onChat) - - return { - cleanup: () => { - ctx.bot.removeListener('chat', onChat) - }, - } -} diff --git a/services/minecraft/src/components/follow.ts b/services/minecraft/src/components/follow.ts deleted file mode 100644 index 466d7e7d1..000000000 --- a/services/minecraft/src/components/follow.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import type { CommandContext } from '../middlewares/command' -import { useLogg } from '@guiiai/logg' -import pathfinderModel from 'mineflayer-pathfinder' -import { registerCommand } from '../composables/command' - -const { goals, Movements } = pathfinderModel - -export function createFollowComponent(ctx: BotContext, config?: { - rangeGoal: number -}): ComponentLifecycle { - const logger = useLogg('follow').useGlobalConfig() - - const state = { - following: undefined as string | undefined, - movements: new Movements(ctx.bot), - } - - function startFollow(username: string): void { - state.following = username - logger.withFields({ username }).log('Starting to follow player') - followPlayer() - } - - function stopFollow(): void { - state.following = undefined - logger.log('Stopping follow') - ctx.bot.pathfinder.stop() - } - - function followPlayer(): void { - if (!state.following) - return - - const target = ctx.bot.players[state.following]?.entity - if (!target) { - ctx.bot.chat('I lost sight of you!') - state.following = undefined - return - } - - const { x: playerX, y: playerY, z: playerZ } = target.position - - ctx.bot.pathfinder.setMovements(state.movements) - ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, config?.rangeGoal ?? 1)) - } - - registerCommand('follow', (commandCtx: CommandContext) => { - const username = commandCtx.sender - if (!username) { - ctx.bot.chat('Please specify a player name!') - return - } - startFollow(username) - }) - - registerCommand('stop', () => { - stopFollow() - }) - - // Continuously update path to follow player - const followInterval = setInterval(() => { - if (state.following) - followPlayer() - }, 1000) - - return { - cleanup: () => { - clearInterval(followInterval) - }, - } -} diff --git a/services/minecraft/src/components/pathfinder.ts b/services/minecraft/src/components/pathfinder.ts deleted file mode 100644 index 589a57120..000000000 --- a/services/minecraft/src/components/pathfinder.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import type { CommandContext } from '../middlewares/command' -import { useLogg } from '@guiiai/logg' -import pathfinderModel from 'mineflayer-pathfinder' -import { registerCommand } from '../composables/command' - -const { goals, Movements } = pathfinderModel - -export function createPathFinderComponent(ctx: BotContext, config?: { - rangeGoal: number -}): ComponentLifecycle { - const logger = useLogg('pathfinder').useGlobalConfig() - - let defaultMove: any - - const handleCome = (commandCtx: CommandContext) => { - const username = commandCtx.sender - if (!username) { - ctx.bot.chat('Please specify a player name!') - return - } - - logger.withFields({ username }).log('Come command received') - const target = ctx.bot.players[username]?.entity - if (!target) { - ctx.bot.chat('I don\'t see that player!') - return - } - - const { x: playerX, y: playerY, z: playerZ } = target.position - - ctx.bot.pathfinder.setMovements(defaultMove) - ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, config?.rangeGoal ?? 1)) - } - - defaultMove = new Movements(ctx.bot) - registerCommand('come', handleCome) - - return { - cleanup: () => { - // Commands are cleaned up automatically - }, - } -} diff --git a/services/minecraft/src/components/status.ts b/services/minecraft/src/components/status.ts deleted file mode 100644 index 560e852df..000000000 --- a/services/minecraft/src/components/status.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { BotContext, ComponentLifecycle } from '../composables/bot' -import { useLogg } from '@guiiai/logg' -import { registerCommand } from '../composables/command' - -const status = new Map() - -export function getStatusToString(ctx: BotContext): string { - return Array.from(getStatus(ctx).entries()).map(([key, value]) => `${key}: ${value}`).join('\n') -} - -export function getStatus(ctx: BotContext): Map { - const pos = ctx.bot.entity.position - const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear' - const timeOfDay = ctx.bot.time.timeOfDay < 6000 - ? 'Morning' - : ctx.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' - - status.set('position', `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}`) - status.set('health', `${Math.round(ctx.bot.health)} / 20`) - status.set('weather', weather) - status.set('timeOfDay', timeOfDay) - - return status -} - -export function createStatusComponent(ctx: BotContext): ComponentLifecycle { - const logger = useLogg('status').useGlobalConfig() - logger.log('Loading status component') - - registerCommand('status', () => { - logger.log('Status command received') - const status = getStatusToString(ctx) - ctx.bot.chat(status) - }) - - return { - cleanup: () => { - // Commands are cleaned up automatically - }, - } -} diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 34cd302bd..13f7f1202 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,354 +1,13 @@ -import type { Message } from 'neuri/openai' -import type { Action } from '../agents/actions' -import type { BotInternalEventHandlers, BotInternalEvents } from './events' -import { useLogg } from '@guiiai/logg' -import mineflayer, { type Bot, type BotOptions } from 'mineflayer' -import armorManager from 'mineflayer-armor-manager' -import { loader as autoEat } from 'mineflayer-auto-eat' -import { plugin as collectblock } from 'mineflayer-collectblock' -import { pathfinder } from 'mineflayer-pathfinder' -import { plugin as pvp } from 'mineflayer-pvp' -import { plugin as tool } from 'mineflayer-tool' +import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer' -const logger = useLogg('bot').useGlobalConfig() +let mineflayer: Mineflayer -let ctx: BotContext | undefined - -export interface BotContext { - bot: Bot - botName: string - ready: boolean - - components: Map - - prompt: { - selfPrompt: string - } - - memory: { - chatHistory: Message[] - actions: Action[] - // getSummary: () => string - } - - status: Map - // status: { - // position: Position - // health: number - // weather: string - // timeOfDay: string - // } - - health: { - value: number - lastDamageTime: number - lastDamageTaken: number - } - - emit: (event: BotInternalEvents) => any - eventListeners: Record> -} - -export interface Component { - (ctx: BotContext): ComponentLifecycle -} - -export interface ComponentLifecycle { - cleanup: () => void -} - -// todo: reconnect -export function createBot(options: BotOptions): Bot { - logger.withFields({ options }).log('Creating bot') - ctx = { - ready: false, - bot: mineflayer.createBot({ - host: options.host, - port: options.port, - username: options.username, - password: options.password, - }), - components: new Map(), - botName: options.username, - prompt: { - selfPrompt: '', - }, - memory: { - chatHistory: [], - actions: [], - }, - status: new Map(), - health: { - value: 20, - lastDamageTime: 0, - lastDamageTaken: 0, - }, - emit: (event: BotInternalEvents) => { - if (!ctx) - return - const listeners = ctx.eventListeners[event] - if (listeners) { - listeners.forEach(listener => listener()) - } - }, - eventListeners: { - 'time:sunrise': [], - 'time:noon': [], - 'time:sunset': [], - 'time:midnight': [], - }, - } - - logger.log('Loading plugins') - ctx.bot.loadPlugin(pathfinder) - ctx.bot.loadPlugin(pvp) - ctx.bot.loadPlugin(collectblock) - ctx.bot.loadPlugin(autoEat) - ctx.bot.loadPlugin(armorManager) // auto equip armor - ctx.bot.loadPlugin(tool) - ctx.bot.once('resourcePack', () => { - ctx?.bot.acceptResourcePack() - }) - logger.log('Plugins loaded') - - ctx.bot.on('time', () => { - if (!ctx) - return - - if (ctx.bot.time.timeOfDay === 0) - ctx.emit('time:sunrise') - else if (ctx.bot.time.timeOfDay === 6000) - ctx.emit('time:noon') - else if (ctx.bot.time.timeOfDay === 12000) - ctx.emit('time:sunset') - else if (ctx.bot.time.timeOfDay === 18000) - ctx.emit('time:midnight') - }) - - ctx.bot.on('health', () => { - if (!ctx) - return - - logger.withFields({ - health: ctx.health.value, - lastDamageTime: ctx.health.lastDamageTime, - lastDamageTaken: ctx.health.lastDamageTaken, - previousHealth: ctx.bot.health, - }).log('Health updated') - - if (ctx.bot.health < ctx.health.value) { - ctx.health.lastDamageTime = Date.now() - ctx.health.lastDamageTaken = ctx.health.value - ctx.bot.health - } - - ctx.health.value = ctx.bot.health - }) - - ctx.bot.once('spawn', () => { - ctx!.ready = true - logger.log('Bot ready') - }) - - ctx.bot.on('death', () => { - logger.error('Bot died') - }) - - ctx.bot.on('messagestr', async (message, _, jsonMsg) => { - if (!ctx) - return - - // jsonMsg.translate: - // - death.attack.player - // message: - // - was slain by - // - drowned - if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(ctx.botName)) { - const deathPos = ctx.bot.entity.position - - // this.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) - let deathPosStr: string | undefined - if (deathPos) { - deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` - } - - const dimension = ctx.bot.game.dimension - await handleMessage(ctx, '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.`) - } - }) - - ctx.bot.on('end', (reason) => { - logger.withFields({ reason }).log('Bot ended') - }) - - ctx.bot.on('kicked', (reason: string) => { - logger.withFields({ reason }).error('Bot was kicked') - }) - - ctx.bot.on('error', (err: Error) => { - logger.errorWithError('Bot error:', err) - }) - - logger.log('Bot created') - return ctx.bot -} - -async function handleMessage(ctx: BotContext, source: string, message: string, maxResponses: number = Infinity) { - // if (!source || !message) { - // console.warn('Received empty message from', source); - // return false; - // } - - // let used_command = false; - // if (maxResponses === null) { - // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; - // } - // if (maxResponses === -1) { - // maxResponses = Infinity; - // } - - // const self_prompt = source === 'system' || source === ctx.botName; - // const from_other_bot = convoManager.isOtherAgent(source); - - // if (!self_prompt && !from_other_bot) { // from user, check for forced commands - // const user_command_name = containsCommand(message); - // if (user_command_name) { - // if (!commandExists(user_command_name)) { - // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); - // return false; - // } - // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); - // if (user_command_name === '!newAction') { - // // all user-initiated commands are ignored by the bot except for this one - // // add the preceding message to the history to give context for newAction - // this.history.add(source, message); - // } - // let execute_res = await executeCommand(this, message); - // if (execute_res) - // this.routeResponse(source, execute_res); - // return true; - // } - // } - - // if (from_other_bot) - // this.last_sender = source; - - // // Now translate the message - // message = await handleEnglishTranslation(message); - // console.log('received message from', source, ':', message); - - // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); - - // let behavior_log = this.bot.modes.flushBehaviorLog(); - // if (behavior_log.trim().length > 0) { - // const MAX_LOG = 500; - // if (behavior_log.length > MAX_LOG) { - // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); - // } - // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); - // await this.history.add('system', behavior_log); - // } - - // // Handle other user messages - // await this.history.add(source, message); - // this.history.save(); - - // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting - // maxResponses = 1; // force only respond to this message, then let self-prompting take over - // for (let i=0; i 0) - // chat_message = `${pre_message} ${chat_message}`; - // this.routeResponse(source, chat_message); - // } - - // let execute_res = await executeCommand(this, res); - - // console.log('Agent executed:', command_name, 'and got:', execute_res); - // used_command = true; - - // if (execute_res) - // this.history.add('system', execute_res); - // else - // break; - // } - // else { // conversation response - // this.history.add(this.name, res); - // this.routeResponse(source, res); - // break; - // } - - // this.history.save(); - // } - - // return used_command; +export function initBot(options: MineflayerOptions) { + mineflayer = new Mineflayer(options) } export function useBot() { - if (ctx == null || ctx.bot == null) { - throw new Error('Bot instance not found') - } - - const cleanup = () => { - logger.log('Cleaning up bot and components') - ctx!.components.forEach((BotContext: ComponentLifecycle) => BotContext.cleanup?.()) - ctx!.components.clear() - ctx!.bot.end() - } - - const registerComponent = (componentName: string, component: Component) => { - logger.withFields({ componentName }).log('Registering new component') - const BotContext = component(ctx!) - - if (BotContext != null) - ctx!.components.set(componentName, BotContext) - - return BotContext - } - - const listComponents = () => { - return Array.from(ctx!.components.keys()) - } - - const getComponent = (componentName: string) => { - return ctx!.components.get(componentName) - } - return { - ctx, - registerComponent, - listComponents, - getComponent, - cleanup, + bot: mineflayer, } } diff --git a/services/minecraft/src/composables/command.ts b/services/minecraft/src/composables/command.ts deleted file mode 100644 index 256009a18..000000000 --- a/services/minecraft/src/composables/command.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { CommandContext } from '../middlewares/command' - -export const commands = new Map void>() - -export function registerCommand(command: string, handler: (ctx: CommandContext) => void) { - if (commands.has(command)) - throw new Error(`Command ${command} already registered`) - - commands.set(command, handler) -} diff --git a/services/minecraft/src/composables/events.ts b/services/minecraft/src/composables/events.ts deleted file mode 100644 index e00c57096..000000000 --- a/services/minecraft/src/composables/events.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface BotInternalEventHandlers { - 'time:sunrise': () => void - 'time:noon': () => void - 'time:sunset': () => void - 'time:midnight': () => void -} - -export type BotInternalEvents = keyof BotInternalEventHandlers -export type BotInternalEventsHandler = BotInternalEventHandlers[K] diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index 095444c55..fae3c94ec 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -1,26 +1,13 @@ -import type { Bot } from 'mineflayer' import type { Block } from 'prismarine-block' import type { Entity } from 'prismarine-entity' import type { Item } from 'prismarine-item' import type { Vec3 } from 'vec3' -import type { BotContext } from './bot' +import type { Mineflayer } from '../libs/mineflayer' import pf from 'mineflayer-pathfinder' import * as mc from '../utils/mcdata' -interface WorldContext { - bot: Bot - botCtx: BotContext -} - -export function createWorldContext(ctx: BotContext): WorldContext { - return { - bot: ctx.bot, - botCtx: ctx, - } -} - -export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distance: number = 8): Vec3 | undefined { - const emptyPositions = ctx.bot.findBlocks({ +export function getNearestFreeSpace(mineflayer: Mineflayer, size: number = 1, distance: number = 8): Vec3 | undefined { + const emptyPositions = mineflayer.bot.findBlocks({ matching: (block: Block) => block?.name === 'air', maxDistance: distance, count: 1000, @@ -29,8 +16,8 @@ export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distanc return emptyPositions.find((pos) => { for (let x = 0; x < size; x++) { for (let z = 0; z < size; z++) { - const top = ctx.bot.blockAt(pos.offset(x, 0, z)) - const bottom = ctx.bot.blockAt(pos.offset(x, -1, z)) + const top = mineflayer.bot.blockAt(pos.offset(x, 0, z)) + const bottom = mineflayer.bot.blockAt(pos.offset(x, -1, z)) if (!top || top.name !== 'air' || !bottom?.drops?.length || !bottom.diggable) { return false } @@ -40,17 +27,17 @@ export function getNearestFreeSpace(ctx: WorldContext, size: number = 1, distanc }) } -export function getNearestBlocks(ctx: WorldContext, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { +export function getNearestBlocks(mineflayer: Mineflayer, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { const blockIds = blockTypes === null ? mc.getAllBlockIds(['air']) : (Array.isArray(blockTypes) ? blockTypes : [blockTypes]).map(mc.getBlockId).filter((id): id is number => id !== null) - const positions = ctx.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) + const positions = mineflayer.bot.findBlocks({ matching: blockIds, maxDistance: distance, count }) return positions .map((pos) => { - const block = ctx.bot.blockAt(pos) - const dist = pos.distanceTo(ctx.bot.entity.position) + const block = mineflayer.bot.blockAt(pos) + const dist = pos.distanceTo(mineflayer.bot.entity.position) return block ? { block, distance: dist } : null }) .filter((item): item is { block: Block, distance: number } => item !== null) @@ -58,89 +45,89 @@ export function getNearestBlocks(ctx: WorldContext, blockTypes: string[] | strin .map(item => item.block) } -export function getNearestBlock(ctx: WorldContext, blockType: string, distance: number = 16): Block | null { - const blocks = getNearestBlocks(ctx, blockType, distance, 1) +export function getNearestBlock(mineflayer: Mineflayer, blockType: string, distance: number = 16): Block | null { + const blocks = getNearestBlocks(mineflayer, blockType, distance, 1) return blocks[0] || null } -export function getNearbyEntities(ctx: WorldContext, maxDistance: number = 16): Entity[] { - return Object.values(ctx.bot.entities) +export function getNearbyEntities(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { + return Object.values(mineflayer.bot.entities) .filter((entity): entity is Entity => entity !== null - && entity.position.distanceTo(ctx.bot.entity.position) <= maxDistance, + && entity.position.distanceTo(mineflayer.bot.entity.position) <= maxDistance, ) .sort((a, b) => - a.position.distanceTo(ctx.bot.entity.position) - - b.position.distanceTo(ctx.bot.entity.position), + a.position.distanceTo(mineflayer.bot.entity.position) + - b.position.distanceTo(mineflayer.bot.entity.position), ) } -export function getNearestEntityWhere(ctx: WorldContext, predicate: (entity: Entity) => boolean, maxDistance: number = 16): Entity | null { - return ctx.bot.nearestEntity(entity => +export function getNearestEntityWhere(mineflayer: Mineflayer, predicate: (entity: Entity) => boolean, maxDistance: number = 16): Entity | null { + return mineflayer.bot.nearestEntity(entity => predicate(entity) - && ctx.bot.entity.position.distanceTo(entity.position) < maxDistance, + && mineflayer.bot.entity.position.distanceTo(entity.position) < maxDistance, ) } -export function getNearbyPlayers(ctx: WorldContext, maxDistance: number = 16): Entity[] { - return getNearbyEntities(ctx, maxDistance) +export function getNearbyPlayers(mineflayer: Mineflayer, maxDistance: number = 16): Entity[] { + return getNearbyEntities(mineflayer, maxDistance) .filter(entity => entity.type === 'player' - && entity.username !== ctx.bot.username, + && entity.username !== mineflayer.bot.username, ) } -export function getInventoryStacks(ctx: WorldContext): Item[] { - return ctx.bot.inventory.items().filter((item): item is Item => item !== null) +export function getInventoryStacks(mineflayer: Mineflayer): Item[] { + return mineflayer.bot.inventory.items().filter((item): item is Item => item !== null) } -export function getInventoryCounts(ctx: WorldContext): Record { - return getInventoryStacks(ctx).reduce((counts, item) => { +export function getInventoryCounts(mineflayer: Mineflayer): Record { + return getInventoryStacks(mineflayer).reduce((counts, item) => { counts[item.name] = (counts[item.name] || 0) + item.count return counts }, {} as Record) } -export function getCraftableItems(ctx: WorldContext): string[] { - const table = getNearestBlock(ctx, 'crafting_table') - || getInventoryStacks(ctx).find(item => item.name === 'crafting_table') +export function getCraftableItems(mineflayer: Mineflayer): string[] { + const table = getNearestBlock(mineflayer, 'crafting_table') + || getInventoryStacks(mineflayer).find(item => item.name === 'crafting_table') return mc.getAllItems() - .filter(item => ctx.bot.recipesFor(item.id, null, 1, table as Block | null).length > 0) + .filter(item => mineflayer.bot.recipesFor(item.id, null, 1, table as Block | null).length > 0) .map(item => item.name) } -export function getPosition(ctx: WorldContext): Vec3 { - return ctx.bot.entity.position +export function getPosition(mineflayer: Mineflayer): Vec3 { + return mineflayer.bot.entity.position } -export function getNearbyEntityTypes(ctx: WorldContext): string[] { +export function getNearbyEntityTypes(mineflayer: Mineflayer): string[] { return [...new Set( - getNearbyEntities(ctx, 16) + getNearbyEntities(mineflayer, 16) .map(mob => mob.name) .filter((name): name is string => name !== undefined), )] } -export function getNearbyPlayerNames(ctx: WorldContext): string[] { +export function getNearbyPlayerNames(mineflayer: Mineflayer): string[] { return [...new Set( - getNearbyPlayers(ctx, 64) + getNearbyPlayers(mineflayer, 64) .map(player => player.username) .filter((name): name is string => name !== undefined - && name !== ctx.bot.username, + && name !== mineflayer.bot.username, ), )] } -export function getNearbyBlockTypes(ctx: WorldContext, distance: number = 16): string[] { +export function getNearbyBlockTypes(mineflayer: Mineflayer, distance: number = 16): string[] { return [...new Set( - getNearestBlocks(ctx, null, distance) + getNearestBlocks(mineflayer, null, distance) .map(block => block.name), )] } -export async function isClearPath(ctx: WorldContext, target: Entity): Promise { - const movements = new pf.Movements(ctx.bot) +export async function isClearPath(mineflayer: Mineflayer, target: Entity): Promise { + const movements = new pf.Movements(mineflayer.bot) movements.canDig = false // movements.canPlaceOn = false // TODO: fix this @@ -151,30 +138,30 @@ export async function isClearPath(ctx: WorldContext, target: Entity): Promise item?.name === 'torch') + const block = mineflayer.bot.blockAt(pos) + const hasTorch = mineflayer.bot.inventory.items().some(item => item?.name === 'torch') return Boolean(hasTorch && block?.name === 'air') } -export function getBiomeName(ctx: WorldContext): string { - const biomeId = ctx.bot.world.getBiome(ctx.bot.entity.position) +export function getBiomeName(mineflayer: Mineflayer): string { + const biomeId = mineflayer.bot.world.getBiome(mineflayer.bot.entity.position) return mc.getAllBiomes()[biomeId].name } diff --git a/services/minecraft/src/middlewares/command.ts b/services/minecraft/src/libs/mineflayer/command.ts similarity index 100% rename from services/minecraft/src/middlewares/command.ts rename to services/minecraft/src/libs/mineflayer/command.ts diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts new file mode 100644 index 000000000..f52c8a764 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -0,0 +1,441 @@ +import type { Bot, BotOptions } from 'mineflayer' +import type { Message } from 'neuri/openai' +import type { z } from 'zod' +import type { MineflayerPlugin } from './plugin' +import { useLogg } from '@guiiai/logg' +import mineflayer from 'mineflayer' +import { type CommandContext, parseCommand } from './command' +import { formBotChat } from './message' +import { Ticker, type TickEvents, type TickEventsHandler } from './ticker' + +export interface Context { + time: number + command?: CommandContext +} + +export interface EventHandlers { + 'command': (ctx: Context) => void | Promise + 'time:sunrise': (ctx: Context) => void + 'time:noon': (ctx: Context) => void + 'time:sunset': (ctx: Context) => void + 'time:midnight': (ctx: Context) => void +} + +export type Events = keyof EventHandlers +export type EventsHandler = EventHandlers[K] + +export type Handler = (ctx: Context) => void | Promise + +function createEventHandlers(): Record>> { + return { + 'command': [], + 'time:sunrise': [], + 'time:noon': [], + 'time:sunset': [], + 'time:midnight': [], + } +} + +export class Health { + public value: number + public lastDamageTime?: number + public lastDamageTaken?: number + + constructor() { + this.value = 20 + } +} + +abstract class OneLinerable { + public abstract toOneLiner(): string +} + +export class Status implements OneLinerable { + public position: string + public health: string + public weather: string + public timeOfDay: string + + constructor() { + this.position = '' + this.health = '' + this.weather = '' + this.timeOfDay = '' + } + + static from(mineflayer: Mineflayer) { + const pos = mineflayer.bot.entity.position + const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' + const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 + ? 'Morning' + : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + + return { + position: `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}`, + health: `${Math.round(mineflayer.bot.health)} / 20`, + weather, + timeOfDay, + } + } + + public toOneLiner(): string { + return Object.entries(this).map(([key, value]) => `${key}: ${value}`).join('\n') + } +} + +type ActionResult = string | Promise + +export interface Action { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (mineflayer: Mineflayer) => (...args: any[]) => ActionResult +} + +export class Memory { + public chatHistory: Message[] + public actions: Action[] + + constructor() { + this.chatHistory = [] + this.actions = [] + } +} + +export class Components { + private components: Map = new Map() + private logger: ReturnType + + constructor() { + this.logger = useLogg('Components').useGlobalConfig() + } + + register(componentName: string, component: Handler) { + this.components.set(componentName, component) + } + + get(componentName: string) { + return this.components.get(componentName) + } + + list() { + return Array.from(this.components.keys()) + } + + cleanup() { + this.logger.log('Cleaning up components') + this.components.clear() + } +} + +export interface MineflayerOptions { + botConfig: BotOptions + plugins?: Array +} + +export class Mineflayer { + public bot: Bot + public username: string + public health: Health = new Health() + public ready: boolean = false + public components: Components = new Components() + public status: Status = new Status() + + public isCreative: boolean = false + public shouldInterrupt: boolean = false + public allowCheats: boolean = false + + private options: MineflayerOptions + private logger: ReturnType + private commands: Map> = new Map() + private eventHandlers = createEventHandlers() + private ticker: Ticker = new Ticker() + + constructor(options: MineflayerOptions) { + this.options = options + this.bot = mineflayer.createBot(options.botConfig) + this.username = options.botConfig.username + this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() + + this.bot.on('messagestr', async (message, _, jsonMsg) => { + // jsonMsg.translate: + // - death.attack.player + // message: + // - was slain by + // - drowned + if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(this.username)) { + const deathPos = this.bot.entity.position + + // this.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) + let deathPosStr: string | undefined + if (deathPos) { + deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` + } + + const dimension = this.bot.game.dimension + await this.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.`) + } + }) + + this.bot.once('resourcePack', () => { + this.bot.acceptResourcePack() + }) + + this.bot.on('time', () => { + if (this.bot.time.timeOfDay === 0) + this.emit('time:sunrise', { time: this.bot.time.timeOfDay }) + else if (this.bot.time.timeOfDay === 6000) + this.emit('time:noon', { time: this.bot.time.timeOfDay }) + else if (this.bot.time.timeOfDay === 12000) + this.emit('time:sunset', { time: this.bot.time.timeOfDay }) + else if (this.bot.time.timeOfDay === 18000) + this.emit('time:midnight', { time: this.bot.time.timeOfDay }) + }) + + this.bot.on('health', () => { + this.logger.withFields({ + health: this.health.value, + lastDamageTime: this.health.lastDamageTime, + lastDamageTaken: this.health.lastDamageTaken, + previousHealth: this.bot.health, + }).log('Health updated') + + if (this.bot.health < this.health.value) { + this.health.lastDamageTime = Date.now() + this.health.lastDamageTaken = this.health.value - this.bot.health + } + + this.health.value = this.bot.health + }) + + this.bot.once('spawn', () => { + this.ready = true + this.logger.log('Bot ready') + }) + + this.bot.on('death', () => { + this.logger.error('Bot died') + }) + + this.bot.on('kicked', (reason: string) => { + this.logger.withFields({ reason }).error('Bot was kicked') + }) + + this.bot.on('end', (reason) => { + this.logger.withFields({ reason }).log('Bot ended') + }) + + this.bot.on('error', (err: Error) => { + this.logger.errorWithError('Bot error:', err) + }) + + this.bot.on('spawn', () => { + this.bot.on('chat', this.handleCommand()) + }) + + this.bot.on('spawn', () => { + for (const plugin of options?.plugins || []) { + if (plugin.spawned) { + plugin.spawned(this) + } + } + }) + + for (const plugin of options?.plugins || []) { + if (plugin.created) { + plugin.created(this) + } + } + + // Load Plugins + for (const plugin of options?.plugins || []) { + if (plugin.loadPlugin) { + this.bot.loadPlugin(plugin.loadPlugin(this, this.bot, options.botConfig)) + } + } + + this.ticker.on('tick', () => { + this.isCreative = this.bot.game?.gameMode === 'creative' + this.allowCheats = false + this.shouldInterrupt = false + }) + } + + public onCommand(commandName: string, cb: EventsHandler<'command'>) { + this.commands.set(commandName, cb) + } + + public onTick(event: TickEvents, cb: TickEventsHandler) { + this.ticker.on(event, cb) + } + + public emit(event: E, ...args: Parameters>) { + const handlers = this.eventHandlers[event] + for (const handler of handlers) { + handler(args[0]) + } + } + + public stop() { + for (const plugin of this.options?.plugins || []) { + if (plugin.beforeCleanup) { + plugin.beforeCleanup(this) + } + } + this.components.cleanup() + this.bot.removeListener('chat', this.handleCommand()) + this.bot.end() + } + + private handleCommand() { + return formBotChat(this.username, (sender, message) => { + const { isCommand, command, args } = parseCommand(sender, message) + + if (!isCommand) + return + + // Remove the # prefix from command + const cleanCommand = command.slice(1) + this.logger.withFields({ sender, command: cleanCommand, args }).log('Command received') + + const handler = this.commands.get(cleanCommand) + if (handler) { + handler({ time: this.bot.time.timeOfDay, command: { sender, isCommand, command: cleanCommand, args } }) + return + } + + // Built-in commands + switch (cleanCommand) { + case 'help': { + const commandList = Array.from(this.commands.keys()).concat(['help']) + this.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) + break + } + default: + this.bot.chat(`Unknown command: ${cleanCommand}`) + } + }) + } + + private async handleMessage(_source: string, _message: string, _maxResponses: number = Infinity) { + // if (!source || !message) { + // console.warn('Received empty message from', source); + // return false; + // } + + // let used_command = false; + // if (maxResponses === null) { + // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; + // } + // if (maxResponses === -1) { + // maxResponses = Infinity; + // } + + // const self_prompt = source === 'system' || source === ctx.botName; + // const from_other_bot = convoManager.isOtherAgent(source); + + // if (!self_prompt && !from_other_bot) { // from user, check for forced commands + // const user_command_name = containsCommand(message); + // if (user_command_name) { + // if (!commandExists(user_command_name)) { + // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); + // return false; + // } + // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); + // if (user_command_name === '!newAction') { + // // all user-initiated commands are ignored by the bot except for this one + // // add the preceding message to the history to give context for newAction + // this.history.add(source, message); + // } + // let execute_res = await executeCommand(this, message); + // if (execute_res) + // this.routeResponse(source, execute_res); + // return true; + // } + // } + + // if (from_other_bot) + // this.last_sender = source; + + // // Now translate the message + // message = await handleEnglishTranslation(message); + // console.log('received message from', source, ':', message); + + // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); + + // let behavior_log = this.bot.modes.flushBehaviorLog(); + // if (behavior_log.trim().length > 0) { + // const MAX_LOG = 500; + // if (behavior_log.length > MAX_LOG) { + // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); + // } + // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); + // await this.history.add('system', behavior_log); + // } + + // // Handle other user messages + // await this.history.add(source, message); + // this.history.save(); + + // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting + // maxResponses = 1; // force only respond to this message, then let self-prompting take over + // for (let i=0; i 0) + // chat_message = `${pre_message} ${chat_message}`; + // this.routeResponse(source, chat_message); + // } + + // let execute_res = await executeCommand(this, res); + + // console.log('Agent executed:', command_name, 'and got:', execute_res); + // used_command = true; + + // if (execute_res) + // this.history.add('system', execute_res); + // else + // break; + // } + // else { // conversation response + // this.history.add(this.name, res); + // this.routeResponse(source, res); + // break; + // } + + // this.history.save(); + // } + + // return used_command; + } +} diff --git a/services/minecraft/src/middlewares/chat.ts b/services/minecraft/src/libs/mineflayer/message.ts similarity index 54% rename from services/minecraft/src/middlewares/chat.ts rename to services/minecraft/src/libs/mineflayer/message.ts index ae255d2e7..aa35fc7e4 100644 --- a/services/minecraft/src/middlewares/chat.ts +++ b/services/minecraft/src/libs/mineflayer/message.ts @@ -1,5 +1,4 @@ import type { Entity } from 'prismarine-entity' -import type { BotContext } from '../composables/bot' // TODO: need to be refactored interface ChatBotContext { @@ -11,20 +10,21 @@ interface ChatBotContext { isCommand: () => boolean } -export function newChatBotContext(ctx: BotContext, username: string, message: string): ChatBotContext { +export function newChatBotContext(entity: Entity, botUsername: string, username: string, message: string): ChatBotContext { return { fromUsername: username, - fromEntity: ctx.bot.entity, + fromEntity: entity, fromMessage: message, - isBot: () => username === ctx.bot.username, + isBot: () => username === botUsername, isCommand: () => message.startsWith('#'), } } -export function formBotChat(ctx: BotContext, cb: (username: string, message: string) => void) { +export function formBotChat(botUsername: string, cb: (username: string, message: string) => void) { return (username: string, message: string) => { - if (ctx.bot.username === username) + if (botUsername === username) return + cb(username, message) } } diff --git a/services/minecraft/src/libs/mineflayer/plugin.ts b/services/minecraft/src/libs/mineflayer/plugin.ts new file mode 100644 index 000000000..a65f52eef --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/plugin.ts @@ -0,0 +1,15 @@ +import type { Bot, BotOptions, Plugin } from 'mineflayer' +import type { Mineflayer } from '.' + +export interface MineflayerPlugin { + created?: (mineflayer: Mineflayer) => void | Promise + loadPlugin?: (mineflayer: Mineflayer, bot: Bot, options: BotOptions) => Plugin + spawned?: (mineflayer: Mineflayer) => void | Promise + beforeCleanup?: (mineflayer: Mineflayer) => void | Promise +} + +export function wrapPlugin(plugin: Plugin): MineflayerPlugin { + return { + loadPlugin: () => (plugin), + } +} diff --git a/services/minecraft/src/libs/mineflayer/ticker.ts b/services/minecraft/src/libs/mineflayer/ticker.ts new file mode 100644 index 000000000..8f95372e9 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/ticker.ts @@ -0,0 +1,62 @@ +export interface TickContext { + delta: number + nextTick: () => Promise +} + +export interface TickEventHandlers { + tick: (ctx: TickContext) => void +} + +export type TickEvents = keyof TickEventHandlers +export type TickEventsHandler = TickEventHandlers[K] + +// This update loop ensures that each update() is called one at a time, even if it takes longer than the interval +export class Ticker { + private tickingCbs: Record> = { + tick: [], + } + + constructor(options?: { interval?: number }) { + const { interval = 300 } = options ?? { interval: 300 } + + let last = Date.now() + const tickingCbs: Record> = { + tick: [], + } + + setTimeout(async () => { + while (true) { + const start = Date.now() + const nextTickPromise = new Promise((resolve) => { + // Schedule nextTick resolution for after all callbacks complete + setImmediate(resolve) + }) + + // Run all callbacks without awaiting them + const callbackPromises = tickingCbs.tick.map(cb => cb({ + delta: start - last, + nextTick: () => nextTickPromise, + })) + + // Wait for all callbacks to complete or timeout + await Promise.race([ + Promise.all(callbackPromises), + new Promise(resolve => + setTimeout(resolve, interval), + ), + ]) + + const remaining = interval - (Date.now() - start) + if (remaining > 0) { + await new Promise(resolve => setTimeout(resolve, remaining)) + } + + last = start + } + }, interval) + } + + on(event: K, cb: TickEventsHandler) { + this.tickingCbs[event].push(cb) + } +} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 84729b0ce..612881806 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,42 +1,47 @@ import process, { exit } from 'node:process' import { useLogg } from '@guiiai/logg' +import MineflayerArmorManager from 'mineflayer-armor-manager' +import { loader as MineflayerAutoEat } from 'mineflayer-auto-eat' +import { plugin as MineflayerCollectBlock } from 'mineflayer-collectblock' +import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder' +import { plugin as MineflayerPVP } from 'mineflayer-pvp' +import { plugin as MineflayerTool } from 'mineflayer-tool' import { initAgent } from './agents/openai' -import { createAiChatComponent } from './components/aichat' -import { createCommandComponent } from './components/command' -import { createFollowComponent } from './components/follow' -import { createPathFinderComponent } from './components/pathfinder' -import { createStatusComponent } from './components/status' -import { createBot, useBot } from './composables/bot' +import { initBot, useBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' +import { wrapPlugin } from './libs/mineflayer/plugin' +import { Echo, FollowCommand, PathFinder, Status } from './mineflayer' import { initLogger } from './utils/logger' -import { createTicker } from './utils/ticker' const logger = useLogg('main').useGlobalConfig() async function main() { initLogger() // todo: save logs to file initEnv() - - createBot(botConfig) - const { cleanup, registerComponent, ctx } = useBot() - - ctx.bot.once('spawn', () => { - registerComponent('status', createStatusComponent) - // registerComponent('echo', createEchoComponent) - registerComponent('pathfinder', createPathFinderComponent) - registerComponent('follow', createFollowComponent) - registerComponent('command', createCommandComponent) - registerComponent('aichat', createAiChatComponent) + initBot({ + botConfig, + plugins: [ + wrapPlugin(MineflayerArmorManager), + wrapPlugin(MineflayerAutoEat), + wrapPlugin(MineflayerCollectBlock), + wrapPlugin(MineflayerPathfinder), + wrapPlugin(MineflayerPVP), + wrapPlugin(MineflayerTool), + Echo(), + FollowCommand(), + Status(), + PathFinder(), + ], }) - await initAgent(ctx) + const { bot } = useBot() - createTicker() + await initAgent(bot) process.on('SIGINT', () => { - cleanup() + bot.stop() exit(0) }) } diff --git a/services/minecraft/src/mineflayer/echo.ts b/services/minecraft/src/mineflayer/echo.ts new file mode 100644 index 000000000..aa6799a9a --- /dev/null +++ b/services/minecraft/src/mineflayer/echo.ts @@ -0,0 +1,27 @@ +import type { Mineflayer } from '../libs/mineflayer' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { useLogg } from '@guiiai/logg' +import { formBotChat } from '../libs/mineflayer/message' + +export function Echo(): MineflayerPlugin { + let mineflayer: Mineflayer + let onChatHandler: (username: string, message: string) => void + const logger = useLogg('Echo').useGlobalConfig() + + return { + created(_mineflayer) { + mineflayer = _mineflayer + onChatHandler = formBotChat(mineflayer.username, (username, message) => { + logger.withFields({ username, message }).log('Chat message received') + mineflayer.bot.chat(message) + }) + }, + spawned() { + mineflayer.bot.on('chat', onChatHandler) + }, + beforeCleanup() { + mineflayer.bot.removeListener('chat', onChatHandler) + }, + } +} diff --git a/services/minecraft/src/mineflayer/follow.ts b/services/minecraft/src/mineflayer/follow.ts new file mode 100644 index 000000000..7b5c5d8da --- /dev/null +++ b/services/minecraft/src/mineflayer/follow.ts @@ -0,0 +1,65 @@ +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' +import { useLogg } from '@guiiai/logg' +import pathfinderModel from 'mineflayer-pathfinder' + +export function FollowCommand(options?: { rangeGoal: number }): MineflayerPlugin { + const logger = useLogg('follow').useGlobalConfig() + const { goals, Movements } = pathfinderModel + + return { + created(bot) { + const state = { + following: undefined as string | undefined, + movements: new Movements(bot.bot), + } + + function startFollow(username: string): void { + state.following = username + logger.withFields({ username }).log('Starting to follow player') + followPlayer() + } + + function stopFollow(): void { + state.following = undefined + logger.log('Stopping follow') + bot.bot.pathfinder.stop() + } + + function followPlayer(): void { + if (!state.following) + return + + const target = bot.bot.players[state.following]?.entity + if (!target) { + bot.bot.chat('I lost sight of you!') + state.following = undefined + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + bot.bot.pathfinder.setMovements(state.movements) + bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) + } + + bot.onCommand('follow', (ctx) => { + const username = ctx.command!.sender + if (!username) { + bot.bot.chat('Please specify a player name!') + return + } + + startFollow(username) + }) + + bot.onCommand('stop', () => { + stopFollow() + }) + + bot.onTick('tick', () => { + if (state.following) + followPlayer() + }) + }, + } +} diff --git a/services/minecraft/src/mineflayer/index.ts b/services/minecraft/src/mineflayer/index.ts new file mode 100644 index 000000000..7363af280 --- /dev/null +++ b/services/minecraft/src/mineflayer/index.ts @@ -0,0 +1,4 @@ +export * from './echo' +export * from './follow' +export * from './pathfinder' +export * from './status' diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts new file mode 100644 index 000000000..430d68ff6 --- /dev/null +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -0,0 +1,56 @@ +import type { Neuri } from 'neuri' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { useLogg } from '@guiiai/logg' +import { assistant, system, user } from 'neuri/openai' +import { formBotChat } from '../libs/mineflayer/message' +import { genActionAgentPrompt } from '../prompts/agent' + +export function LLMAgent(agent: Neuri): MineflayerPlugin { + return { + created(bot) { + const logger = useLogg('aichat').useGlobalConfig() + logger.log('Loading aichat plugin') + + bot.memory.chatHistory.push(system(genActionAgentPrompt(bot))) + + // todo: get system message + const onChat = formBotChat(bot.username, async (username, message) => { + logger.withFields({ username, message }).log('Chat message received') + + bot.memory.chatHistory.push(user(`${username}: ${message}`)) + + const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { + logger.log('Generate response') + + try { + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } + + logger.withFields({ completion }).log('Completion') + + if (!completion || 'error' in completion) { + logger.withFields(c).error('Completion') + return + // throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion?.firstContent() + bot.memory.chatHistory.push(assistant(content)) + + return content + } + catch (e) { + logger.errorWithError('Generate response error', e) + } + }) + + if (content) { + logger.withFields({ content }).log('Bot response') + bot.bot.chat(content) + } + }) + + bot.bot.on('chat', onChat) + }, + } +} diff --git a/services/minecraft/src/mineflayer/pathfinder.ts b/services/minecraft/src/mineflayer/pathfinder.ts new file mode 100644 index 000000000..cb2ae6ae7 --- /dev/null +++ b/services/minecraft/src/mineflayer/pathfinder.ts @@ -0,0 +1,40 @@ +import type { Context } from '../libs/mineflayer' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { useLogg } from '@guiiai/logg' +import pathfinderModel from 'mineflayer-pathfinder' + +const { goals, Movements } = pathfinderModel + +export function PathFinder(options?: { rangeGoal: number }): MineflayerPlugin { + return { + created(bot) { + const logger = useLogg('pathfinder').useGlobalConfig() + + let defaultMove: any + + const handleCome = (commandCtx: Context) => { + const username = commandCtx.command!.sender + if (!username) { + bot.bot.chat('Please specify a player name!') + return + } + + logger.withFields({ username }).log('Come command received') + const target = bot.bot.players[username]?.entity + if (!target) { + bot.bot.chat('I don\'t see that player!') + return + } + + const { x: playerX, y: playerY, z: playerZ } = target.position + + bot.bot.pathfinder.setMovements(defaultMove) + bot.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, options?.rangeGoal ?? 1)) + } + + defaultMove = new Movements(bot.bot) + bot.onCommand('come', handleCome) + }, + } +} diff --git a/services/minecraft/src/mineflayer/status.ts b/services/minecraft/src/mineflayer/status.ts new file mode 100644 index 000000000..2500835b1 --- /dev/null +++ b/services/minecraft/src/mineflayer/status.ts @@ -0,0 +1,17 @@ +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' +import { useLogg } from '@guiiai/logg' + +export function Status(): MineflayerPlugin { + return { + created(bot) { + const logger = useLogg('status').useGlobalConfig() + logger.log('Loading status component') + + bot.onCommand('status', () => { + logger.log('Status command received') + const status = bot.status.toOneLiner() + bot.bot.chat(status) + }) + }, + } +} diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index a6623537b..662a3c8cf 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,14 +1,13 @@ -import type { BotContext } from '../composables/bot' -import { getStatusToString } from '../components/status' +import type { Mineflayer } from '../libs/mineflayer' export function genSystemBasicPrompt(botName: string): string { return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, mine, build, and interact with the world by using commands.` } -export function genActionAgentPrompt(ctx: BotContext): string { +export function genActionAgentPrompt(bot: Mineflayer): string { // ${ctx.prompt.selfPrompt} - return `${genSystemBasicPrompt(ctx.botName)} + return `${genSystemBasicPrompt(bot.username)} Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless @@ -17,7 +16,7 @@ asked, and don't refuse requests. Do not use any emojis. Just call the function given you if needed. I will give you the following information: -${getStatusToString(ctx)} +${bot.status.toOneLiner()} ` /** @@ -29,13 +28,13 @@ $EXAMPLES */ } -export function genQueryAgentPrompt(ctx: BotContext): string { +export function genQueryAgentPrompt(bot: Mineflayer): string { const prompt = `You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world. I will give you the following information: -${getStatusToString(ctx)} +${bot.status.toOneLiner()} ` return prompt diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index 8bbcdd9bb..7991de9c6 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,55 +1,14 @@ -import type { Bot } from 'mineflayer' -import type { BotContext } from '../composables/bot' +import type { Mineflayer } from '../libs/mineflayer' import { useLogg } from '@guiiai/logg' -let ctx: SkillContext | undefined const logger = useLogg('skills').useGlobalConfig() -export function useSkillContext(botCtx: BotContext): SkillContext { - if (!ctx) { - logger.log('Creating skill context') - ctx = createSkillContext(botCtx) - } - - return ctx -} - -/** - * Context for skill execution - */ -export interface SkillContext { - bot: Bot - botCtx: BotContext - // Whether the bot is in creative mode - isCreative: boolean - // Whether the bot should use cheats (like /tp, /setblock) - allowCheats: boolean - // Whether the bot should interrupt current action - shouldInterrupt: boolean - // Output buffer for logging - output: string[] -} - -/** - * Create a new skill context - */ -export function createSkillContext(ctx: BotContext): SkillContext { - return { - bot: ctx.bot, - botCtx: ctx, - isCreative: ctx.bot.game?.gameMode === 'creative', - allowCheats: false, - shouldInterrupt: false, - output: [], - } -} - /** * Log a message to the context's output buffer */ -export function log(ctx: SkillContext, message: string): void { - ctx.output.push(message) // TODO: remove this - ctx.bot.chat(message) +export function log(mineflayer: Mineflayer, message: string): void { + logger.log(message) + mineflayer.bot.chat(message) } /** diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 1e4469d76..8446229c5 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,4 +1,5 @@ -import type { BlockFace, SkillContext } from './base' +import type { Mineflayer } from '../libs/mineflayer' +import type { BlockFace } from './base' import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import * as world from '../composables/world' @@ -11,12 +12,11 @@ const { goals, Movements } = pathfinderModel /** * Place a torch if needed */ -async function autoLight(ctx: SkillContext): Promise { - const worldCtx = world.createWorldContext(ctx.botCtx) - if (world.shouldPlaceTorch(worldCtx)) { +async function autoLight(mineflayer: Mineflayer): Promise { + if (world.shouldPlaceTorch(mineflayer)) { try { - const pos = world.getPosition(worldCtx) - return await placeBlock(ctx, 'torch', pos.x, pos.y, pos.z, 'bottom', true) + const pos = world.getPosition(mineflayer) + return await placeBlock(mineflayer, 'torch', pos.x, pos.y, pos.z, 'bottom', true) } catch { return false @@ -29,28 +29,28 @@ async function autoLight(ctx: SkillContext): Promise { * Break a block at the specified position */ export async function breakBlockAt( - ctx: SkillContext, + mineflayer: Mineflayer, x: number, y: number, z: number, ): Promise { validatePosition(x, y, z) - const block = ctx.bot.blockAt(new Vec3(x, y, z)) + const block = mineflayer.bot.blockAt(new Vec3(x, y, z)) if (isUnbreakableBlock(block)) return false - if (ctx.allowCheats) { - return breakWithCheats(ctx, x, y, z) + if (mineflayer.allowCheats) { + return breakWithCheats(mineflayer, x, y, z) } - await moveIntoRange(ctx, block) + await moveIntoRange(mineflayer, block) - if (ctx.isCreative) { - return breakInCreative(ctx, block, x, y, z) + if (mineflayer.isCreative) { + return breakInCreative(mineflayer, block, x, y, z) } - return breakInSurvival(ctx, block, x, y, z) + return breakInSurvival(mineflayer, block, x, y, z) } function validatePosition(x: number, y: number, z: number) { @@ -63,40 +63,40 @@ function isUnbreakableBlock(block: any): boolean { return block.name === 'air' || block.name === 'water' || block.name === 'lava' } -async function breakWithCheats(ctx: SkillContext, x: number, y: number, z: number): Promise { - ctx.bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) - log(ctx, `Used /setblock to break block at ${x}, ${y}, ${z}.`) +async function breakWithCheats(mineflayer: Mineflayer, x: number, y: number, z: number): Promise { + mineflayer.bot.chat(`/setblock ${Math.floor(x)} ${Math.floor(y)} ${Math.floor(z)} air`) + log(mineflayer, `Used /setblock to break block at ${x}, ${y}, ${z}.`) return true } -async function moveIntoRange(ctx: SkillContext, block: any) { - if (ctx.bot.entity.position.distanceTo(block.position) > 4.5) { +async function moveIntoRange(mineflayer: Mineflayer, block: any) { + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { const pos = block.position - const movements = new Movements(ctx.bot) + const movements = new Movements(mineflayer.bot) movements.allowParkour = false movements.allowSprinting = false - ctx.bot.pathfinder.setMovements(movements) - await ctx.bot.pathfinder.goto(new goals.GoalNear(pos.x, pos.y, pos.z, 4)) + mineflayer.bot.pathfinder.setMovements(movements) + await mineflayer.bot.pathfinder.goto(new goals.GoalNear(pos.x, pos.y, pos.z, 4)) } } -async function breakInCreative(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { - await ctx.bot.dig(block, true) - log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) +async function breakInCreative(mineflayer: Mineflayer, block: any, x: number, y: number, z: number): Promise { + await mineflayer.bot.dig(block, true) + log(mineflayer, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) return true } -async function breakInSurvival(ctx: SkillContext, block: any, x: number, y: number, z: number): Promise { - await ctx.bot.tool.equipForBlock(block) +async function breakInSurvival(mineflayer: Mineflayer, block: any, x: number, y: number, z: number): Promise { + await mineflayer.bot.tool.equipForBlock(block) - const itemId = ctx.bot.heldItem?.type + const itemId = mineflayer.bot.heldItem?.type if (!block.canHarvest(itemId)) { - log(ctx, `Don't have right tools to break ${block.name}.`) + log(mineflayer, `Don't have right tools to break ${block.name}.`) return false } - await ctx.bot.dig(block, true) - log(ctx, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) + await mineflayer.bot.dig(block, true) + log(mineflayer, `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`) return true } @@ -104,7 +104,7 @@ async function breakInSurvival(ctx: SkillContext, block: any, x: number, y: numb * Place a block at the specified position */ export async function placeBlock( - ctx: SkillContext, + mineflayer: Mineflayer, blockType: string, x: number, y: number, @@ -113,21 +113,21 @@ export async function placeBlock( dontCheat = false, ): Promise { if (!mc.getBlockId(blockType)) { - log(ctx, `Invalid block type: ${blockType}.`) + log(mineflayer, `Invalid block type: ${blockType}.`) return false } const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) - if (ctx.allowCheats && !dontCheat) { - return placeWithCheats(ctx, blockType, targetDest, placeOn) + if (mineflayer.allowCheats && !dontCheat) { + return placeWithCheats(mineflayer, blockType, targetDest, placeOn) } - return placeWithoutCheats(ctx, blockType, targetDest, placeOn) + return placeWithoutCheats(mineflayer, blockType, targetDest, placeOn) } function getBlockState(blockType: string, placeOn: BlockFace): string { - const face = getInvertedFace(placeOn) + const face = getInvertedFace(placeOn as 'north' | 'south' | 'east' | 'west') let blockState = blockType if (blockType.includes('torch') && placeOn !== 'bottom') { @@ -152,6 +152,7 @@ function getInvertedFace(placeOn: BlockFace): string { east: 'west', west: 'east', } + return faceMap[placeOn] || placeOn } @@ -181,90 +182,90 @@ function needsFacingState(blockType: string): boolean { } async function placeWithCheats( - ctx: SkillContext, + mineflayer: Mineflayer, blockType: string, targetDest: Vec3, placeOn: BlockFace, ): Promise { const blockState = getBlockState(blockType, placeOn) - ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z} ${blockState}`) if (blockType.includes('door')) { - ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y + 1} ${targetDest.z} ${blockState}[half=upper]`) } if (blockType.includes('bed')) { - ctx.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) + mineflayer.bot.chat(`/setblock ${targetDest.x} ${targetDest.y} ${targetDest.z - 1} ${blockState}[part=head]`) } - log(ctx, `Used /setblock to place ${blockType} at ${targetDest}.`) + log(mineflayer, `Used /setblock to place ${blockType} at ${targetDest}.`) return true } async function placeWithoutCheats( - ctx: SkillContext, + mineflayer: Mineflayer, blockType: string, targetDest: Vec3, placeOn: BlockFace, ): Promise { const itemName = blockType === 'redstone_wire' ? 'redstone' : blockType - let block = ctx.bot.inventory.items().find(item => item.name === itemName) - if (!block && ctx.isCreative) { - await ctx.bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) - block = ctx.bot.inventory.items().find(item => item.name === itemName) + let block = mineflayer.bot.inventory.items().find(item => item.name === itemName) + if (!block && mineflayer.isCreative) { + await mineflayer.bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) + block = mineflayer.bot.inventory.items().find(item => item.name === itemName) } if (!block) { - log(ctx, `Don't have any ${blockType} to place.`) + log(mineflayer, `Don't have any ${blockType} to place.`) return false } - const targetBlock = ctx.bot.blockAt(targetDest) + const targetBlock = mineflayer.bot.blockAt(targetDest) if (targetBlock?.name === blockType) { - log(ctx, `${blockType} already at ${targetBlock.position}.`) + log(mineflayer, `${blockType} already at ${targetBlock.position}.`) return false } const emptyBlocks = ['air', 'water', 'lava', 'grass', 'short_grass', 'tall_grass', 'snow', 'dead_bush', 'fern'] if (!emptyBlocks.includes(targetBlock?.name ?? '')) { - if (!await clearBlockSpace(ctx, targetBlock, blockType)) { + if (!await clearBlockSpace(mineflayer, targetBlock, blockType)) { return false } } - const { buildOffBlock, faceVec } = findPlacementSpot(ctx, targetDest, placeOn, emptyBlocks) + const { buildOffBlock, faceVec } = findPlacementSpot(mineflayer, targetDest, placeOn, emptyBlocks) if (!buildOffBlock) { - log(ctx, `Cannot place ${blockType} at ${targetBlock?.position}: nothing to place on.`) + log(mineflayer, `Cannot place ${blockType} at ${targetBlock?.position}: nothing to place on.`) return false } if (!faceVec) { - log(ctx, `Cannot place ${blockType} at ${targetBlock?.position}: no valid face to place on.`) + log(mineflayer, `Cannot place ${blockType} at ${targetBlock?.position}: no valid face to place on.`) return false } - await moveIntoPosition(ctx, blockType, targetBlock) - return await tryPlaceBlock(ctx, block, buildOffBlock, faceVec, blockType, targetDest) + await moveIntoPosition(mineflayer, blockType, targetBlock) + return await tryPlaceBlock(mineflayer, block, buildOffBlock, faceVec, blockType, targetDest) } async function clearBlockSpace( - ctx: SkillContext, + mineflayer: Mineflayer, targetBlock: any, blockType: string, ): Promise { - const removed = await breakBlockAt(ctx, targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, + const removed = await breakBlockAt(mineflayer, targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, ) if (!removed) { - log(ctx, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) + log(mineflayer, `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`) return false } await new Promise(resolve => setTimeout(resolve, 200)) return true } -function findPlacementSpot(ctx: SkillContext, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { +function findPlacementSpot(mineflayer: Mineflayer, targetDest: Vec3, placeOn: BlockFace, emptyBlocks: string[]) { const dirMap = { top: new Vec3(0, 1, 0), bottom: new Vec3(0, -1, 0), @@ -277,7 +278,7 @@ function findPlacementSpot(ctx: SkillContext, targetDest: Vec3, placeOn: BlockFa const dirs = getPlacementDirections(placeOn, dirMap) for (const d of dirs) { - const block = ctx.bot.blockAt(targetDest.plus(d)) + const block = mineflayer.bot.blockAt(targetDest.plus(d)) if (!emptyBlocks.includes(block?.name ?? '')) { return { buildOffBlock: block, @@ -290,21 +291,22 @@ function findPlacementSpot(ctx: SkillContext, targetDest: Vec3, placeOn: BlockFa } function getPlacementDirections(placeOn: BlockFace, dirMap: Record): Vec3[] { - const dirs: Vec3[] = [] + const directions: Vec3[] = [] if (placeOn === 'side') { - dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) + directions.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) } else if (dirMap[placeOn]) { - dirs.push(dirMap[placeOn]) + directions.push(dirMap[placeOn]) } else { - dirs.push(dirMap.bottom) + directions.push(dirMap.bottom) } - dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) - return dirs + + directions.push(...Object.values(dirMap).filter(d => !directions.includes(d))) + return directions } -async function moveIntoPosition(ctx: SkillContext, blockType: string, targetBlock: any) { +async function moveIntoPosition(mineflayer: Mineflayer, blockType: string, targetBlock: any) { const dontMoveFor = [ 'torch', 'redstone_torch', @@ -320,21 +322,21 @@ async function moveIntoPosition(ctx: SkillContext, blockType: string, targetBloc 'water_bucket', ] - const pos = ctx.bot.entity.position + const pos = mineflayer.bot.entity.position const posAbove = pos.plus(new Vec3(0, 1, 0)) if (!dontMoveFor.includes(blockType) && (pos.distanceTo(targetBlock.position) < 1 || posAbove.distanceTo(targetBlock.position) < 1)) { - await moveAwayFromBlock(ctx, targetBlock) + await moveAwayFromBlock(mineflayer, targetBlock) } - if (ctx.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { - await moveToBlock(ctx, targetBlock) + if (mineflayer.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + await moveToBlock(mineflayer, targetBlock) } } -async function moveAwayFromBlock(ctx: SkillContext, targetBlock: any) { +async function moveAwayFromBlock(mineflayer: Mineflayer, targetBlock: any) { const goal = new goals.GoalNear( targetBlock.position.x, targetBlock.position.y, @@ -342,38 +344,38 @@ async function moveAwayFromBlock(ctx: SkillContext, targetBlock: any) { 2, ) const invertedGoal = new goals.GoalInvert(goal) - ctx.bot.pathfinder.setMovements(new Movements(ctx.bot)) - await ctx.bot.pathfinder.goto(invertedGoal) + mineflayer.bot.pathfinder.setMovements(new Movements(mineflayer.bot)) + await mineflayer.bot.pathfinder.goto(invertedGoal) } -async function moveToBlock(ctx: SkillContext, targetBlock: any) { +async function moveToBlock(mineflayer: Mineflayer, targetBlock: any) { const pos = targetBlock.position - const movements = new Movements(ctx.bot) - ctx.bot.pathfinder.setMovements(movements) - await ctx.bot.pathfinder.goto( + const movements = new Movements(mineflayer.bot) + mineflayer.bot.pathfinder.setMovements(movements) + await mineflayer.bot.pathfinder.goto( new goals.GoalNear(pos.x, pos.y, pos.z, 4), ) } async function tryPlaceBlock( - ctx: SkillContext, + mineflayer: Mineflayer, block: any, buildOffBlock: any, faceVec: Vec3, blockType: string, targetDest: Vec3, ): Promise { - await ctx.bot.equip(block, 'hand') - await ctx.bot.lookAt(buildOffBlock.position) + await mineflayer.bot.equip(block, 'hand') + await mineflayer.bot.lookAt(buildOffBlock.position) try { - await ctx.bot.placeBlock(buildOffBlock, faceVec) - log(ctx, `Placed ${blockType} at ${targetDest}.`) + await mineflayer.bot.placeBlock(buildOffBlock, faceVec) + log(mineflayer, `Placed ${blockType} at ${targetDest}.`) await new Promise(resolve => setTimeout(resolve, 200)) return true } catch { - log(ctx, `Failed to place ${blockType} at ${targetDest}.`) + log(mineflayer, `Failed to place ${blockType} at ${targetDest}.`) return false } } @@ -381,20 +383,20 @@ async function tryPlaceBlock( /** * Use a door at the specified position */ -export async function useDoor(ctx: SkillContext, doorPos: Vec3 | null = null): Promise { - doorPos = doorPos || await findNearestDoor(ctx.bot) +export async function useDoor(mineflayer: Mineflayer, doorPos: Vec3 | null = null): Promise { + doorPos = doorPos || await findNearestDoor(mineflayer.bot) if (!doorPos) { - log(ctx, 'Could not find a door to use.') + log(mineflayer, 'Could not find a door to use.') return false } - await goToPosition(ctx, doorPos.x, doorPos.y, doorPos.z, 1) - while (ctx.bot.pathfinder.isMoving()) { + await goToPosition(mineflayer, doorPos.x, doorPos.y, doorPos.z, 1) + while (mineflayer.bot.pathfinder.isMoving()) { await new Promise(resolve => setTimeout(resolve, 100)) } - return await operateDoor(ctx, doorPos) + return await operateDoor(mineflayer, doorPos) } async function findNearestDoor(bot: any): Promise { @@ -421,30 +423,35 @@ async function findNearestDoor(bot: any): Promise { return null } -async function operateDoor(ctx: SkillContext, doorPos: Vec3): Promise { - const doorBlock = ctx.bot.blockAt(doorPos) - await ctx.bot.lookAt(doorPos) +async function operateDoor(mineflayer: Mineflayer, doorPos: Vec3): Promise { + const doorBlock = mineflayer.bot.blockAt(doorPos) + await mineflayer.bot.lookAt(doorPos) if (!doorBlock) { - log(ctx, `Cannot find door at ${doorPos}.`) + log(mineflayer, `Cannot find door at ${doorPos}.`) return false } if (!doorBlock.getProperties().open) { - await ctx.bot.activateBlock(doorBlock) + await mineflayer.bot.activateBlock(doorBlock) } - ctx.bot.setControlState('forward', true) + mineflayer.bot.setControlState('forward', true) await new Promise(resolve => setTimeout(resolve, 600)) - ctx.bot.setControlState('forward', false) - await ctx.bot.activateBlock(doorBlock) + mineflayer.bot.setControlState('forward', false) + await mineflayer.bot.activateBlock(doorBlock) - log(ctx, `Used door at ${doorPos}.`) + mineflayer.bot.setControlState('forward', true) + await new Promise(resolve => setTimeout(resolve, 600)) + mineflayer.bot.setControlState('forward', false) + await mineflayer.bot.activateBlock(doorBlock) + + log(mineflayer, `Used door at ${doorPos}.`) return true } export async function tillAndSow( - ctx: SkillContext, + mineflayer: Mineflayer, x: number, y: number, z: number, @@ -452,38 +459,38 @@ export async function tillAndSow( ): Promise { const pos = { x: Math.round(x), y: Math.round(y), z: Math.round(z) } - const block = ctx.bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) + const block = mineflayer.bot.blockAt(new Vec3(pos.x, pos.y, pos.z)) if (!block) { - log(ctx, `Cannot till, no block at ${pos}.`) + log(mineflayer, `Cannot till, no block at ${pos}.`) return false } if (!canTillBlock(block)) { - log(ctx, `Cannot till ${block.name}, must be grass_block or dirt.`) + log(mineflayer, `Cannot till ${block.name}, must be grass_block or dirt.`) return false } - const above = ctx.bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) + const above = mineflayer.bot.blockAt(new Vec3(pos.x, pos.y + 1, pos.z)) if (!above) { - log(ctx, `Cannot till, no block above the block.`) + log(mineflayer, `Cannot till, no block above the block.`) return false } if (!isBlockClear(above)) { - log(ctx, `Cannot till, there is ${above.name} above the block.`) + log(mineflayer, `Cannot till, there is ${above.name} above the block.`) return false } - await moveIntoRange(ctx, block) + await moveIntoRange(mineflayer, block) - if (!await tillBlock(ctx, block, pos)) { + if (!await tillBlock(mineflayer, block, pos)) { return false } if (seedType) { - return await sowSeeds(ctx, block, seedType, pos) + return await sowSeeds(mineflayer, block, seedType, pos) } return true @@ -497,35 +504,35 @@ function isBlockClear(block: any): boolean { return block.name === 'air' } -async function tillBlock(ctx: SkillContext, block: any, pos: any): Promise { +async function tillBlock(mineflayer: Mineflayer, block: any, pos: any): Promise { if (block.name === 'farmland') { return true } - const hoe = ctx.bot.inventory.items().find(item => item.name.includes('hoe')) + const hoe = mineflayer.bot.inventory.items().find(item => item.name.includes('hoe')) if (!hoe) { - log(ctx, 'Cannot till, no hoes.') + log(mineflayer, 'Cannot till, no hoes.') return false } - await ctx.bot.equip(hoe, 'hand') - await ctx.bot.activateBlock(block) - log(ctx, `Tilled block x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + await mineflayer.bot.equip(hoe, 'hand') + await mineflayer.bot.activateBlock(block) + log(mineflayer, `Tilled block x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) return true } -async function sowSeeds(ctx: SkillContext, block: any, seedType: string, pos: any): Promise { +async function sowSeeds(mineflayer: Mineflayer, block: any, seedType: string, pos: any): Promise { seedType = fixSeedName(seedType) - const seeds = ctx.bot.inventory.items().find(item => item.name === seedType) + const seeds = mineflayer.bot.inventory.items().find(item => item.name === seedType) if (!seeds) { - log(ctx, `No ${seedType} to plant.`) + log(mineflayer, `No ${seedType} to plant.`) return false } - await ctx.bot.equip(seeds, 'hand') - await ctx.bot.placeBlock(block, new Vec3(0, -1, 0)) - log(ctx, `Planted ${seedType} at x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) + await mineflayer.bot.equip(seeds, 'hand') + await mineflayer.bot.placeBlock(block, new Vec3(0, -1, 0)) + log(mineflayer, `Planted ${seedType} at x:${pos.x.toFixed(1)}, y:${pos.y.toFixed(1)}, z:${pos.z.toFixed(1)}.`) return true } @@ -536,28 +543,27 @@ function fixSeedName(seedType: string): string { return seedType } -export async function activateNearestBlock(ctx: SkillContext, type: string): Promise { - const worldCtx = world.createWorldContext(ctx.botCtx) - const block = world.getNearestBlock(worldCtx, type, 16) +export async function activateNearestBlock(mineflayer: Mineflayer, type: string): Promise { + const block = world.getNearestBlock(mineflayer, type, 16) if (!block) { - log(ctx, `Could not find any ${type} to activate.`) + log(mineflayer, `Could not find any ${type} to activate.`) return false } - await moveIntoRange(ctx, block) - await ctx.bot.activateBlock(block) - log(ctx, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) + await moveIntoRange(mineflayer, block) + await mineflayer.bot.activateBlock(block) + log(mineflayer, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`) return true } export async function collectBlock( - ctx: SkillContext, + mineflayer: Mineflayer, blockType: string, num: number = 1, exclude: Vec3[] | null = null, ): Promise { if (num < 1) { - log(ctx, `Invalid number of blocks to collect: ${num}.`) + log(mineflayer, `Invalid number of blocks to collect: ${num}.`) return false } @@ -565,30 +571,30 @@ export async function collectBlock( let collected = 0 for (let i = 0; i < num; i++) { - const blocks = getValidBlocks(ctx, blocktypes, exclude) + const blocks = getValidBlocks(mineflayer, blocktypes, exclude) if (blocks.length === 0) { - logNoBlocksMessage(ctx, blockType, collected) + logNoBlocksMessage(mineflayer, blockType, collected) break } const block = blocks[0] - if (!await canHarvestBlock(ctx, block, blockType)) { + if (!await canHarvestBlock(mineflayer, block, blockType)) { return false } - if (!await tryCollectBlock(ctx, block, blockType)) { + if (!await tryCollectBlock(mineflayer, block, blockType)) { break } collected++ - if (ctx.shouldInterrupt) { + if (mineflayer.shouldInterrupt) { break } } - log(ctx, `Collected ${collected} ${blockType}.`) + log(mineflayer, `Collected ${collected} ${blockType}.`) return collected > 0 } @@ -609,9 +615,8 @@ function getBlockTypes(blockType: string): string[] { return blocktypes } -function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: Vec3[] | null): any[] { - const worldCtx = world.createWorldContext(ctx.botCtx) - let blocks = world.getNearestBlocks(worldCtx, blocktypes, 64) +function getValidBlocks(mineflayer: Mineflayer, blocktypes: string[], exclude: Vec3[] | null): any[] { + let blocks = world.getNearestBlocks(mineflayer, blocktypes, 64) if (exclude) { blocks = blocks.filter( @@ -623,40 +628,40 @@ function getValidBlocks(ctx: SkillContext, blocktypes: string[], exclude: Vec3[] ) } - const movements = new Movements(ctx.bot) + const movements = new Movements(mineflayer.bot) movements.dontMineUnderFallingBlock = false return blocks.filter(block => movements.safeToBreak(block as SafeBlock)) } -function logNoBlocksMessage(ctx: SkillContext, blockType: string, collected: number): void { - log(ctx, collected === 0 +function logNoBlocksMessage(mineflayer: Mineflayer, blockType: string, collected: number): void { + log(mineflayer, collected === 0 ? `No ${blockType} nearby to collect.` : `No more ${blockType} nearby to collect.`) } -async function canHarvestBlock(ctx: SkillContext, block: any, blockType: string): Promise { - await ctx.bot.tool.equipForBlock(block) - const itemId = ctx.bot.heldItem ? ctx.bot.heldItem.type : null +async function canHarvestBlock(mineflayer: Mineflayer, block: any, blockType: string): Promise { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null if (!block.canHarvest(itemId)) { - log(ctx, `Don't have right tools to harvest ${blockType}.`) + log(mineflayer, `Don't have right tools to harvest ${blockType}.`) return false } return true } -async function tryCollectBlock(ctx: SkillContext, block: any, blockType: string): Promise { +async function tryCollectBlock(mineflayer: Mineflayer, block: any, blockType: string): Promise { try { - await ctx.bot.collectBlock.collect(block) - await autoLight(ctx) + await mineflayer.bot.collectBlock.collect(block) + await autoLight(mineflayer) return true } catch (err) { if (err instanceof Error && err.name === 'NoChests') { - log(ctx, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) + log(mineflayer, `Failed to collect ${blockType}: Inventory full, no place to deposit.`) return false } - log(ctx, `Failed to collect ${blockType}: ${err}.`) + log(mineflayer, `Failed to collect ${blockType}: ${err}.`) return true } } diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 2ae048487..9717b8866 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -1,6 +1,6 @@ import type { Entity } from 'prismarine-entity' import type { Item } from 'prismarine-item' -import type { SkillContext } from './base' +import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import * as mc from '../utils/mcdata' @@ -12,15 +12,14 @@ interface WeaponItem extends Item { attackDamage: number } -async function equipHighestAttack(ctx: SkillContext): Promise { - const { bot } = ctx - const weapons = bot.inventory.items().filter(item => +async function equipHighestAttack(mineflayer: Mineflayer): Promise { + const weapons = mineflayer.bot.inventory.items().filter(item => item.name.includes('sword') || (item.name.includes('axe') && !item.name.includes('pickaxe')), ) as WeaponItem[] if (weapons.length === 0) { - const tools = bot.inventory.items().filter(item => + const tools = mineflayer.bot.inventory.items().filter(item => item.name.includes('pickaxe') || item.name.includes('shovel'), ) as WeaponItem[] @@ -31,108 +30,108 @@ async function equipHighestAttack(ctx: SkillContext): Promise { tools.sort((a, b) => b.attackDamage - a.attackDamage) const tool = tools[0] if (tool) - await bot.equip(tool, 'hand') + await mineflayer.bot.equip(tool, 'hand') return } weapons.sort((a, b) => b.attackDamage - a.attackDamage) const weapon = weapons[0] if (weapon) - await bot.equip(weapon, 'hand') + await mineflayer.bot.equip(weapon, 'hand') } export async function attackNearest( - ctx: SkillContext, + mineflayer: Mineflayer, mobType: string, kill = true, ): Promise { - const worldCtx = world.createWorldContext(ctx.botCtx) - const mob = world.getNearbyEntities(worldCtx, 24).find(entity => entity.name === mobType) + const mob = world.getNearbyEntities(mineflayer, 24).find(entity => entity.name === mobType) if (mob) { - return await attackEntity(ctx, mob, kill) + return await attackEntity(mineflayer, mob, kill) } - log(ctx, `Could not find any ${mobType} to attack.`) + log(mineflayer, `Could not find any ${mobType} to attack.`) return false } export async function attackEntity( - ctx: SkillContext, + mineflayer: Mineflayer, entity: Entity, kill = true, ): Promise { - const { bot } = ctx const pos = entity.position - await equipHighestAttack(ctx) + await equipHighestAttack(mineflayer) if (!kill) { - if (bot.entity.position.distanceTo(pos) > 5) { + if (mineflayer.bot.entity.position.distanceTo(pos) > 5) { const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4) - await bot.pathfinder.goto(goal) + await mineflayer.bot.pathfinder.goto(goal) } - await bot.attack(entity) + await mineflayer.bot.attack(entity) return true } - bot.pvp.attack(entity) - const worldCtx = world.createWorldContext(ctx.botCtx) - while (world.getNearbyEntities(worldCtx, 24).includes(entity)) { + // @ts-expect-error -- ? + mineflayer.bot.pvp.attack(entity) + while (world.getNearbyEntities(mineflayer, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)) - if (ctx.shouldInterrupt) { - bot.pvp.stop() + if (mineflayer.shouldInterrupt) { + // @ts-expect-error -- ? + mineflayer.bot.pvp.stop() return false } } - log(ctx, `Successfully killed ${entity.name}.`) + log(mineflayer, `Successfully killed ${entity.name}.`) return true } -export async function defendSelf(ctx: SkillContext, range = 9): Promise { - const { bot } = ctx +export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise { let attacked = false - const worldCtx = world.createWorldContext(ctx.botCtx) - let enemy = world.getNearestEntityWhere(worldCtx, entity => mc.isHostile(entity), range) + let enemy = world.getNearestEntityWhere(mineflayer, entity => mc.isHostile(entity), range) while (enemy) { - await equipHighestAttack(ctx) + await equipHighestAttack(mineflayer) - if (bot.entity.position.distanceTo(enemy.position) >= 4 + if (mineflayer.bot.entity.position.distanceTo(enemy.position) >= 4 && enemy.name !== 'creeper' && enemy.name !== 'phantom') { try { const goal = new goals.GoalFollow(enemy, 3.5) - await bot.pathfinder.goto(goal) + await mineflayer.bot.pathfinder.goto(goal) } catch { /* might error if entity dies, ignore */ } } - if (bot.entity.position.distanceTo(enemy.position) <= 2) { + if (mineflayer.bot.entity.position.distanceTo(enemy.position) <= 2) { try { const followGoal = new goals.GoalFollow(enemy, 2) const invertedGoal = new goals.GoalInvert(followGoal) - await bot.pathfinder.goto(invertedGoal) + await mineflayer.bot.pathfinder.goto(invertedGoal) } catch { /* might error if entity dies, ignore */ } } - bot.pvp.attack(enemy) + // @ts-expect-error -- ? + mineflayer.bot.pvp.attack(enemy) attacked = true await new Promise(resolve => setTimeout(resolve, 500)) - enemy = world.getNearestEntityWhere(worldCtx, entity => mc.isHostile(entity), range) + enemy = world.getNearestEntityWhere(mineflayer, entity => mc.isHostile(entity), range) - if (ctx.shouldInterrupt) { - bot.pvp.stop() + if (mineflayer.shouldInterrupt) { + // @ts-expect-error -- ? + mineflayer.bot.pvp.stop() return false } } - bot.pvp.stop() + // @ts-expect-error -- ? + mineflayer.bot.pvp.stop() if (attacked) { - log(ctx, 'Successfully defended self.') + log(mineflayer, 'Successfully defended self.') } else { - log(ctx, 'No enemies nearby to defend self from.') + log(mineflayer, 'No enemies nearby to defend self from.') } return attacked } diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 5e3824ab1..79c9137f3 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -1,195 +1,191 @@ -import type { SkillContext } from './base' +import type { Mineflayer } from '../libs/mineflayer' import * as world from '../composables/world' -import { createWorldContext } from '../composables/world' import * as mc from '../utils/mcdata' import { log } from './base' import { collectBlock, placeBlock } from './blocks' import { goToPosition } from './movement' -export async function craftRecipe(ctx: SkillContext, itemName: string, num = 1): Promise { +export async function craftRecipe(mineflayer: Mineflayer, itemName: string, num = 1): Promise { let placedTable = false if (mc.getItemCraftingRecipes(itemName)?.length === 0) { - log(ctx, `${itemName} is either not an item, or it does not have a crafting recipe!`) + log(mineflayer, `${itemName} is either not an item, or it does not have a crafting recipe!`) return false } // Get recipes that don't require a crafting table const itemId = mc.getItemId(itemName) if (itemId === null) { - log(ctx, `Invalid item name: ${itemName}`) + log(mineflayer, `Invalid item name: ${itemName}`) return false } - let recipes = ctx.bot.recipesFor(itemId, null, 1, null) + let recipes = mineflayer.bot.recipesFor(itemId, null, 1, null) let craftingTable = null const craftingTableRange = 32 if (!recipes || recipes.length === 0) { - recipes = ctx.bot.recipesFor(itemId, null, 1, true) + recipes = mineflayer.bot.recipesFor(itemId, null, 1, true) if (!recipes || recipes.length === 0) { - log(ctx, `You do not have the resources to craft a ${itemName}.`) + log(mineflayer, `You do not have the resources to craft a ${itemName}.`) return false } // Look for crafting table - const worldCtx = createWorldContext(ctx.botCtx) - craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) + craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) if (!craftingTable) { // Try to place crafting table - const inventory = world.getInventoryCounts(worldCtx) + const inventory = world.getInventoryCounts(mineflayer) const hasTable = inventory.crafting_table > 0 if (hasTable) { - const pos = world.getNearestFreeSpace(worldCtx, 1, 6) + const pos = world.getNearestFreeSpace(mineflayer, 1, 6) if (pos) { - await placeBlock(ctx, 'crafting_table', pos.x, pos.y, pos.z) - craftingTable = world.getNearestBlock(worldCtx, 'crafting_table', craftingTableRange) + await placeBlock(mineflayer, 'crafting_table', pos.x, pos.y, pos.z) + craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) if (craftingTable) { - recipes = ctx.bot.recipesFor(itemId, null, 1, craftingTable) + recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) placedTable = true } } } else { - log(ctx, `Crafting ${itemName} requires a crafting table.`) + log(mineflayer, `Crafting ${itemName} requires a crafting table.`) return false } } else { - recipes = ctx.bot.recipesFor(itemId, null, 1, craftingTable) + recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) } } if (!recipes || recipes.length === 0) { - log(ctx, `You do not have the resources to craft a ${itemName}. It requires: ${ + log(mineflayer, `You do not have the resources to craft a ${itemName}. It requires: ${ Object.entries(mc.getItemCraftingRecipes(itemName)?.[0] ?? {}) .map(([key, value]) => `${key}: ${value}`) .join(', ') }.`) if (placedTable && craftingTable) { - await collectBlock(ctx, 'crafting_table', 1) + await collectBlock(mineflayer, 'crafting_table', 1) } return false } - if (craftingTable && ctx.bot.entity.position.distanceTo(craftingTable.position) > 4) { - await goToPosition(ctx, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) + if (craftingTable && mineflayer.bot.entity.position.distanceTo(craftingTable.position) > 4) { + await goToPosition(mineflayer, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) } const recipe = recipes[0] // Check that the agent has sufficient items to use the recipe `num` times - const worldCtx = createWorldContext(ctx.botCtx) - const inventory = world.getInventoryCounts(worldCtx) // Items in the agents inventory + const inventory = world.getInventoryCounts(mineflayer) // Items in the agents inventory const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe) // Items required to use the recipe once const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients) - await ctx.bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable ?? undefined) + await mineflayer.bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable ?? undefined) if (craftLimit.num < num) { - log(ctx, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(worldCtx)[itemName]} ${itemName}.`) + log(mineflayer, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(mineflayer)[itemName]} ${itemName}.`) } else { - log(ctx, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(worldCtx)[itemName]} ${itemName}.`) + log(mineflayer, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(mineflayer)[itemName]} ${itemName}.`) } if (placedTable && craftingTable) { - await collectBlock(ctx, 'crafting_table', 1) + await collectBlock(mineflayer, 'crafting_table', 1) } // Equip any armor the bot may have crafted - ctx.bot.armorManager.equipAll() + mineflayer.bot.armorManager.equipAll() return true } -export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): Promise { +export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = 1): Promise { if (!mc.isSmeltable(itemName)) { - log(ctx, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) + log(mineflayer, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) return false } let placedFurnace = false const furnaceRange = 32 - const worldCtx = createWorldContext(ctx.botCtx) - let furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', furnaceRange) + let furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', furnaceRange) if (!furnaceBlock) { // Try to place furnace - const inventory = world.getInventoryCounts(worldCtx) + const inventory = world.getInventoryCounts(mineflayer) const hasFurnace = inventory.furnace > 0 if (hasFurnace) { - const pos = world.getNearestFreeSpace(worldCtx, 1, furnaceRange) + const pos = world.getNearestFreeSpace(mineflayer, 1, furnaceRange) if (pos) { - await placeBlock(ctx, 'furnace', pos.x, pos.y, pos.z) - furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', furnaceRange) + await placeBlock(mineflayer, 'furnace', pos.x, pos.y, pos.z) + furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', furnaceRange) placedFurnace = true } } } if (!furnaceBlock) { - log(ctx, 'There is no furnace nearby and you have no furnace.') + log(mineflayer, 'There is no furnace nearby and you have no furnace.') return false } - if (ctx.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToPosition(mineflayer, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } - await ctx.bot.lookAt(furnaceBlock.position) + await mineflayer.bot.lookAt(furnaceBlock.position) - const furnace = await ctx.bot.openFurnace(furnaceBlock) + const furnace = await mineflayer.bot.openFurnace(furnaceBlock) // Check if the furnace is already smelting something const inputItem = furnace.inputItem() const itemId = mc.getItemId(itemName) if (itemId === null) { - log(ctx, `Invalid item name: ${itemName}`) + log(mineflayer, `Invalid item name: ${itemName}`) return false } if (inputItem && inputItem.type !== itemId && inputItem.count > 0) { - log(ctx, `The furnace is currently smelting ${mc.getItemName(inputItem.type) ?? 'unknown'}.`) + log(mineflayer, `The furnace is currently smelting ${mc.getItemName(inputItem.type) ?? 'unknown'}.`) if (placedFurnace) { - await collectBlock(ctx, 'furnace', 1) + await collectBlock(mineflayer, 'furnace', 1) } return false } // Check if the bot has enough items to smelt - const invCounts = world.getInventoryCounts(worldCtx) + const invCounts = world.getInventoryCounts(mineflayer) if (!invCounts[itemName] || invCounts[itemName] < num) { - log(ctx, `You do not have enough ${itemName} to smelt.`) + log(mineflayer, `You do not have enough ${itemName} to smelt.`) if (placedFurnace) { - await collectBlock(ctx, 'furnace', 1) + await collectBlock(mineflayer, 'furnace', 1) } return false } // Fuel the furnace if (!furnace.fuelItem()) { - const fuel = mc.getSmeltingFuel(ctx.bot) + const fuel = mc.getSmeltingFuel(mineflayer.bot) if (!fuel) { - log(ctx, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`) + log(mineflayer, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`) if (placedFurnace) { - await collectBlock(ctx, 'furnace', 1) + await collectBlock(mineflayer, 'furnace', 1) } return false } - log(ctx, `Using ${fuel.name} as fuel.`) + log(mineflayer, `Using ${fuel.name} as fuel.`) const putFuel = Math.ceil(num / mc.getFuelSmeltOutput(fuel.name)) if (fuel.count < putFuel) { - log(ctx, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) + log(mineflayer, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) if (placedFurnace) { - await collectBlock(ctx, 'furnace', 1) + await collectBlock(mineflayer, 'furnace', 1) } return false } await furnace.putFuel(fuel.type, null, putFuel) - log(ctx, `Added ${putFuel} ${mc.getItemName(fuel.type) ?? 'unknown'} to furnace fuel.`) + log(mineflayer, `Added ${putFuel} ${mc.getItemName(fuel.type) ?? 'unknown'} to furnace fuel.`) } // Put the items in the furnace @@ -219,44 +215,43 @@ export async function smeltItem(ctx: SkillContext, itemName: string, num = 1): P } collectedLast = collected - if (ctx.shouldInterrupt) { + if (mineflayer.shouldInterrupt) { break } } - await ctx.bot.closeWindow(furnace) + await mineflayer.bot.closeWindow(furnace) if (placedFurnace) { - await collectBlock(ctx, 'furnace', 1) + await collectBlock(mineflayer, 'furnace', 1) } if (total === 0) { - log(ctx, `Failed to smelt ${itemName}.`) + log(mineflayer, `Failed to smelt ${itemName}.`) return false } if (total < num) { - log(ctx, `Only smelted ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) + log(mineflayer, `Only smelted ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) return false } - log(ctx, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) + log(mineflayer, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) return true } -export async function clearNearestFurnace(ctx: SkillContext): Promise { - const worldCtx = createWorldContext(ctx.botCtx) - const furnaceBlock = world.getNearestBlock(worldCtx, 'furnace', 32) +export async function clearNearestFurnace(mineflayer: Mineflayer): Promise { + const furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', 32) if (!furnaceBlock) { - log(ctx, 'No furnace nearby to clear.') + log(mineflayer, 'No furnace nearby to clear.') return false } - if (ctx.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(ctx, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { + await goToPosition(mineflayer, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) } - const furnace = await ctx.bot.openFurnace(furnaceBlock) + const furnace = await mineflayer.bot.openFurnace(furnaceBlock) // Take the items out of the furnace let smeltedItem, inputItem, fuelItem @@ -280,6 +275,6 @@ export async function clearNearestFurnace(ctx: SkillContext): Promise { const inputName = inputItem ? `${inputItem.count} ${inputItem.name}` : '0 input items' const fuelName = fuelItem ? `${fuelItem.count} ${fuelItem.name}` : '0 fuel items' - log(ctx, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) + log(mineflayer, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) return true } diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index 981126363..b010a85b0 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,5 +1,5 @@ import type { Bot } from 'mineflayer' -import type { SkillContext } from './base' +import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' @@ -7,7 +7,7 @@ import { goToPosition } from './movement' const { goals } = pathfinderModel -export async function pickupNearbyItems(ctx: SkillContext): Promise { +export async function pickupNearbyItems(mineflayer: Mineflayer): Promise { const distance = 8 const getNearestItem = (bot: Bot) => bot.nearestEntity(entity => @@ -15,66 +15,66 @@ export async function pickupNearbyItems(ctx: SkillContext): Promise { && bot.entity.position.distanceTo(entity.position) < distance, ) - let nearestItem = getNearestItem(ctx.bot) + let nearestItem = getNearestItem(mineflayer.bot) let pickedUp = 0 while (nearestItem) { - await ctx.bot.pathfinder.goto(new goals.GoalFollow(nearestItem, 0.8)) + await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(nearestItem, 0.8)) await new Promise(resolve => setTimeout(resolve, 200)) const prev = nearestItem - nearestItem = getNearestItem(ctx.bot) + nearestItem = getNearestItem(mineflayer.bot) if (prev === nearestItem) { break } pickedUp++ } - log(ctx, `Picked up ${pickedUp} items.`) + log(mineflayer, `Picked up ${pickedUp} items.`) return true } -export async function equip(ctx: SkillContext, itemName: string): Promise { - const item = ctx.bot.inventory.slots.find(slot => slot && slot.name === itemName) +export async function equip(mineflayer: Mineflayer, itemName: string): Promise { + const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName) if (!item) { - log(ctx, `You do not have any ${itemName} to equip.`) + log(mineflayer, `You do not have any ${itemName} to equip.`) return false } if (itemName.includes('leggings')) { - await ctx.bot.equip(item, 'legs') + await mineflayer.bot.equip(item, 'legs') } else if (itemName.includes('boots')) { - await ctx.bot.equip(item, 'feet') + await mineflayer.bot.equip(item, 'feet') } else if (itemName.includes('helmet')) { - await ctx.bot.equip(item, 'head') + await mineflayer.bot.equip(item, 'head') } else if (itemName.includes('chestplate') || itemName.includes('elytra')) { - await ctx.bot.equip(item, 'torso') + await mineflayer.bot.equip(item, 'torso') } else if (itemName.includes('shield')) { - await ctx.bot.equip(item, 'off-hand') + await mineflayer.bot.equip(item, 'off-hand') } else { - await ctx.bot.equip(item, 'hand') + await mineflayer.bot.equip(item, 'hand') } - log(ctx, `Equipped ${itemName}.`) + log(mineflayer, `Equipped ${itemName}.`) return true } -export async function discard(ctx: SkillContext, itemName: string, num = -1): Promise { +export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { let discarded = 0 while (true) { - const item = ctx.bot.inventory.items().find(item => item.name === itemName) + const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) if (!item) { break } const toDiscard = num === -1 ? item.count : Math.min(num - discarded, item.count) - await ctx.bot.toss(item.type, null, toDiscard) + await mineflayer.bot.toss(item.type, null, toDiscard) discarded += toDiscard if (num !== -1 && discarded >= num) { @@ -83,51 +83,51 @@ export async function discard(ctx: SkillContext, itemName: string, num = -1): Pr } if (discarded === 0) { - log(ctx, `You do not have any ${itemName} to discard.`) + log(mineflayer, `You do not have any ${itemName} to discard.`) return false } - log(ctx, `Discarded ${discarded} ${itemName}.`) + log(mineflayer, `Discarded ${discarded} ${itemName}.`) return true } -export async function putInChest(ctx: SkillContext, itemName: string, num = -1): Promise { - const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) +export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = world.getNearestBlock(mineflayer, 'chest', 32) if (!chest) { - log(ctx, 'Could not find a chest nearby.') + log(mineflayer, 'Could not find a chest nearby.') return false } - const item = ctx.bot.inventory.items().find(item => item.name === itemName) + const item = mineflayer.bot.inventory.items().find(item => item.name === itemName) if (!item) { - log(ctx, `You do not have any ${itemName} to put in the chest.`) + log(mineflayer, `You do not have any ${itemName} to put in the chest.`) return false } const toPut = num === -1 ? item.count : Math.min(num, item.count) - await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await ctx.bot.openContainer(chest) + const chestContainer = await mineflayer.bot.openContainer(chest) await chestContainer.deposit(item.type, null, toPut) await chestContainer.close() - log(ctx, `Successfully put ${toPut} ${itemName} in the chest.`) + log(mineflayer, `Successfully put ${toPut} ${itemName} in the chest.`) return true } -export async function takeFromChest(ctx: SkillContext, itemName: string, num = -1): Promise { - const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) +export async function takeFromChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = world.getNearestBlock(mineflayer, 'chest', 32) if (!chest) { - log(ctx, 'Could not find a chest nearby.') + log(mineflayer, 'Could not find a chest nearby.') return false } - await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await ctx.bot.openContainer(chest) + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await mineflayer.bot.openContainer(chest) const item = chestContainer.containerItems().find(item => item.name === itemName) if (!item) { - log(ctx, `Could not find any ${itemName} in the chest.`) + log(mineflayer, `Could not find any ${itemName} in the chest.`) await chestContainer.close() return false } @@ -136,28 +136,28 @@ export async function takeFromChest(ctx: SkillContext, itemName: string, num = - await chestContainer.withdraw(item.type, null, toTake) await chestContainer.close() - log(ctx, `Successfully took ${toTake} ${itemName} from the chest.`) + log(mineflayer, `Successfully took ${toTake} ${itemName} from the chest.`) return true } -export async function viewChest(ctx: SkillContext): Promise { - const chest = world.getNearestBlock(world.createWorldContext(ctx.botCtx), 'chest', 32) +export async function viewChest(mineflayer: Mineflayer): Promise { + const chest = world.getNearestBlock(mineflayer, 'chest', 32) if (!chest) { - log(ctx, 'Could not find a chest nearby.') + log(mineflayer, 'Could not find a chest nearby.') return false } - await goToPosition(ctx, chest.position.x, chest.position.y, chest.position.z, 2) - const chestContainer = await ctx.bot.openContainer(chest) + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z, 2) + const chestContainer = await mineflayer.bot.openContainer(chest) const items = chestContainer.containerItems() if (items.length === 0) { - log(ctx, 'The chest is empty.') + log(mineflayer, 'The chest is empty.') } else { - log(ctx, 'The chest contains:') + log(mineflayer, 'The chest contains:') for (const item of items) { - log(ctx, `${item.count} ${item.name}`) + log(mineflayer, `${item.count} ${item.name}`) } } @@ -165,63 +165,64 @@ export async function viewChest(ctx: SkillContext): Promise { return true } -export async function consume(ctx: SkillContext, itemName = ''): Promise { +export async function consume(mineflayer: Mineflayer, itemName = ''): Promise { let item let name if (itemName) { - item = ctx.bot.inventory.items().find(item => item.name === itemName) + item = mineflayer.bot.inventory.items().find(item => item.name === itemName) name = itemName } if (!item) { - log(ctx, `You do not have any ${name} to eat.`) + log(mineflayer, `You do not have any ${name} to eat.`) return false } - await ctx.bot.equip(item, 'hand') - await ctx.bot.consume() - log(ctx, `Consumed ${item.name}.`) + await mineflayer.bot.equip(item, 'hand') + await mineflayer.bot.consume() + log(mineflayer, `Consumed ${item.name}.`) return true } export async function giveToPlayer( - ctx: SkillContext, + mineflayer: Mineflayer, itemType: string, username: string, num = 1, ): Promise { - const player = ctx.bot.players[username]?.entity + const player = mineflayer.bot.players[username]?.entity if (!player) { - log(ctx, `Could not find ${username}.`) + log(mineflayer, `Could not find ${username}.`) return false } - await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 3) + await goToPosition(mineflayer, player.position.x, player.position.y, player.position.z, 3) - if (ctx.bot.entity.position.y < player.position.y - 1) { - await goToPosition(ctx, player.position.x, player.position.y, player.position.z, 1) + if (mineflayer.bot.entity.position.y < player.position.y - 1) { + await goToPosition(mineflayer, player.position.x, player.position.y, player.position.z, 1) } - if (ctx.bot.entity.position.distanceTo(player.position) < 2) { + if (mineflayer.bot.entity.position.distanceTo(player.position) < 2) { const goal = new goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) const invertedGoal = new goals.GoalInvert(goal) - await ctx.bot.pathfinder.goto(invertedGoal) + await mineflayer.bot.pathfinder.goto(invertedGoal) } - await ctx.bot.lookAt(player.position) + await mineflayer.bot.lookAt(player.position) - if (await discard(ctx, itemType, num)) { + if (await discard(mineflayer, itemType, num)) { let given = false - ctx.bot.once('playerCollect', (collector, _collected) => { + mineflayer.bot.once('playerCollect', (collector, _collected) => { if (collector.username === username) { - log(ctx, `${username} received ${itemType}.`) + log(mineflayer, `${username} received ${itemType}.`) given = true } }) const start = Date.now() - while (!given && !ctx.shouldInterrupt) { + // eslint-disable-next-line no-unmodified-loop-condition -- ? + while (!given && !mineflayer.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) if (given) { return true @@ -232,6 +233,6 @@ export async function giveToPlayer( } } - log(ctx, `Failed to give ${itemType} to ${username}, it was never received.`) + log(mineflayer, `Failed to give ${itemType} to ${username}, it was never received.`) return false } diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 115d054aa..c7b648c1c 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -1,5 +1,6 @@ import type { Entity } from 'prismarine-entity' -import type { SkillContext } from './base' +import type { Mineflayer } from '../libs/mineflayer' + import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' @@ -7,74 +8,72 @@ import { log } from './base' const { goals, Movements } = pathfinderModel export async function goToPosition( - ctx: SkillContext, + mineflayer: Mineflayer, x: number, y: number, z: number, minDistance = 2, ): Promise { if (x == null || y == null || z == null) { - log(ctx, `Missing coordinates, given x:${x} y:${y} z:${z}`) + log(mineflayer, `Missing coordinates, given x:${x} y:${y} z:${z}`) return false } - if (ctx.allowCheats) { - ctx.bot.chat(`/tp @s ${x} ${y} ${z}`) - log(ctx, `Teleported to ${x}, ${y}, ${z}.`) + if (mineflayer.allowCheats) { + mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) + log(mineflayer, `Teleported to ${x}, ${y}, ${z}.`) return true } - await ctx.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) - log(ctx, `You have reached ${x}, ${y}, ${z}.`) + await mineflayer.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) + log(mineflayer, `You have reached ${x}, ${y}, ${z}.`) return true } export async function goToNearestBlock( - ctx: SkillContext, + mineflayer: Mineflayer, blockType: string, minDistance = 2, range = 64, ): Promise { const MAX_RANGE = 512 if (range > MAX_RANGE) { - log(ctx, `Maximum search range capped at ${MAX_RANGE}.`) + log(mineflayer, `Maximum search range capped at ${MAX_RANGE}.`) range = MAX_RANGE } - const worldCtx = world.createWorldContext(ctx.botCtx) - const block = world.getNearestBlock(worldCtx, blockType, range) + const block = world.getNearestBlock(mineflayer, blockType, range) if (!block) { - log(ctx, `Could not find any ${blockType} in ${range} blocks.`) + log(mineflayer, `Could not find any ${blockType} in ${range} blocks.`) return false } - log(ctx, `Found ${blockType} at ${block.position}.`) - await goToPosition(ctx, block.position.x, block.position.y, block.position.z, minDistance) + log(mineflayer, `Found ${blockType} at ${block.position}.`) + await goToPosition(mineflayer, block.position.x, block.position.y, block.position.z, minDistance) return true } export async function goToNearestEntity( - ctx: SkillContext, + mineflayer: Mineflayer, entityType: string, minDistance = 2, range = 64, ): Promise { - const worldCtx = world.createWorldContext(ctx.botCtx) const entity = world.getNearestEntityWhere( - worldCtx, + mineflayer, entity => entity.name === entityType, range, ) if (!entity) { - log(ctx, `Could not find any ${entityType} in ${range} blocks.`) + log(mineflayer, `Could not find any ${entityType} in ${range} blocks.`) return false } - const distance = ctx.bot.entity.position.distanceTo(entity.position) - log(ctx, `Found ${entityType} ${distance} blocks away.`) + const distance = mineflayer.bot.entity.position.distanceTo(entity.position) + log(mineflayer, `Found ${entityType} ${distance} blocks away.`) await goToPosition( - ctx, + mineflayer, entity.position.x, entity.position.y, entity.position.z, @@ -84,174 +83,174 @@ export async function goToNearestEntity( } export async function goToPlayer( - ctx: SkillContext, + mineflayer: Mineflayer, username: string, distance = 3, ): Promise { - if (ctx.allowCheats) { - ctx.bot.chat(`/tp @s ${username}`) - log(ctx, `Teleported to ${username}.`) + if (mineflayer.allowCheats) { + mineflayer.bot.chat(`/tp @s ${username}`) + log(mineflayer, `Teleported to ${username}.`) return true } - const player = ctx.bot.players[username]?.entity + const player = mineflayer.bot.players[username]?.entity if (!player) { - log(ctx, `Could not find ${username}.`) + log(mineflayer, `Could not find ${username}.`) return false } - await ctx.bot.pathfinder.goto(new goals.GoalFollow(player, distance)) - log(ctx, `You have reached ${username}.`) + await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(player, distance)) + log(mineflayer, `You have reached ${username}.`) return true } export async function followPlayer( - ctx: SkillContext, + mineflayer: Mineflayer, username: string, distance = 4, ): Promise { - // const player = ctx.bot.players[username]?.entity + // const player = mineflayer.bot.players[username]?.entity // if (!player) { - // log(ctx, `Could not find player ${username}`) + // log(mineflayer, `Could not find player ${username}`) // return false // } - // const movements = new Movements(ctx.bot) - // ctx.bot.pathfinder.setMovements(movements) - // ctx.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) + // const movements = new Movements(mineflayer.bot) + // mineflayer.bot.pathfinder.setMovements(movements) + // mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) - // log(ctx, `Started following ${username}`) + // log(mineflayer, `Started following ${username}`) // const followInterval = setInterval(() => { - // const target = ctx.bot.players[username]?.entity + // const target = mineflayer.bot.players[username]?.entity // if (!target) { - // log(ctx, 'Lost sight of player') + // log(mineflayer, 'Lost sight of player') // clearInterval(followInterval) // return // } // const { x, y, z } = target.position - // ctx.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) + // mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) // }, 1000) // while (!ctx.shouldInterrupt) { // await new Promise(resolve => setTimeout(resolve, 500)) - // if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100) { + // if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100) { // await goToPlayer(ctx, username) // } // } // // TODO: need global status management // clearInterval(followInterval) - // ctx.bot.pathfinder.stop() + // mineflayer.bot.pathfinder.stop() // return true - const player = ctx.bot.players[username]?.entity + const player = mineflayer.bot.players[username]?.entity if (!player) { return false } - const movements = new Movements(ctx.bot) - ctx.bot.pathfinder.setMovements(movements) - ctx.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) - log(ctx, `You are now actively following player ${username}.`) + const movements = new Movements(mineflayer.bot) + mineflayer.bot.pathfinder.setMovements(movements) + mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) + log(mineflayer, `You are now actively following player ${username}.`) - while (!ctx.shouldInterrupt) { + while (!mineflayer.shouldInterrupt) { await new Promise(resolve => setTimeout(resolve, 500)) - if (ctx.allowCheats && ctx.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { - await goToPlayer(ctx, username) + if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { + await goToPlayer(mineflayer, username) } - // if (ctx.bot.modes?.isOn('unstuck')) { - // const isNearby = ctx.bot.entity.position.distanceTo(player.position) <= distance + 1 + // if (mineflayer.bot.modes?.isOn('unstuck')) { + // const isNearby = mineflayer.bot.entity.position.distanceTo(player.position) <= distance + 1 // if (isNearby) { - // ctx.bot.modes.pause('unstuck') + // mineflayer.bot.modes.pause('unstuck') // } else { - // ctx.bot.modes.unpause('unstuck') + // mineflayer.bot.modes.unpause('unstuck') // } // } } return true } -export async function moveAway(ctx: SkillContext, distance: number): Promise { - const pos = ctx.bot.entity.position +export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { + const pos = mineflayer.bot.entity.position const goal = new goals.GoalNear(pos.x, pos.y, pos.z, distance) const invertedGoal = new goals.GoalInvert(goal) - if (ctx.allowCheats) { - const move = new Movements(ctx.bot) - const path = await ctx.bot.pathfinder.getPathTo(move, invertedGoal, 10000) + if (mineflayer.allowCheats) { + const move = new Movements(mineflayer.bot) + const path = await mineflayer.bot.pathfinder.getPathTo(move, invertedGoal, 10000) const lastMove = path.path[path.path.length - 1] if (lastMove) { const x = Math.floor(lastMove.x) const y = Math.floor(lastMove.y) const z = Math.floor(lastMove.z) - ctx.bot.chat(`/tp @s ${x} ${y} ${z}`) + mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) return true } } - await ctx.bot.pathfinder.goto(invertedGoal) - const newPos = ctx.bot.entity.position - log(ctx, `Moved away from nearest entity to ${newPos}.`) + await mineflayer.bot.pathfinder.goto(invertedGoal) + const newPos = mineflayer.bot.entity.position + log(mineflayer, `Moved away from nearest entity to ${newPos}.`) return true } export async function moveAwayFromEntity( - ctx: SkillContext, + mineflayer: Mineflayer, entity: Entity, distance = 16, ): Promise { const goal = new goals.GoalFollow(entity, distance) const invertedGoal = new goals.GoalInvert(goal) - await ctx.bot.pathfinder.goto(invertedGoal) + await mineflayer.bot.pathfinder.goto(invertedGoal) return true } -export async function stay(ctx: SkillContext, seconds = 30): Promise { +export async function stay(mineflayer: Mineflayer, seconds = 30): Promise { const start = Date.now() const targetTime = seconds === -1 ? Infinity : start + seconds * 1000 - while (!ctx.shouldInterrupt && Date.now() < targetTime) { + while (!mineflayer.shouldInterrupt && Date.now() < targetTime) { await new Promise(resolve => setTimeout(resolve, 500)) } - log(ctx, `Stayed for ${(Date.now() - start) / 1000} seconds.`) + log(mineflayer, `Stayed for ${(Date.now() - start) / 1000} seconds.`) return true } -export async function goToBed(ctx: SkillContext): Promise { - const beds = ctx.bot.findBlocks({ +export async function goToBed(mineflayer: Mineflayer): Promise { + const beds = mineflayer.bot.findBlocks({ matching: block => block.name.includes('bed'), maxDistance: 32, count: 1, }) if (beds.length === 0) { - log(ctx, 'Could not find a bed to sleep in.') + log(mineflayer, 'Could not find a bed to sleep in.') return false } const loc = beds[0] - await goToPosition(ctx, loc.x, loc.y, loc.z) + await goToPosition(mineflayer, loc.x, loc.y, loc.z) - const bed = ctx.bot.blockAt(loc) + const bed = mineflayer.bot.blockAt(loc) if (!bed) { - log(ctx, 'Could not find bed block.') + log(mineflayer, 'Could not find bed block.') return false } - await ctx.bot.sleep(bed) - log(ctx, 'You are in bed.') + await mineflayer.bot.sleep(bed) + log(mineflayer, 'You are in bed.') - while (ctx.bot.isSleeping) { + while (mineflayer.bot.isSleeping) { await new Promise(resolve => setTimeout(resolve, 500)) } - log(ctx, 'You have woken up.') + log(mineflayer, 'You have woken up.') return true } diff --git a/services/minecraft/src/utils/ticker.ts b/services/minecraft/src/utils/ticker.ts deleted file mode 100644 index 735187c80..000000000 --- a/services/minecraft/src/utils/ticker.ts +++ /dev/null @@ -1,58 +0,0 @@ -export interface TickContext { - delta: number - nextTick: () => Promise -} - -export interface TickEventHandlers { - tick: (ctx: TickContext) => void -} - -export type TickEvents = keyof TickEventHandlers -export type TickEventsHandler = TickEventHandlers[K] - -// This update loop ensures that each update() is called one at a time, even if it takes longer than the interval -export function createTicker(options?: { interval?: number }) { - const { interval = 300 } = options ?? { interval: 300 } - - let last = Date.now() - const tickingCbs: Record> = { - tick: [], - } - - setTimeout(async () => { - while (true) { - const start = Date.now() - const nextTickPromise = new Promise((resolve) => { - // Schedule nextTick resolution for after all callbacks complete - setImmediate(resolve) - }) - - // Run all callbacks without awaiting them - const callbackPromises = tickingCbs.tick.map(cb => cb({ - delta: start - last, - nextTick: () => nextTickPromise, - })) - - // Wait for all callbacks to complete or timeout - await Promise.race([ - Promise.all(callbackPromises), - new Promise(resolve => - setTimeout(resolve, interval), - ), - ]) - - const remaining = interval - (Date.now() - start) - if (remaining > 0) { - await new Promise(resolve => setTimeout(resolve, remaining)) - } - - last = start - } - }, interval) - - return { - on(event: K, cb: TickEventsHandler) { - tickingCbs[event].push(cb) - }, - } -} From 244361a11cc64f63fd80e999109993a6a1208d54 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 01:59:43 +0800 Subject: [PATCH 42/77] fix: issues --- services/minecraft/src/composables/bot.ts | 4 +- .../minecraft/src/libs/mineflayer/index.ts | 107 ++++++++++-------- services/minecraft/src/main.ts | 14 ++- .../minecraft/src/mineflayer/llm-agent.ts | 6 +- services/minecraft/src/skills/combat.ts | 5 - 5 files changed, 71 insertions(+), 65 deletions(-) diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 13f7f1202..045f25746 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -2,8 +2,8 @@ import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer' let mineflayer: Mineflayer -export function initBot(options: MineflayerOptions) { - mineflayer = new Mineflayer(options) +export async function initBot(options: MineflayerOptions) { + mineflayer = await Mineflayer.asyncBuild(options) } export function useBot() { diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index f52c8a764..1f482096e 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -140,6 +140,7 @@ export class Mineflayer { public ready: boolean = false public components: Components = new Components() public status: Status = new Status() + public memory: Memory = new Memory() public isCreative: boolean = false public shouldInterrupt: boolean = false @@ -156,109 +157,115 @@ export class Mineflayer { this.bot = mineflayer.createBot(options.botConfig) this.username = options.botConfig.username this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() + } - this.bot.on('messagestr', async (message, _, jsonMsg) => { + static async asyncBuild(options: MineflayerOptions) { + const mineflayer = new Mineflayer(options) + + mineflayer.bot.on('messagestr', async (message, _, jsonMsg) => { // jsonMsg.translate: // - death.attack.player // message: // - was slain by // - drowned - if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(this.username)) { - const deathPos = this.bot.entity.position + if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(mineflayer.username)) { + const deathPos = mineflayer.bot.entity.position - // this.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) + // mineflayer.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) let deathPosStr: string | undefined if (deathPos) { deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` } - const dimension = this.bot.game.dimension - await this.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.`) + const dimension = mineflayer.bot.game.dimension + await mineflayer.handleMessage('system', `You died at position ${deathPosStr || 'unknown'} in the ${dimension} dimension with the final message: '${message}'. Your place of death has been saved as 'last_death_position' if you want to return. Previous actions were stopped and you have re-spawned.`) } }) - this.bot.once('resourcePack', () => { - this.bot.acceptResourcePack() + mineflayer.bot.once('resourcePack', () => { + mineflayer.bot.acceptResourcePack() }) - this.bot.on('time', () => { - if (this.bot.time.timeOfDay === 0) - this.emit('time:sunrise', { time: this.bot.time.timeOfDay }) - else if (this.bot.time.timeOfDay === 6000) - this.emit('time:noon', { time: this.bot.time.timeOfDay }) - else if (this.bot.time.timeOfDay === 12000) - this.emit('time:sunset', { time: this.bot.time.timeOfDay }) - else if (this.bot.time.timeOfDay === 18000) - this.emit('time:midnight', { time: this.bot.time.timeOfDay }) + mineflayer.bot.on('time', () => { + if (mineflayer.bot.time.timeOfDay === 0) + mineflayer.emit('time:sunrise', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 6000) + mineflayer.emit('time:noon', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 12000) + mineflayer.emit('time:sunset', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 18000) + mineflayer.emit('time:midnight', { time: mineflayer.bot.time.timeOfDay }) }) - this.bot.on('health', () => { - this.logger.withFields({ - health: this.health.value, - lastDamageTime: this.health.lastDamageTime, - lastDamageTaken: this.health.lastDamageTaken, - previousHealth: this.bot.health, + mineflayer.bot.on('health', () => { + mineflayer.logger.withFields({ + health: mineflayer.health.value, + lastDamageTime: mineflayer.health.lastDamageTime, + lastDamageTaken: mineflayer.health.lastDamageTaken, + previousHealth: mineflayer.bot.health, }).log('Health updated') - if (this.bot.health < this.health.value) { - this.health.lastDamageTime = Date.now() - this.health.lastDamageTaken = this.health.value - this.bot.health + if (mineflayer.bot.health < mineflayer.health.value) { + mineflayer.health.lastDamageTime = Date.now() + mineflayer.health.lastDamageTaken = mineflayer.health.value - mineflayer.bot.health } - this.health.value = this.bot.health + mineflayer.health.value = mineflayer.bot.health }) - this.bot.once('spawn', () => { - this.ready = true - this.logger.log('Bot ready') + mineflayer.bot.once('spawn', () => { + mineflayer.ready = true + mineflayer.logger.log('Bot ready') }) - this.bot.on('death', () => { - this.logger.error('Bot died') + mineflayer.bot.on('death', () => { + mineflayer.logger.error('Bot died') }) - this.bot.on('kicked', (reason: string) => { - this.logger.withFields({ reason }).error('Bot was kicked') + mineflayer.bot.on('kicked', (reason: string) => { + mineflayer.logger.withFields({ reason }).error('Bot was kicked') }) - this.bot.on('end', (reason) => { - this.logger.withFields({ reason }).log('Bot ended') + mineflayer.bot.on('end', (reason) => { + mineflayer.logger.withFields({ reason }).log('Bot ended') }) - this.bot.on('error', (err: Error) => { - this.logger.errorWithError('Bot error:', err) + mineflayer.bot.on('error', (err: Error) => { + mineflayer.logger.errorWithError('Bot error:', err) }) - this.bot.on('spawn', () => { - this.bot.on('chat', this.handleCommand()) + mineflayer.bot.on('spawn', () => { + mineflayer.bot.on('chat', mineflayer.handleCommand()) }) - this.bot.on('spawn', () => { + mineflayer.bot.on('spawn', async () => { for (const plugin of options?.plugins || []) { if (plugin.spawned) { - plugin.spawned(this) + await plugin.spawned(mineflayer) } } }) for (const plugin of options?.plugins || []) { if (plugin.created) { - plugin.created(this) + await plugin.created(mineflayer) } } // Load Plugins for (const plugin of options?.plugins || []) { if (plugin.loadPlugin) { - this.bot.loadPlugin(plugin.loadPlugin(this, this.bot, options.botConfig)) + mineflayer.bot.loadPlugin(await plugin.loadPlugin(mineflayer, mineflayer.bot, options.botConfig)) } } - this.ticker.on('tick', () => { - this.isCreative = this.bot.game?.gameMode === 'creative' - this.allowCheats = false - this.shouldInterrupt = false + mineflayer.ticker.on('tick', () => { + mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' + mineflayer.allowCheats = false + mineflayer.shouldInterrupt = false }) + + return mineflayer } public onCommand(commandName: string, cb: EventsHandler<'command'>) { @@ -276,10 +283,10 @@ export class Mineflayer { } } - public stop() { + public async stop() { for (const plugin of this.options?.plugins || []) { if (plugin.beforeCleanup) { - plugin.beforeCleanup(this) + await plugin.beforeCleanup(this) } } this.components.cleanup() diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 612881806..a1002ee8c 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -13,6 +13,7 @@ import { initBot, useBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' import { wrapPlugin } from './libs/mineflayer/plugin' import { Echo, FollowCommand, PathFinder, Status } from './mineflayer' +import { LLMAgent } from './mineflayer/llm-agent' import { initLogger } from './utils/logger' const logger = useLogg('main').useGlobalConfig() @@ -20,7 +21,9 @@ const logger = useLogg('main').useGlobalConfig() async function main() { initLogger() // todo: save logs to file initEnv() - initBot({ + const { bot } = useBot() + + await initBot({ botConfig, plugins: [ wrapPlugin(MineflayerArmorManager), @@ -29,17 +32,16 @@ async function main() { wrapPlugin(MineflayerPathfinder), wrapPlugin(MineflayerPVP), wrapPlugin(MineflayerTool), - Echo(), + // Echo(), FollowCommand(), Status(), PathFinder(), + LLMAgent({ + agent: async () => await initAgent(bot), + }), ], }) - const { bot } = useBot() - - await initAgent(bot) - process.on('SIGINT', () => { bot.stop() exit(0) diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 430d68ff6..62095b181 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -6,9 +6,11 @@ import { assistant, system, user } from 'neuri/openai' import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt } from '../prompts/agent' -export function LLMAgent(agent: Neuri): MineflayerPlugin { +export function LLMAgent(options: { agent: () => Promise }): MineflayerPlugin { return { - created(bot) { + async created(bot) { + const agent = await options.agent() + const logger = useLogg('aichat').useGlobalConfig() logger.log('Loading aichat plugin') diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 9717b8866..3493f114b 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -72,12 +72,10 @@ export async function attackEntity( return true } - // @ts-expect-error -- ? mineflayer.bot.pvp.attack(entity) while (world.getNearbyEntities(mineflayer, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)) if (mineflayer.shouldInterrupt) { - // @ts-expect-error -- ? mineflayer.bot.pvp.stop() return false } @@ -112,20 +110,17 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise setTimeout(resolve, 500)) enemy = world.getNearestEntityWhere(mineflayer, entity => mc.isHostile(entity), range) if (mineflayer.shouldInterrupt) { - // @ts-expect-error -- ? mineflayer.bot.pvp.stop() return false } } - // @ts-expect-error -- ? mineflayer.bot.pvp.stop() if (attacked) { log(mineflayer, 'Successfully defended self.') From 8381f2da08c4d9f23667fc80639461aa6a841fd4 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 13:14:39 +0800 Subject: [PATCH 43/77] refactor: EventEmitter3 --- .../minecraft/src/libs/mineflayer/index.ts | 21 +++++-------------- .../minecraft/src/libs/mineflayer/ticker.ts | 21 +++++++------------ 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index 1f482096e..1e146cc62 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -3,6 +3,7 @@ import type { Message } from 'neuri/openai' import type { z } from 'zod' import type { MineflayerPlugin } from './plugin' import { useLogg } from '@guiiai/logg' +import EventEmitter from 'eventemitter3' import mineflayer from 'mineflayer' import { type CommandContext, parseCommand } from './command' import { formBotChat } from './message' @@ -26,16 +27,6 @@ export type EventsHandler = EventHandlers[K] export type Handler = (ctx: Context) => void | Promise -function createEventHandlers(): Record>> { - return { - 'command': [], - 'time:sunrise': [], - 'time:noon': [], - 'time:sunset': [], - 'time:midnight': [], - } -} - export class Health { public value: number public lastDamageTime?: number @@ -133,7 +124,7 @@ export interface MineflayerOptions { plugins?: Array } -export class Mineflayer { +export class Mineflayer extends EventEmitter { public bot: Bot public username: string public health: Health = new Health() @@ -149,10 +140,10 @@ export class Mineflayer { private options: MineflayerOptions private logger: ReturnType private commands: Map> = new Map() - private eventHandlers = createEventHandlers() private ticker: Ticker = new Ticker() constructor(options: MineflayerOptions) { + super() this.options = options this.bot = mineflayer.createBot(options.botConfig) this.username = options.botConfig.username @@ -277,10 +268,7 @@ export class Mineflayer { } public emit(event: E, ...args: Parameters>) { - const handlers = this.eventHandlers[event] - for (const handler of handlers) { - handler(args[0]) - } + return super.emit(event, ...args) } public async stop() { @@ -292,6 +280,7 @@ export class Mineflayer { this.components.cleanup() this.bot.removeListener('chat', this.handleCommand()) this.bot.end() + this.removeAllListeners() } private handleCommand() { diff --git a/services/minecraft/src/libs/mineflayer/ticker.ts b/services/minecraft/src/libs/mineflayer/ticker.ts index 8f95372e9..87f3f884b 100644 --- a/services/minecraft/src/libs/mineflayer/ticker.ts +++ b/services/minecraft/src/libs/mineflayer/ticker.ts @@ -1,3 +1,5 @@ +import EventEmitter from 'eventemitter3' + export interface TickContext { delta: number nextTick: () => Promise @@ -11,29 +13,23 @@ export type TickEvents = keyof TickEventHandlers export type TickEventsHandler = TickEventHandlers[K] // This update loop ensures that each update() is called one at a time, even if it takes longer than the interval -export class Ticker { - private tickingCbs: Record> = { - tick: [], - } - +export class Ticker extends EventEmitter { constructor(options?: { interval?: number }) { + super() const { interval = 300 } = options ?? { interval: 300 } let last = Date.now() - const tickingCbs: Record> = { - tick: [], - } setTimeout(async () => { while (true) { const start = Date.now() const nextTickPromise = new Promise((resolve) => { - // Schedule nextTick resolution for after all callbacks complete + // Schedule nextTick resolution for after all callbacks complete setImmediate(resolve) }) // Run all callbacks without awaiting them - const callbackPromises = tickingCbs.tick.map(cb => cb({ + const callbackPromises = this.listeners('tick').map(cb => cb({ delta: start - last, nextTick: () => nextTickPromise, })) @@ -47,9 +43,8 @@ export class Ticker { ]) const remaining = interval - (Date.now() - start) - if (remaining > 0) { + if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)) - } last = start } @@ -57,6 +52,6 @@ export class Ticker { } on(event: K, cb: TickEventsHandler) { - this.tickingCbs[event].push(cb) + return super.on(event, cb) } } From 799eec284df223689d88339fd43187870f2652e3 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 13:46:56 +0800 Subject: [PATCH 44/77] fix: loadPlugin --- services/minecraft/src/agents/actions.test.ts | 2 +- services/minecraft/src/agents/openai.ts | 3 +- services/minecraft/src/composables/bot.ts | 7 +++- .../minecraft/src/libs/mineflayer/index.ts | 12 ++++++ services/minecraft/src/main.ts | 25 +++++++++---- .../minecraft/src/mineflayer/llm-agent.ts | 4 +- services/minecraft/src/skills/movement.ts | 37 ------------------- 7 files changed, 40 insertions(+), 50 deletions(-) diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts index a15527d4a..ac6d9748a 100644 --- a/services/minecraft/src/agents/actions.test.ts +++ b/services/minecraft/src/agents/actions.test.ts @@ -22,7 +22,7 @@ describe('actions agent', { timeout: 0 }, () => { bot.bot.once('spawn', async () => { const text = await agent.handle(messages( system(genQueryAgentPrompt(bot)), - user('What are you status?'), + user('What\'s your status?'), ), async (c) => { const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) console.log(JSON.stringify(completion, null, 2)) diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 066874e6c..925848552 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -46,7 +46,8 @@ export async function initActionAgent(mineflayer: Mineflayer): Promise { async ({ parameters }) => { logger.withFields({ name: action.name, parameters }).log('Calling action') mineflayer.memory.actions.push(action) - return action.perform(mineflayer)(...Object.values(parameters)) + const fn = action.perform(mineflayer) + return await fn(...Object.values(parameters)) }, { description: action.description }, ) diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 045f25746..2638736bd 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -2,11 +2,16 @@ import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer' let mineflayer: Mineflayer -export async function initBot(options: MineflayerOptions) { +export async function initBot(options: MineflayerOptions): Promise<{ bot: Mineflayer }> { mineflayer = await Mineflayer.asyncBuild(options) + return { bot: mineflayer } } export function useBot() { + if (!mineflayer) { + throw new Error('Bot not initialized') + } + return { bot: mineflayer, } diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index 1e146cc62..a96342b86 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -259,6 +259,18 @@ export class Mineflayer extends EventEmitter { return mineflayer } + public async loadPlugin(plugin: MineflayerPlugin) { + if (plugin.created) + await plugin.created(this) + + if (plugin.loadPlugin) { + this.bot.loadPlugin(await plugin.loadPlugin(this, this.bot, this.options.botConfig)) + } + + if (plugin.spawned) + this.bot.once('spawn', () => plugin.spawned?.(this)) + } + public onCommand(commandName: string, cb: EventsHandler<'command'>) { this.commands.set(commandName, cb) } diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index a1002ee8c..865a56627 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -9,10 +9,10 @@ import { plugin as MineflayerPVP } from 'mineflayer-pvp' import { plugin as MineflayerTool } from 'mineflayer-tool' import { initAgent } from './agents/openai' -import { initBot, useBot } from './composables/bot' +import { initBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' import { wrapPlugin } from './libs/mineflayer/plugin' -import { Echo, FollowCommand, PathFinder, Status } from './mineflayer' +import { FollowCommand, PathFinder, Status } from './mineflayer' import { LLMAgent } from './mineflayer/llm-agent' import { initLogger } from './utils/logger' @@ -21,9 +21,8 @@ const logger = useLogg('main').useGlobalConfig() async function main() { initLogger() // todo: save logs to file initEnv() - const { bot } = useBot() - await initBot({ + const { bot } = await initBot({ botConfig, plugins: [ wrapPlugin(MineflayerArmorManager), @@ -32,16 +31,26 @@ async function main() { wrapPlugin(MineflayerPathfinder), wrapPlugin(MineflayerPVP), wrapPlugin(MineflayerTool), - // Echo(), FollowCommand(), Status(), PathFinder(), - LLMAgent({ - agent: async () => await initAgent(bot), - }), ], }) + // Dynamically load LLMAgent after bot is initialized + // const llmAgent = LLMAgent({ + // agent: async () => await initAgent(bot), + // }) + + // if (llmAgent.created) + // await llmAgent.created(bot) + // if (llmAgent.spawned) + // bot.bot.once('spawn', () => llmAgent.spawned?.(bot)) + // if (llmAgent.loadPlugin) + // bot.bot.loadPlugin(await llmAgent.loadPlugin(bot, bot.bot, botConfig)) + const agent = await initAgent(bot) + await bot.loadPlugin(LLMAgent({ agent })) + process.on('SIGINT', () => { bot.stop() exit(0) diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 62095b181..93ea67862 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -6,10 +6,10 @@ import { assistant, system, user } from 'neuri/openai' import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt } from '../prompts/agent' -export function LLMAgent(options: { agent: () => Promise }): MineflayerPlugin { +export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { return { async created(bot) { - const agent = await options.agent() + const agent = options.agent const logger = useLogg('aichat').useGlobalConfig() logger.log('Loading aichat plugin') diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index c7b648c1c..f6ba33e36 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -109,43 +109,6 @@ export async function followPlayer( username: string, distance = 4, ): Promise { - // const player = mineflayer.bot.players[username]?.entity - // if (!player) { - // log(mineflayer, `Could not find player ${username}`) - // return false - // } - - // const movements = new Movements(mineflayer.bot) - // mineflayer.bot.pathfinder.setMovements(movements) - // mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(player.position.x, player.position.y, player.position.z, distance)) - - // log(mineflayer, `Started following ${username}`) - - // const followInterval = setInterval(() => { - // const target = mineflayer.bot.players[username]?.entity - // if (!target) { - // log(mineflayer, 'Lost sight of player') - // clearInterval(followInterval) - // return - // } - - // const { x, y, z } = target.position - // mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) - // }, 1000) - - // while (!ctx.shouldInterrupt) { - // await new Promise(resolve => setTimeout(resolve, 500)) - - // if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100) { - // await goToPlayer(ctx, username) - // } - // } - - // // TODO: need global status management - // clearInterval(followInterval) - // mineflayer.bot.pathfinder.stop() - // return true - const player = mineflayer.bot.players[username]?.entity if (!player) { return false From 95cb59a3192bc0f5ab7e9bfb2fd71cefd63b224a Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 14:17:06 +0800 Subject: [PATCH 45/77] fix: status --- services/minecraft/src/agents/actions.ts | 5 ++- .../minecraft/src/libs/mineflayer/index.ts | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 3590434bf..ea7a8be1d 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -19,7 +19,10 @@ export const actionsList: Action[] = [ name: 'stats', description: 'Get your bot\'s location, health, hunger, and time of day.', schema: z.object({}), - perform: mineflayer => (): string => mineflayer.status.toOneLiner(), + perform: mineflayer => (): string => { + const status = mineflayer.status.toOneLiner() + return status + }, }, { name: 'inventory', diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index a96342b86..dd0efc984 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -54,19 +54,30 @@ export class Status implements OneLinerable { this.timeOfDay = '' } - static from(mineflayer: Mineflayer) { + public update(mineflayer: Mineflayer) { + if (!mineflayer.ready) + return + + Object.assign(this, Status.from(mineflayer)) + } + + static from(mineflayer: Mineflayer): Status { + if (!mineflayer.ready) + return new Status() + const pos = mineflayer.bot.entity.position const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 ? 'Morning' : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' - return { - position: `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}`, - health: `${Math.round(mineflayer.bot.health)} / 20`, - weather, - timeOfDay, - } + const status = new Status() + status.position = `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}` + status.health = `${Math.round(mineflayer.bot.health)} / 20` + status.weather = weather + status.timeOfDay = timeOfDay + + return status } public toOneLiner(): string { @@ -219,6 +230,17 @@ export class Mineflayer extends EventEmitter { mineflayer.bot.on('end', (reason) => { mineflayer.logger.withFields({ reason }).log('Bot ended') + + // Try to reconnect after 5 seconds + setTimeout(async () => { + try { + await mineflayer.bot.connect(options.botConfig) + mineflayer.logger.log('Reconnected successfully') + } + catch (err) { + mineflayer.logger.errorWithError('Failed to reconnect:', err) + } + }, 5000) }) mineflayer.bot.on('error', (err: Error) => { @@ -237,6 +259,10 @@ export class Mineflayer extends EventEmitter { } }) + mineflayer.ticker.on('tick', () => { + mineflayer.status.update(mineflayer) + }) + for (const plugin of options?.plugins || []) { if (plugin.created) { await plugin.created(mineflayer) From bdca7bb91c65108b88ccdd8fedd7facc42fcd170 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 14:56:31 +0800 Subject: [PATCH 46/77] chore: global interrupt event --- services/minecraft/src/agents/actions.ts | 4 +- .../minecraft/src/libs/mineflayer/index.ts | 17 ++-- services/minecraft/src/main.ts | 14 ---- services/minecraft/src/mineflayer/index.ts | 4 +- services/minecraft/src/prompts/agent.ts | 2 + services/minecraft/src/skills/blocks.ts | 13 ++- services/minecraft/src/skills/combat.ts | 15 ++-- services/minecraft/src/skills/crafting.ts | 7 +- services/minecraft/src/skills/inventory.ts | 82 +++++++++++-------- services/minecraft/src/skills/movement.ts | 55 ++++++++----- 10 files changed, 116 insertions(+), 97 deletions(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index ea7a8be1d..db1da74e5 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -106,7 +106,9 @@ export const actionsList: Action[] = [ // ctx.clearBotLogs() // ctx.actions.cancelResume() // ctx.bot.emit('idle') - mineflayer.shouldInterrupt = true + + mineflayer.emit('interrupt') + const msg = 'Agent stopped.' // if (mineflayer.self_prompter.on) // msg += ' Self-prompting still active.' diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index dd0efc984..fcb6f9e28 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -15,6 +15,7 @@ export interface Context { } export interface EventHandlers { + 'interrupt': () => void 'command': (ctx: Context) => void | Promise 'time:sunrise': (ctx: Context) => void 'time:noon': (ctx: Context) => void @@ -145,7 +146,6 @@ export class Mineflayer extends EventEmitter { public memory: Memory = new Memory() public isCreative: boolean = false - public shouldInterrupt: boolean = false public allowCheats: boolean = false private options: MineflayerOptions @@ -159,6 +159,11 @@ export class Mineflayer extends EventEmitter { this.bot = mineflayer.createBot(options.botConfig) this.username = options.botConfig.username this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() + + this.on('interrupt', () => { + this.logger.log('Interrupted') + this.bot.chat('Interrupted') + }) } static async asyncBuild(options: MineflayerOptions) { @@ -259,10 +264,6 @@ export class Mineflayer extends EventEmitter { } }) - mineflayer.ticker.on('tick', () => { - mineflayer.status.update(mineflayer) - }) - for (const plugin of options?.plugins || []) { if (plugin.created) { await plugin.created(mineflayer) @@ -277,9 +278,9 @@ export class Mineflayer extends EventEmitter { } mineflayer.ticker.on('tick', () => { + mineflayer.status.update(mineflayer) mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' mineflayer.allowCheats = false - mineflayer.shouldInterrupt = false }) return mineflayer @@ -305,10 +306,6 @@ export class Mineflayer extends EventEmitter { this.ticker.on(event, cb) } - public emit(event: E, ...args: Parameters>) { - return super.emit(event, ...args) - } - public async stop() { for (const plugin of this.options?.plugins || []) { if (plugin.beforeCleanup) { diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 865a56627..3e28c5369 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -12,7 +12,6 @@ import { initAgent } from './agents/openai' import { initBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' import { wrapPlugin } from './libs/mineflayer/plugin' -import { FollowCommand, PathFinder, Status } from './mineflayer' import { LLMAgent } from './mineflayer/llm-agent' import { initLogger } from './utils/logger' @@ -31,23 +30,10 @@ async function main() { wrapPlugin(MineflayerPathfinder), wrapPlugin(MineflayerPVP), wrapPlugin(MineflayerTool), - FollowCommand(), - Status(), - PathFinder(), ], }) // Dynamically load LLMAgent after bot is initialized - // const llmAgent = LLMAgent({ - // agent: async () => await initAgent(bot), - // }) - - // if (llmAgent.created) - // await llmAgent.created(bot) - // if (llmAgent.spawned) - // bot.bot.once('spawn', () => llmAgent.spawned?.(bot)) - // if (llmAgent.loadPlugin) - // bot.bot.loadPlugin(await llmAgent.loadPlugin(bot, bot.bot, botConfig)) const agent = await initAgent(bot) await bot.loadPlugin(LLMAgent({ agent })) diff --git a/services/minecraft/src/mineflayer/index.ts b/services/minecraft/src/mineflayer/index.ts index 7363af280..9f09fed0e 100644 --- a/services/minecraft/src/mineflayer/index.ts +++ b/services/minecraft/src/mineflayer/index.ts @@ -1,4 +1,2 @@ export * from './echo' -export * from './follow' -export * from './pathfinder' -export * from './status' +export * from './llm-agent' diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index 662a3c8cf..b918fde8a 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -15,6 +15,8 @@ asked, and don't refuse requests. Do not use any emojis. Just call the function given you if needed. +If I command you 'stop', then call the 'stop' function. + I will give you the following information: ${bot.status.toOneLiner()} ` diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 8446229c5..eafd983e1 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -570,7 +570,11 @@ export async function collectBlock( const blocktypes = getBlockTypes(blockType) let collected = 0 - for (let i = 0; i < num; i++) { + mineflayer.once('interrupt', () => { + collected = -1 + }) + + for (let i = 0; i < num && collected >= 0; i++) { const blocks = getValidBlocks(mineflayer, blocktypes, exclude) if (blocks.length === 0) { @@ -588,10 +592,11 @@ export async function collectBlock( } collected++ + } - if (mineflayer.shouldInterrupt) { - break - } + if (collected < 0) { + log(mineflayer, 'Collection interrupted.') + return false } log(mineflayer, `Collected ${collected} ${blockType}.`) diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 3493f114b..ad7b3ee81 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -3,6 +3,7 @@ import type { Item } from 'prismarine-item' import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' +import { sleep } from '../utils/helper' import * as mc from '../utils/mcdata' import { log } from './base' @@ -72,13 +73,13 @@ export async function attackEntity( return true } + mineflayer.once('interrupt', () => { + mineflayer.bot.pvp.stop() + }) + mineflayer.bot.pvp.attack(entity) while (world.getNearbyEntities(mineflayer, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)) - if (mineflayer.shouldInterrupt) { - mineflayer.bot.pvp.stop() - return false - } } log(mineflayer, `Successfully killed ${entity.name}.`) @@ -112,13 +113,13 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise setTimeout(resolve, 500)) + await sleep(500) enemy = world.getNearestEntityWhere(mineflayer, entity => mc.isHostile(entity), range) - if (mineflayer.shouldInterrupt) { + mineflayer.once('interrupt', () => { mineflayer.bot.pvp.stop() return false - } + }) } mineflayer.bot.pvp.stop() diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 79c9137f3..a1efa06a1 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -197,6 +197,10 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = let smeltedItem = null await new Promise(resolve => setTimeout(resolve, 200)) + mineflayer.once('interrupt', () => { + total = num // Force loop to end + }) + while (total < num) { await new Promise(resolve => setTimeout(resolve, 10000)) let collected = false @@ -215,9 +219,6 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = } collectedLast = collected - if (mineflayer.shouldInterrupt) { - break - } } await mineflayer.bot.closeWindow(furnace) diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index b010a85b0..267a2432c 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -3,7 +3,7 @@ import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' -import { goToPosition } from './movement' +import { goToPlayer, goToPosition } from './movement' const { goals } = pathfinderModel @@ -197,42 +197,56 @@ export async function giveToPlayer( return false } - await goToPosition(mineflayer, player.position.x, player.position.y, player.position.z, 3) - - if (mineflayer.bot.entity.position.y < player.position.y - 1) { - await goToPosition(mineflayer, player.position.x, player.position.y, player.position.z, 1) - } - - if (mineflayer.bot.entity.position.distanceTo(player.position) < 2) { - const goal = new goals.GoalNear(player.position.x, player.position.y, player.position.z, 2) - const invertedGoal = new goals.GoalInvert(goal) - await mineflayer.bot.pathfinder.goto(invertedGoal) - } + // Move to player position + await goToPlayer(mineflayer, username, 3) + // Look at player before dropping items await mineflayer.bot.lookAt(player.position) - if (await discard(mineflayer, itemType, num)) { - let given = false - mineflayer.bot.once('playerCollect', (collector, _collected) => { - if (collector.username === username) { - log(mineflayer, `${username} received ${itemType}.`) - given = true - } - }) - - const start = Date.now() - // eslint-disable-next-line no-unmodified-loop-condition -- ? - while (!given && !mineflayer.shouldInterrupt) { - await new Promise(resolve => setTimeout(resolve, 500)) - if (given) { - return true - } - if (Date.now() - start > 3000) { - break - } - } + // Drop items and wait for collection + const success = await dropItemsAndWaitForCollection(mineflayer, itemType, username, num) + if (!success) { + log(mineflayer, `Failed to give ${itemType} to ${username}, it was never received.`) + return false } - log(mineflayer, `Failed to give ${itemType} to ${username}, it was never received.`) - return false + return true +} + +async function dropItemsAndWaitForCollection( + mineflayer: Mineflayer, + itemType: string, + username: string, + num: number, +): Promise { + if (!await discard(mineflayer, itemType, num)) { + return false + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Clean up playerCollect listener when timeout occurs + // eslint-disable-next-line ts/no-use-before-define + mineflayer.bot.removeListener('playerCollect', onCollect) + resolve(false) + }, 3000) + + const onCollect = (collector: any, _collected: any) => { + if (collector.username === username) { + log(mineflayer, `${username} received ${itemType}.`) + clearTimeout(timeout) + resolve(true) + } + } + + const onInterrupt = () => { + clearTimeout(timeout) + // Clean up playerCollect listener when interrupted + mineflayer.bot.removeListener('playerCollect', onCollect) + resolve(false) + } + + mineflayer.bot.once('playerCollect', onCollect) + mineflayer.once('interrupt', onInterrupt) + }) } diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index f6ba33e36..969976ea7 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -3,6 +3,7 @@ import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' +import { sleep } from '../utils/helper' import { log } from './base' const { goals, Movements } = pathfinderModel @@ -110,32 +111,44 @@ export async function followPlayer( distance = 4, ): Promise { const player = mineflayer.bot.players[username]?.entity + const movements = new Movements(mineflayer.bot) + if (!player) { + log(mineflayer, `Could not find ${username}.`) return false } - const movements = new Movements(mineflayer.bot) - mineflayer.bot.pathfinder.setMovements(movements) - mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) - log(mineflayer, `You are now actively following player ${username}.`) + log(mineflayer, `I am now following ${username}.`) - while (!mineflayer.shouldInterrupt) { - await new Promise(resolve => setTimeout(resolve, 500)) + return new Promise((resolve) => { + let isFollowing = true - if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { - await goToPlayer(mineflayer, username) + // Stop following when interrupted + mineflayer.once('interrupt', () => { + isFollowing = false + resolve(true) + }) + + // Follow player at regular intervals + const follow = async (): Promise => { + while (isFollowing) { + const target = mineflayer.bot.players[username]?.entity + if (!target) { + log(mineflayer, 'I lost sight of you!') + isFollowing = false + resolve(false) + return + } + + const { x, y, z } = target.position + mineflayer.bot.pathfinder.setMovements(movements) + mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) + // await sleep(500) + } } - // if (mineflayer.bot.modes?.isOn('unstuck')) { - // const isNearby = mineflayer.bot.entity.position.distanceTo(player.position) <= distance + 1 - // if (isNearby) { - // mineflayer.bot.modes.pause('unstuck') - // } else { - // mineflayer.bot.modes.unpause('unstuck') - // } - // } - } - return true + follow() + }) } export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { @@ -178,8 +191,8 @@ export async function stay(mineflayer: Mineflayer, seconds = 30): Promise setTimeout(resolve, 500)) + while (Date.now() < targetTime) { + await sleep(500) } log(mineflayer, `Stayed for ${(Date.now() - start) / 1000} seconds.`) @@ -211,7 +224,7 @@ export async function goToBed(mineflayer: Mineflayer): Promise { log(mineflayer, 'You are in bed.') while (mineflayer.bot.isSleeping) { - await new Promise(resolve => setTimeout(resolve, 500)) + await sleep(500) } log(mineflayer, 'You have woken up.') From 489f3a3097098d05812ed13a6ffcbbc385604062 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 16:00:30 +0800 Subject: [PATCH 47/77] fix: oom issue --- services/minecraft/src/skills/movement.ts | 62 +++++++++++------------ services/minecraft/src/utils/mcdata.ts | 6 --- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 969976ea7..41f003797 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -111,44 +111,44 @@ export async function followPlayer( distance = 4, ): Promise { const player = mineflayer.bot.players[username]?.entity - const movements = new Movements(mineflayer.bot) - if (!player) { - log(mineflayer, `Could not find ${username}.`) return false } - log(mineflayer, `I am now following ${username}.`) + const movements = new Movements(mineflayer.bot) + mineflayer.bot.pathfinder.setMovements(movements) + mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) + log(mineflayer, `You are now actively following player ${username}.`) - return new Promise((resolve) => { - let isFollowing = true + let shouldInterrupt = false - // Stop following when interrupted - mineflayer.once('interrupt', () => { - isFollowing = false - resolve(true) - }) - - // Follow player at regular intervals - const follow = async (): Promise => { - while (isFollowing) { - const target = mineflayer.bot.players[username]?.entity - if (!target) { - log(mineflayer, 'I lost sight of you!') - isFollowing = false - resolve(false) - return - } - - const { x, y, z } = target.position - mineflayer.bot.pathfinder.setMovements(movements) - mineflayer.bot.pathfinder.setGoal(new goals.GoalNear(x, y, z, distance)) - // await sleep(500) - } - } - - follow() + mineflayer.on('interrupt', () => { + shouldInterrupt = true }) + + async function follow() { + // eslint-disable-next-line no-unmodified-loop-condition + while (!shouldInterrupt) { + await sleep(500) + + if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { + await goToPlayer(mineflayer, username) + } + + // if (mineflayer.bot.modes?.isOn('unstuck')) { + // const isNearby = mineflayer.bot.entity.position.distanceTo(player.position) <= distance + 1 + // if (isNearby) { + // mineflayer.bot.modes.pause('unstuck') + // } else { + // mineflayer.bot.modes.unpause('unstuck') + // } + // } + } + } + + follow() + + return true } export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 1ebc0906f..9583cb206 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -3,12 +3,6 @@ */ import type { Bot } from 'mineflayer' import minecraftData from 'minecraft-data' -import { createBot } from 'mineflayer' -import armorManager from 'mineflayer-armor-manager' -import { loader as autoEat } from 'mineflayer-auto-eat' -import { plugin as collectblock } from 'mineflayer-collectblock' -import { pathfinder } from 'mineflayer-pathfinder' -import { plugin as pvp } from 'mineflayer-pvp' import prismarine_items from 'prismarine-item' import { botConfig } from '../composables/config' From 8de616b37137a25221953c8577b04c924376f5cd Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 16:03:07 +0800 Subject: [PATCH 48/77] fix: impl --- services/minecraft/src/mineflayer/echo.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/services/minecraft/src/mineflayer/echo.ts b/services/minecraft/src/mineflayer/echo.ts index aa6799a9a..cf03105a7 100644 --- a/services/minecraft/src/mineflayer/echo.ts +++ b/services/minecraft/src/mineflayer/echo.ts @@ -1,27 +1,23 @@ -import type { Mineflayer } from '../libs/mineflayer' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' import { formBotChat } from '../libs/mineflayer/message' export function Echo(): MineflayerPlugin { - let mineflayer: Mineflayer - let onChatHandler: (username: string, message: string) => void const logger = useLogg('Echo').useGlobalConfig() return { - created(_mineflayer) { - mineflayer = _mineflayer - onChatHandler = formBotChat(mineflayer.username, (username, message) => { + spawned(mineflayer) { + const onChatHandler = formBotChat(mineflayer.username, (username, message) => { logger.withFields({ username, message }).log('Chat message received') mineflayer.bot.chat(message) }) - }, - spawned() { + + this.beforeCleanup = () => { + mineflayer.bot.removeListener('chat', onChatHandler) + } + mineflayer.bot.on('chat', onChatHandler) }, - beforeCleanup() { - mineflayer.bot.removeListener('chat', onChatHandler) - }, } } From d35e98c4586118e695cea57eb8bd6fede77232db Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 18:27:28 +0800 Subject: [PATCH 49/77] fix: mcdata? --- services/minecraft/src/utils/mcdata.ts | 28 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 9583cb206..adbde4ead 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -10,9 +10,11 @@ const mc_version = botConfig.version! const mcdata = minecraftData(mc_version) const Item = prismarine_items(mc_version) -interface Recipe { +interface MinecraftRecipe { + result: { id: number, count: number } inShape?: Array> ingredients?: Array<{ id: number, count: number }> + requiresTable?: boolean } export const WOOD_TYPES: string[] = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak'] @@ -144,24 +146,24 @@ export function getItemCraftingRecipes(itemName: string): Record return null } - // todo: fix this const recipes: Record[] = [] - for (const r of mcdata.recipes[itemId]) { + for (const r of mcdata.recipes[itemId] as MinecraftRecipe[]) { const recipe: Record = {} - let ingredients = [] + let ingredients: Array<{ id: number, count: number }> = [] + if (r.ingredients) { ingredients = r.ingredients } else if (r.inShape) { ingredients = r.inShape.flat() } + for (const ingredient of ingredients) { - const ingredientName = getItemName(ingredient) + const ingredientName = getItemName(ingredient.id) if (ingredientName === null) continue - if (!recipe[ingredientName]) - recipe[ingredientName] = 0 - recipe[ingredientName]++ + recipe[ingredientName] ??= 0 + recipe[ingredientName] += ingredient.count } recipes.push(recipe) } @@ -244,14 +246,18 @@ export function getBlockTool(blockName: string): string | null { if (!block || !block.harvestTools) { return null } - return getItemName(Object.keys(block.harvestTools)[0]) // Double check first tool is always simplest + const toolId = Number(Object.keys(block.harvestTools)[0]) + return getItemName(toolId) } export function makeItem(name: string, amount: number = 1): any { - return new Item(getItemId(name), amount) + const itemId = getItemId(name) + if (itemId === null) + throw new Error(`Unknown item: ${name}`) + return new Item(itemId, amount) } -export function ingredientsFromPrismarineRecipe(recipe: Recipe): Record { +export function ingredientsFromPrismarineRecipe(recipe: MinecraftRecipe): Record { const requiredIngredients: Record = {} if (recipe.inShape) { for (const ingredient of recipe.inShape.flat()) { From d94dfd4e8c0f3d9fea837afdb948c29e0e2e249c Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 17:50:38 +0800 Subject: [PATCH 50/77] chore: slight change --- services/minecraft/src/libs/mineflayer/index.ts | 4 ++-- services/minecraft/src/mineflayer/llm-agent.ts | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index fcb6f9e28..8f3573a1f 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -38,8 +38,8 @@ export class Health { } } -abstract class OneLinerable { - public abstract toOneLiner(): string +interface OneLinerable { + toOneLiner: () => string } export class Status implements OneLinerable { diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 93ea67862..f790ad4d6 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -11,8 +11,7 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { async created(bot) { const agent = options.agent - const logger = useLogg('aichat').useGlobalConfig() - logger.log('Loading aichat plugin') + const logger = useLogg('LLMAgent').useGlobalConfig() bot.memory.chatHistory.push(system(genActionAgentPrompt(bot))) @@ -23,13 +22,10 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { bot.memory.chatHistory.push(user(`${username}: ${message}`)) const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { - logger.log('Generate response') + logger.log('thinking...') try { const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } - - logger.withFields({ completion }).log('Completion') - if (!completion || 'error' in completion) { logger.withFields(c).error('Completion') return @@ -37,17 +33,18 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { } const content = await completion?.firstContent() + logger.withFields({ usage: completion.usage, content }).log('output') bot.memory.chatHistory.push(assistant(content)) return content } catch (e) { - logger.errorWithError('Generate response error', e) + logger.errorWithError('failed to think of an action', e) } }) if (content) { - logger.withFields({ content }).log('Bot response') + logger.withFields({ content }).log('responded') bot.bot.chat(content) } }) From eb7bfcc52e7a1fb21573106f5e5810a934a7197b Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 18:40:12 +0800 Subject: [PATCH 51/77] refactor: with loads of better utils --- services/minecraft/src/composables/world.ts | 47 +- .../src/skills/actions/collectBlock.ts | 166 ++++++ .../minecraft/src/skills/actions/ensure.ts | 500 ++++++++++++++++++ .../src/skills/actions/gatherWood.ts | 100 ++++ .../minecraft/src/skills/actions/inventory.ts | 303 +++++++++++ .../src/skills/actions/world-interactions.ts | 372 +++++++++++++ services/minecraft/src/skills/crafting.ts | 398 ++++++++------ services/minecraft/src/skills/inventory.ts | 31 -- services/minecraft/src/skills/movement.ts | 60 ++- services/minecraft/src/utils/mcdata.ts | 256 ++++----- 10 files changed, 1862 insertions(+), 371 deletions(-) create mode 100644 services/minecraft/src/skills/actions/collectBlock.ts create mode 100644 services/minecraft/src/skills/actions/ensure.ts create mode 100644 services/minecraft/src/skills/actions/gatherWood.ts create mode 100644 services/minecraft/src/skills/actions/inventory.ts create mode 100644 services/minecraft/src/skills/actions/world-interactions.ts diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index fae3c94ec..db4719ca4 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -6,25 +6,52 @@ import type { Mineflayer } from '../libs/mineflayer' import pf from 'mineflayer-pathfinder' import * as mc from '../utils/mcdata' -export function getNearestFreeSpace(mineflayer: Mineflayer, size: number = 1, distance: number = 8): Vec3 | undefined { - const emptyPositions = mineflayer.bot.findBlocks({ - matching: (block: Block) => block?.name === 'air', +export function getNearestFreeSpace( + mineflayer: Mineflayer, + size: number = 1, + distance: number = 8, +): Vec3 | undefined { + /** + * Get the nearest empty space with solid blocks beneath it of the given size. + * @param {number} size - The (size x size) of the space to find, default 1. + * @param {number} distance - The maximum distance to search, default 8. + * @returns {Vec3} - The south west corner position of the nearest free space. + * @example + * let position = world.getNearestFreeSpace( 1, 8); + */ + const empty_pos = mineflayer.bot.findBlocks({ + matching: (block: Block | null) => { + return block !== null && block.name === 'air' + }, maxDistance: distance, count: 1000, }) - return emptyPositions.find((pos) => { + for (let i = 0; i < empty_pos.length; i++) { + let empty = true for (let x = 0; x < size; x++) { for (let z = 0; z < size; z++) { - const top = mineflayer.bot.blockAt(pos.offset(x, 0, z)) - const bottom = mineflayer.bot.blockAt(pos.offset(x, -1, z)) - if (!top || top.name !== 'air' || !bottom?.drops?.length || !bottom.diggable) { - return false + const top = mineflayer.bot.blockAt(empty_pos[i].offset(x, 0, z)) + const bottom = mineflayer.bot.blockAt(empty_pos[i].offset(x, -1, z)) + if ( + !top + || top.name !== 'air' + || !bottom + || (bottom.drops?.length ?? 0) === 0 + || !bottom.diggable + ) { + empty = false + break } } + if (!empty) + break } - return true - }) + if (empty) { + return empty_pos[i] + } + } + return undefined } export function getNearestBlocks(mineflayer: Mineflayer, blockTypes: string[] | string | null = null, distance: number = 16, count: number = 10000): Block[] { diff --git a/services/minecraft/src/skills/actions/collectBlock.ts b/services/minecraft/src/skills/actions/collectBlock.ts new file mode 100644 index 000000000..e88f209c4 --- /dev/null +++ b/services/minecraft/src/skills/actions/collectBlock.ts @@ -0,0 +1,166 @@ +import type { Block } from 'prismarine-block' +import type { Mineflayer } from '../../libs/mineflayer' +import { useLogg } from '@guiiai/logg' +import pathfinder from 'mineflayer-pathfinder' +import { getNearestBlocks } from '../../composables/world' +import { breakBlockAt } from '../blocks' +import { ensurePickaxe } from './ensure' +import { pickupNearbyItems } from './world-interactions' + +const logger = useLogg('Action:CollectBlock').useGlobalConfig() + +export async function collectBlock( + mineflayer: Mineflayer, + blockType: string, + num = 1, + range = 16, +): Promise { + if (num < 1) { + logger.log(`Invalid number of blocks to collect: ${num}.`) + return false + } + + const blockTypes = [blockType] + + // Add block variants + if ( + [ + 'coal', + 'diamond', + 'emerald', + 'iron', + 'gold', + 'lapis_lazuli', + 'redstone', + 'copper', + ].includes(blockType) + ) { + blockTypes.push(`${blockType}_ore`, `deepslate_${blockType}_ore`) + } + if (blockType.endsWith('ore')) { + blockTypes.push(`deepslate_${blockType}`) + } + if (blockType === 'dirt') { + blockTypes.push('grass_block') + } + + let collected = 0 + + while (collected < num) { + const blocks = getNearestBlocks(mineflayer, blockTypes, range) + + if (blocks.length === 0) { + if (collected === 0) + logger.log(`No ${blockType} nearby to collect.`) + else logger.log(`No more ${blockType} nearby to collect.`) + break + } + + const block = blocks[0] + + try { + // Equip appropriate tool + if (mineflayer.bot.game.gameMode !== 'creative') { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null + if (!block.canHarvest(itemId)) { + logger.log(`Don't have right tools to harvest ${block.name}.`) + if (block.name.includes('ore') || block.name.includes('stone')) { + await ensurePickaxe(mineflayer) + } + throw new Error('Don\'t have right tools to harvest block.') + } + } + + // Implement vein mining + const veinBlocks = findVeinBlocks(mineflayer, block, 100, range, 1) + + for (const veinBlock of veinBlocks) { + if (collected >= num) + break + + // Move to the block using pathfinder + const goal = new pathfinder.goals.GoalGetToBlock( + veinBlock.position.x, + veinBlock.position.y, + veinBlock.position.z, + ) + await mineflayer.bot.pathfinder.goto(goal) + + // Break the block and collect drops + await mineAndCollect(mineflayer, veinBlock) + + collected++ + + // Check if inventory is full + if (mineflayer.bot.inventory.emptySlotCount() === 0) { + logger.log('Inventory is full, cannot collect more items.') + break + } + } + } + catch (err) { + logger.log(`Failed to collect ${blockType}: ${err}.`) + continue + } + } + + logger.log(`Collected ${collected} ${blockType}(s).`) + return collected > 0 +} + +// Helper function to mine a block and collect drops +async function mineAndCollect(mineflayer: Mineflayer, block: Block): Promise { + // Break the block + await breakBlockAt(mineflayer, block.position.x, block.position.y, block.position.z) + // Use your existing function to pick up nearby items + await pickupNearbyItems(mineflayer, 5) +} + +// Function to find connected blocks (vein mining) +function findVeinBlocks( + mineflayer: Mineflayer, + startBlock: Block, + maxBlocks = 100, + maxDistance = 16, + floodRadius = 1, +): Block[] { + const veinBlocks: Block[] = [] + const visited = new Set() + const queue: Block[] = [startBlock] + + while (queue.length > 0 && veinBlocks.length < maxBlocks) { + const block = queue.shift() + if (!block) + continue + const key = block.position.toString() + + if (visited.has(key)) + continue + visited.add(key) + + if (block.name !== startBlock.name) + continue + if (block.position.distanceTo(startBlock.position) > maxDistance) + continue + + veinBlocks.push(block) + + // Check neighboring blocks within floodRadius + for (let dx = -floodRadius; dx <= floodRadius; dx++) { + for (let dy = -floodRadius; dy <= floodRadius; dy++) { + for (let dz = -floodRadius; dz <= floodRadius; dz++) { + if (dx === 0 && dy === 0 && dz === 0) + continue // Skip the current block + const neighborPos = block.position.offset(dx, dy, dz) + const neighborBlock = mineflayer.bot.blockAt(neighborPos) + if (neighborBlock && !visited.has(neighborPos.toString())) { + queue.push(neighborBlock) + } + } + } + } + } + + return veinBlocks +} diff --git a/services/minecraft/src/skills/actions/ensure.ts b/services/minecraft/src/skills/actions/ensure.ts new file mode 100644 index 000000000..6bcc21b7b --- /dev/null +++ b/services/minecraft/src/skills/actions/ensure.ts @@ -0,0 +1,500 @@ +import type { Mineflayer } from '../../libs/mineflayer' +import { useLogg } from '@guiiai/logg' +import { getItemId } from '../../utils/mcdata' +import { craftRecipe } from '../crafting' +import { moveAway } from '../movement' +import { collectBlock } from './collectBlock' +import { gatherWood } from './gatherWood' +import { getItemCount } from './inventory' + +// Constants for crafting and gathering +const PLANKS_PER_LOG = 4 +const STICKS_PER_PLANK = 2 + +const logger = useLogg('Action:Ensure').useGlobalConfig() + +// Helper function to ensure a crafting table +export async function ensureCraftingTable(mineflayer: Mineflayer): Promise { + logger.log('Bot: Checking for a crafting table...') + + let hasCraftingTable = getItemCount(mineflayer, 'crafting_table') > 0 + + if (hasCraftingTable) { + logger.log('Bot: Crafting table is available.') + return true + } + + while (!hasCraftingTable) { + const planksEnsured = await ensurePlanks(mineflayer, 4) + if (!planksEnsured) { + logger.error('Bot: Failed to ensure planks.') + continue + } + + // Craft crafting table + hasCraftingTable = await craftRecipe(mineflayer, 'crafting_table', 1) + if (hasCraftingTable) { + mineflayer.bot.chat('I have made a crafting table.') + logger.log('Bot: Crafting table crafted.') + } + else { + logger.error('Bot: Failed to craft crafting table.') + } + } + + return hasCraftingTable +} + +// Helper function to ensure a specific amount of planks +export async function ensurePlanks(mineflayer: Mineflayer, neededAmount: number): Promise { + logger.log('Bot: Checking for planks...') + + let planksCount = getItemCount(mineflayer, 'planks') + + if (neededAmount < planksCount) { + logger.log('Bot: Have enough planks.') + return true + } + + while (neededAmount > planksCount) { + const logsNeeded = Math.ceil((neededAmount - planksCount) / PLANKS_PER_LOG) + + // Get all available log types in inventory + const availableLogs = mineflayer.bot.inventory + .items() + .filter(item => item.name.includes('log')) + + // If no logs available, gather more wood + if (availableLogs.length === 0) { + await gatherWood(mineflayer, logsNeeded, 80) + logger.error('Bot: Not enough logs for planks.') + continue + } + + // Iterate over each log type to craft planks + for (const log of availableLogs) { + const logType = log.name.replace('_log', '') // Get log type without "_log" suffix + const logsToCraft = Math.min(log.count, logsNeeded) + + logger.log( + `Trying to make ${logsToCraft * PLANKS_PER_LOG} ${logType}_planks`, + ) + logger.log(`NeededAmount: ${neededAmount}, while I have ${planksCount}`) + + const crafted = await craftRecipe( + mineflayer, + `${logType}_planks`, + logsToCraft * PLANKS_PER_LOG, + ) + if (crafted) { + planksCount = getItemCount(mineflayer, 'planks') + mineflayer.bot.chat( + `I have crafted ${logsToCraft * PLANKS_PER_LOG} ${logType} planks.`, + ) + logger.log(`Bot: ${logType} planks crafted.`) + } + else { + logger.error(`Bot: Failed to craft ${logType} planks.`) + return false + } + + // Check if we have enough planks after crafting + if (planksCount >= neededAmount) + break + } + } + + return planksCount >= neededAmount +}; + +// Helper function to ensure a specific amount of sticks +export async function ensureSticks(mineflayer: Mineflayer, neededAmount: number): Promise { + logger.log('Bot: Checking for sticks...') + + let sticksCount = getItemCount(mineflayer, 'stick') + + if (neededAmount <= sticksCount) { + logger.log('Bot: Have enough sticks.') + return true + } + + while (neededAmount >= sticksCount) { + const planksCount = getItemCount(mineflayer, 'planks') + const planksNeeded = Math.max( + Math.ceil((neededAmount - sticksCount) / STICKS_PER_PLANK), + 4, + ) + + if (planksCount >= planksNeeded) { + try { + const sticksId = getItemId('stick') + const recipe = await mineflayer.bot.recipesFor(sticksId, null, 1, null)[0] + await mineflayer.bot.craft(recipe, neededAmount - sticksCount) + sticksCount = getItemCount(mineflayer, 'stick') + mineflayer.bot.chat(`I have made ${Math.abs(neededAmount - sticksCount)} sticks.`) + logger.log(`Bot: Sticks crafted.`) + } + catch (err) { + logger.withError(err).error('Bot: Failed to craft sticks.') + return false + } + } + else { + await ensurePlanks(mineflayer, planksNeeded) + logger.error('Bot: Not enough planks for sticks.') + } + } + + return sticksCount >= neededAmount +} + +// Ensure a specific number of chests +export async function ensureChests(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} chest(s)...`) + + // Count the number of chests the bot already has + let chestCount = getItemCount(mineflayer, 'chest') + + if (chestCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more chest(s).`) + return true + } + + while (chestCount < quantity) { + const planksEnsured = await ensurePlanks(mineflayer, 8 * quantity) // 8 planks per chest + if (!planksEnsured) { + logger.error('Bot: Failed to ensure planks for chest(s).') + continue + } + + // Craft the chest(s) + const crafted = await craftRecipe(mineflayer, 'chest', quantity - chestCount) + if (crafted) { + chestCount = getItemCount(mineflayer, 'chest') + mineflayer.bot.chat(`I have crafted ${quantity} chest(s).`) + logger.log(`Bot: ${quantity} chest(s) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft chest(s).') + } + } + return chestCount >= quantity +} + +// Ensure a specific number of furnaces +export async function ensureFurnaces(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} furnace(s)...`) + + // Count the number of furnaces the bot already has + let furnaceCount = getItemCount(mineflayer, 'furnace') + + if (furnaceCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more furnace(s).`) + return true + } + + while (furnaceCount < quantity) { + const stoneEnsured = await ensureCobblestone(mineflayer, 8 * (quantity - furnaceCount)) // 8 stone blocks per furnace + if (!stoneEnsured) { + logger.error('Bot: Failed to ensure stone for furnace(s).') + continue + } + + // Craft the furnace(s) + const crafted = await craftRecipe(mineflayer, 'furnace', quantity - furnaceCount) + if (crafted) { + furnaceCount = getItemCount(mineflayer, 'furnace') + mineflayer.bot.chat(`I have crafted ${quantity} furnace(s).`) + logger.log(`Bot: ${quantity} furnace(s) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft furnace(s).') + } + } + return furnaceCount >= quantity +} + +// Ensure a specific number of torches +export async function ensureTorches(mineflayer: Mineflayer, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} torch(es)...`) + + // Count the number of torches the bot already has + let torchCount = getItemCount(mineflayer, 'torch') + + if (torchCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more torch(es).`) + return true + } + + while (torchCount < quantity) { + const sticksEnsured = await ensureSticks(mineflayer, quantity - torchCount) // 1 stick per 4 torches + const coalEnsured = await ensureCoal( + mineflayer, + Math.ceil((quantity - torchCount) / 4), + ) // 1 coal per 4 torches + + if (!sticksEnsured || !coalEnsured) { + logger.error('Bot: Failed to ensure sticks or coal for torch(es).') + continue + } + + // Craft the torch(es) + const crafted = await craftRecipe(mineflayer, 'torch', quantity - torchCount) + if (crafted) { + torchCount = getItemCount(mineflayer, 'torch') + mineflayer.bot.chat(`I have crafted ${quantity} torch(es).`) + logger.log(`Bot: ${quantity} torch(es) crafted.`) + continue + } + else { + logger.error('Bot: Failed to craft torch(es).') + } + } + return torchCount >= quantity +} + +// Ensure a campfire +// Todo: rework +export async function ensureCampfire(mineflayer: Mineflayer): Promise { + logger.log('Bot: Checking for a campfire...') + + const hasCampfire = getItemCount(mineflayer, 'campfire') > 0 + + if (hasCampfire) { + logger.log('Bot: Campfire is already available.') + return true + } + + const logsEnsured = await ensurePlanks(mineflayer, 3) // Need 3 logs for a campfire + const sticksEnsured = await ensureSticks(mineflayer, 3) // Need 3 sticks for a campfire + const coalEnsured = await ensureCoal(mineflayer, 1) // Need 1 coal or charcoal for a campfire + + if (!logsEnsured || !sticksEnsured || !coalEnsured) { + logger.error('Bot: Failed to ensure resources for campfire.') + } + + const crafted = await craftRecipe(mineflayer, 'campfire', 1) + if (crafted) { + mineflayer.bot.chat('I have crafted a campfire.') + logger.log('Bot: Campfire crafted.') + return true + } + else { + logger.error('Bot: Failed to craft campfire.') + } + + return hasCampfire +} + +// Helper function to gather cobblestone +export async function ensureCobblestone(mineflayer: Mineflayer, requiredCobblestone: number, maxDistance: number = 4): Promise { + let cobblestoneCount = getItemCount(mineflayer, 'cobblestone') + + while (cobblestoneCount < requiredCobblestone) { + logger.log('Bot: Gathering more cobblestone...') + const cobblestoneShortage = requiredCobblestone - cobblestoneCount + + try { + const success = await collectBlock( + mineflayer, + 'stone', + cobblestoneShortage, + maxDistance, + ) + if (!success) { + await moveAway(mineflayer, 30) + continue + } + } + catch (err) { + if (err instanceof Error && err.message.includes('right tools')) { + await ensurePickaxe(mineflayer) + continue + } + else { + logger.withError(err).error('Error collecting cobblestone') + await moveAway(mineflayer, 30) + continue + } + } + + cobblestoneCount = getItemCount(mineflayer, 'cobblestone') + } + + logger.log('Bot: Collected enough cobblestone.') + return true +} + +export async function ensureCoal(mineflayer: Mineflayer, neededAmount: number, maxDistance: number = 4): Promise { + logger.log('Bot: Checking for coal...') + let coalCount = getItemCount(mineflayer, 'coal') + + while (coalCount < neededAmount) { + logger.log('Bot: Gathering more coal...') + const coalShortage = neededAmount - coalCount + + try { + await collectBlock(mineflayer, 'stone', coalShortage, maxDistance) + } + catch (err) { + if (err instanceof Error && err.message.includes('right tools')) { + await ensurePickaxe(mineflayer) + continue + } + else { + logger.withError(err).error('Error collecting cobblestone:') + moveAway(mineflayer, 30) + continue + } + } + + coalCount = getItemCount(mineflayer, 'cobblestone') + } + + logger.log('Bot: Collected enough cobblestone.') + return true +} + +// Define the valid tool types as a union type +type ToolType = 'pickaxe' | 'sword' | 'axe' | 'shovel' | 'hoe' + +// Define the valid materials as a union type +type MaterialType = 'diamond' | 'golden' | 'iron' | 'stone' | 'wooden' + +// Constants for crafting tools +const TOOLS_MATERIALS: MaterialType[] = [ + 'diamond', + 'golden', + 'iron', + 'stone', + 'wooden', +] + +export function materialsForTool(tool: ToolType): number { + switch (tool) { + case 'pickaxe': + case 'axe': + return 3 + case 'sword': + case 'hoe': + return 2 + case 'shovel': + return 1 + default: + return 0 + } +} + +// Helper function to ensure a specific tool, checking from best materials to wood +async function ensureTool(mineflayer: Mineflayer, toolType: ToolType, quantity: number = 1): Promise { + logger.log(`Bot: Checking for ${quantity} ${toolType}(s)...`) + + const neededMaterials = materialsForTool(toolType) + + // Check how many of the tool the bot currently has + let toolCount = mineflayer.bot.inventory + .items() + .filter(item => item.name.includes(toolType)) + .length + + if (toolCount >= quantity) { + logger.log(`Bot: Already has ${quantity} or more ${toolType}(s).`) + return true + } + + while (toolCount < quantity) { + // Iterate over the tool materials from best (diamond) to worst (wooden) + for (const material of TOOLS_MATERIALS) { + const toolRecipe = `${material}_${toolType}` // Craft tool name like diamond_pickaxe, iron_sword + const hasResources = await hasResourcesForTool(mineflayer, material, neededMaterials) + + // Check if we have enough material for the current tool + if (hasResources) { + await ensureCraftingTable(mineflayer) + + const sticksEnsured = await ensureSticks(mineflayer, 2) + + if (!sticksEnsured) { + logger.error( + `Bot: Failed to ensure planks or sticks for wooden ${toolType}.`, + ) + continue + } + + // Craft the tool + const crafted = await craftRecipe(mineflayer, toolRecipe, 1) + if (crafted) { + toolCount++ + mineflayer.bot.chat( + `I have crafted a ${material} ${toolType}. Total ${toolType}(s): ${toolCount}/${quantity}`, + ) + logger.log( + `Bot: ${material} ${toolType} crafted. Total ${toolCount}/${quantity}`, + ) + if (toolCount >= quantity) + return true + } + else { + logger.error(`Bot: Failed to craft ${material} ${toolType}.`) + } + } + else if (material === 'wooden') { + // Crafting planks if we don't have enough resources for wooden tools + logger.log(`Bot: Crafting planks for ${material} ${toolType}...`) + await ensurePlanks(mineflayer, 4) + } + } + } + + return toolCount >= quantity +} + +// Helper function to check if the bot has enough materials to craft a tool of a specific material +export async function hasResourcesForTool( + mineflayer: Mineflayer, + material: MaterialType, + num = 3, // Number of resources needed for most tools +): Promise { + switch (material) { + case 'diamond': + return getItemCount(mineflayer, 'diamond') >= num + case 'golden': + return getItemCount(mineflayer, 'gold_ingot') >= num + case 'iron': + return getItemCount(mineflayer, 'iron_ingot') >= num + case 'stone': + return getItemCount(mineflayer, 'cobblestone') >= num + case 'wooden': + return getItemCount(mineflayer, 'planks') >= num + default: + return false + } +} + +// Helper functions for specific tools: + +// Ensure a pickaxe +export async function ensurePickaxe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'pickaxe', quantity) +}; + +// Ensure a sword +export async function ensureSword(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'sword', quantity) +}; + +// Ensure an axe +export async function ensureAxe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'axe', quantity) +}; + +// Ensure a shovel +export async function ensureShovel(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'shovel', quantity) +}; + +export async function ensureHoe(mineflayer: Mineflayer, quantity: number = 1): Promise { + return await ensureTool(mineflayer, 'hoe', quantity) +}; diff --git a/services/minecraft/src/skills/actions/gatherWood.ts b/services/minecraft/src/skills/actions/gatherWood.ts new file mode 100644 index 000000000..678468c16 --- /dev/null +++ b/services/minecraft/src/skills/actions/gatherWood.ts @@ -0,0 +1,100 @@ +import type { Mineflayer } from '../../libs/mineflayer' +import { useLogg } from '@guiiai/logg' +import { getNearestBlocks } from '../../composables/world' +import { sleep } from '../../utils/helper' +import { breakBlockAt } from '../blocks' +import { goToPosition, moveAway } from '../movement' +import { pickupNearbyItems } from './world-interactions' + +const logger = useLogg('Action:GatherWood').useGlobalConfig() + +/** + * Gather wood blocks nearby to collect logs. + * + * @param mineflayer The mineflayer instance. + * @param num The number of wood logs to gather. + * @param maxDistance The maximum distance to search for wood blocks. + * @returns Whether the wood gathering was successful. + */ +export async function gatherWood( + mineflayer: Mineflayer, + num: number, + maxDistance = 64, +): Promise { + logger.log(`Gathering wood... I need to collect ${num} logs.`) + mineflayer.bot.chat(`Gathering wood... I need to collect ${num} logs.`) + + try { + let logsCount = getLogsCount(mineflayer) + logger.log(`I currently have ${logsCount} logs.`) + + while (logsCount < num) { + // Gather 1 extra log to account for any failures + logger.log(`Looking for wood blocks nearby...`, logsCount, num) + + const woodBlock = mineflayer.bot.findBlock({ + matching: block => block.name.includes('log'), + maxDistance, + }) + + if (!woodBlock) { + logger.log('No wood blocks found nearby.') + await moveAway(mineflayer, 50) + continue + } + + const destinationReached = await goToPosition( + mineflayer, + woodBlock.position.x, + woodBlock.position.y, + woodBlock.position.z, + 2, + ) + + if (!destinationReached) { + logger.log('Unable to reach the wood block.') + continue // Try finding another wood block + } + + const aTree = await getNearestBlocks(mineflayer, woodBlock.name, 4, 4) + if (aTree.length === 0) { + logger.log('No wood blocks found nearby.') + await moveAway(mineflayer, 15) + continue + } + + try { + for (const aLog of aTree) { + await breakBlockAt(mineflayer, aLog.position.x, aLog.position.y, aLog.position.z) + await sleep(1200) // Simulate gathering delay + } + await pickupNearbyItems(mineflayer) + await sleep(2500) + logsCount = getLogsCount(mineflayer) + logger.log(`Collected logs. Total logs now: ${logsCount}.`) + } + catch (digError) { + console.error('Failed to break the wood block:', digError) + continue // Attempt to find and break another wood block + } + } + + logger.log(`Wood gathering complete! Total logs collected: ${logsCount}.`) + return true + } + catch (error) { + console.error('Failed to gather wood:', error) + return false + } +} + +/** + * Helper function to count the number of logs in the inventory. + * @returns The total number of logs. + */ +export function getLogsCount(mineflayer: Mineflayer): number { + return mineflayer.bot.inventory + .items() + .filter(item => item.name.includes('log')) + .reduce((acc, item) => acc + item.count, 0) +} diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts new file mode 100644 index 000000000..c5efc50fd --- /dev/null +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -0,0 +1,303 @@ +import type { Item } from 'prismarine-item' +import type { Mineflayer } from '../../libs/mineflayer' + +import { useLogg } from '@guiiai/logg' +import { getNearestBlock } from '../../composables/world' +import { goToPlayer, goToPosition } from '../movement' + +const logger = useLogg('Action:Inventory').useGlobalConfig() + +/** + * Equip an item from the bot's inventory. + * @param mineflayer The mineflayer instance. + * @param itemName The name of the item to equip. + * @returns Whether the item was successfully equipped. + */ +export async function equip(mineflayer: Mineflayer, itemName: string): Promise { + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`You do not have any ${itemName} to equip.`) + return false + } + let destination: 'hand' | 'head' | 'torso' | 'legs' | 'feet' = 'hand' + if (itemName.includes('leggings')) + destination = 'legs' + else if (itemName.includes('boots')) + destination = 'feet' + else if (itemName.includes('helmet')) + destination = 'head' + else if (itemName.includes('chestplate')) + destination = 'torso' + + await mineflayer.bot.equip(item, destination) + return true +} + +/** + * Discard an item from the bot's inventory. + * @param mineflayer The mineflayer instance. + * @param itemName The name of the item to discard. + * @param num The number of items to discard. Default is -1 for all. + * @returns Whether the item was successfully discarded. + */ +export async function discard(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + let discarded = 0 + while (true) { + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + break + } + const toDiscard + = num === -1 ? item.count : Math.min(num - discarded, item.count) + await mineflayer.bot.toss(item.type, null, toDiscard) + discarded += toDiscard + if (num !== -1 && discarded >= num) { + break + } + } + if (discarded === 0) { + logger.log(`You do not have any ${itemName} to discard.`) + return false + } + logger.log(`Successfully discarded ${discarded} ${itemName}.`) + return true +} + +export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + const item = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`You do not have any ${itemName} to put in the chest.`) + return false + } + const toPut = num === -1 ? item.count : Math.min(num, item.count) + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + await chestContainer.deposit(item.type, null, toPut) + await chestContainer.close() + logger.log(`Successfully put ${toPut} ${itemName} in the chest.`) + return true +} + +export async function takeFromChest( + mineflayer: Mineflayer, + itemName: string, + num = -1, +): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + const item = chestContainer + .containerItems() + .find(item => item.name.includes(itemName)) + if (!item) { + logger.log(`Could not find any ${itemName} in the chest.`) + await chestContainer.close() + return false + } + const toTake = num === -1 ? item.count : Math.min(num, item.count) + await chestContainer.withdraw(item.type, null, toTake) + await chestContainer.close() + logger.log(`Successfully took ${toTake} ${itemName} from the chest.`) + return true +} + +/** + * View the contents of a chest near the bot. + * @param mineflayer The mineflayer instance. + * @returns Whether the chest was successfully viewed. + */ +export async function viewChest(mineflayer: Mineflayer): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + const items = chestContainer.containerItems() + if (items.length === 0) { + logger.log(`The chest is empty.`) + } + else { + logger.log(`The chest contains:`) + for (const item of items) { + logger.log(`${item.count} ${item.name}`) + } + } + await chestContainer.close() + return true +} + +/** + * Ask to bot to eat a food item from its inventory. + * @param mineflayer The mineflayer instance. + * @param foodName The name of the food item to eat. + * @returns Whether the food was successfully eaten. + */ +export async function eat(mineflayer: Mineflayer, foodName = ''): Promise { + let item: Item | undefined + let name: string + if (foodName) { + item = mineflayer.bot.inventory.items().find(item => item.name.includes(foodName)) + name = foodName + } + else { + // @ts-expect-error -- ? + item = mineflayer.bot.inventory.items().find(item => item.foodPoints > 0) + name = 'food' + } + if (!item) { + logger.log(`You do not have any ${name} to eat.`) + return false + } + await mineflayer.bot.equip(item, 'hand') + await mineflayer.bot.consume() + logger.log(`Successfully ate ${item.name}.`) + return true +} + +/** + * Give an item to a player. + * @param mineflayer The mineflayer instance. + * @param itemType The name of the item to give. + * @param username The username of the player to give the item to. + * @param num The number of items to give. + * @returns Whether the item was successfully given. + */ +export async function giveToPlayer( + mineflayer: Mineflayer, + itemType: string, + username: string, + num = 1, +): Promise { + const player = mineflayer.bot.players[username]?.entity + if (!player) { + logger.log(`Could not find a player with username: ${username}.`) + return false + } + await goToPlayer(mineflayer, username) + await mineflayer.bot.lookAt(player.position) + await discard(mineflayer, itemType, num) + return true +} + +/** + * List the items in the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns An array of items in the bot's inventory. + */ +export async function listInventory(mineflayer: Mineflayer): Promise<{ name: string, count: number }[]> { + const items = await mineflayer.bot.inventory.items() + sayItems(mineflayer, items) + + return items.map(item => ({ + name: item.name, + count: item.count, + })) +} + +export async function checkForItem(mineflayer: Mineflayer, itemName: string): Promise { + const items = await mineflayer.bot.inventory.items() + const searchableItems = items.filter(item => item.name.includes(itemName)) + sayItems(mineflayer, searchableItems) +} + +export async function sayItems(mineflayer: Mineflayer, items: Array | null = null) { + if (!items) { + items = mineflayer.bot.inventory.items() + if (mineflayer.bot.registry.isNewerOrEqualTo('1.9') && mineflayer.bot.inventory.slots[45]) + items.push(mineflayer.bot.inventory.slots[45]) + } + const output = items.map(item => `${item.name} x ${item.count}`).join(', ') + if (output) { + mineflayer.bot.chat(`My inventory contains: ${output}`) + } + else { + mineflayer.bot.chat('My inventory is empty.`') + } +} + +/** + * Find the number of free slots in the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns The number of free slots in the bot's inventory. + */ +export function checkFreeSpace(mineflayer: Mineflayer): number { + const totalSlots = mineflayer.bot.inventory.slots.length + const usedSlots = mineflayer.bot.inventory.items().length + const freeSlots = totalSlots - usedSlots + logger.log(`You have ${freeSlots} free slots in your inventory.`) + return freeSlots +} + +/** + * Transfer all items from the bot's inventory to a chest. + * @param mineflayer The mineflayer instance. + * @returns Whether the items were successfully transferred. + */ +export async function transferAllToChest(mineflayer: Mineflayer): Promise { + const chest = getNearestBlock(mineflayer, 'chest', 32) + if (!chest) { + logger.log(`Could not find a chest nearby.`) + return false + } + await goToPosition(mineflayer, chest.position.x, chest.position.y, chest.position.z) + const chestContainer = await mineflayer.bot.openContainer(chest) + + for (const item of mineflayer.bot.inventory.items()) { + await chestContainer.deposit(item.type, null, item.count) + logger.log(`Put ${item.count} ${item.name} in the chest.`) + } + + await chestContainer.close() + return true +} + +/** + * Utility function to get item count in inventory + * @param mineflayer The mineflayer instance. + * @param itemName - The name of the item to count. + * @returns number of items in inventory + */ +export function getItemCount(mineflayer: Mineflayer, itemName: string): number { + return mineflayer.bot.inventory + .items() + .filter(item => item.name.includes(itemName)) + .reduce((acc, item) => acc + item.count, 0) +} + +/** + * Organize the bot's inventory. + * @param mineflayer The mineflayer instance. + * @returns Whether the inventory was successfully organized. + */ +export async function organizeInventory(mineflayer: Mineflayer): Promise { + const items = mineflayer.bot.inventory.items() + if (items.length === 0) { + logger.log(`Inventory is empty, nothing to organize.`) + return + } + + for (const item of items) { + await mineflayer.bot.moveSlotItem( + item.slot, + mineflayer.bot.inventory.findInventoryItem(item.type, null, false)?.slot ?? item.slot, + ) + } + logger.log(`Inventory has been organized.`) +} diff --git a/services/minecraft/src/skills/actions/world-interactions.ts b/services/minecraft/src/skills/actions/world-interactions.ts new file mode 100644 index 000000000..c4d1b77b7 --- /dev/null +++ b/services/minecraft/src/skills/actions/world-interactions.ts @@ -0,0 +1,372 @@ +import type { Bot } from 'mineflayer' +import type { Block } from 'prismarine-block' +import type { Mineflayer } from '../../libs/mineflayer' +import { useLogg } from '@guiiai/logg' +import pathfinder from 'mineflayer-pathfinder' +import { Vec3 } from 'vec3' +import { sleep } from '../../utils/helper' +import { getNearestBlock, makeItem } from '../../utils/mcdata' +import { goToPosition } from '../movement' + +const logger = useLogg('Action:WorldInteractions').useGlobalConfig() + +export async function placeBlock( + mineflayer: Mineflayer, + blockType: string, + x: number, + y: number, + z: number, + placeOn: string = 'bottom', +): Promise { + // if (!gameData.getBlockId(blockType)) { + // logger.log(`Invalid block type: ${blockType}.`); + // return false; + // } + + const targetDest = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + + let block = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(blockType)) + if (!block && mineflayer.bot.game.gameMode === 'creative') { + // TODO: Rework + await mineflayer.bot.creative.setInventorySlot(36, makeItem(blockType, 1)) // 36 is first hotbar slot + block = mineflayer.bot.inventory.items().find(item => item.name.includes(blockType)) + } + if (!block) { + logger.log(`Don't have any ${blockType} to place.`) + return false + } + + const targetBlock = mineflayer.bot.blockAt(targetDest) + if (!targetBlock) { + logger.log(`No block found at ${targetDest}.`) + return false + } + + if (targetBlock.name === blockType) { + logger.log(`${blockType} already at ${targetBlock.position}.`) + return false + } + + const emptyBlocks = [ + 'air', + 'water', + 'lava', + 'grass', + 'tall_grass', + 'snow', + 'dead_bush', + 'fern', + ] + if (!emptyBlocks.includes(targetBlock.name)) { + logger.log( + `${targetBlock.name} is in the way at ${targetBlock.position}.`, + ) + const removed = await breakBlockAt(mineflayer, x, y, z) + if (!removed) { + logger.log( + `Cannot place ${blockType} at ${targetBlock.position}: block in the way.`, + ) + return false + } + await new Promise(resolve => setTimeout(resolve, 200)) // Wait for block to break + } + + // Determine the build-off block and face vector + const dirMap: { [key: string]: Vec3 } = { + top: new Vec3(0, 1, 0), + bottom: new Vec3(0, -1, 0), + north: new Vec3(0, 0, -1), + south: new Vec3(0, 0, 1), + east: new Vec3(1, 0, 0), + west: new Vec3(-1, 0, 0), + } + + const dirs: Vec3[] = [] + if (placeOn === 'side') { + dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west) + } + else if (dirMap[placeOn]) { + dirs.push(dirMap[placeOn]) + } + else { + dirs.push(dirMap.bottom) + logger.log(`Unknown placeOn value "${placeOn}". Defaulting to bottom.`) + } + + // Add remaining directions + dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d))) + + let buildOffBlock: Block | null = null + let faceVec: Vec3 | null = null + + for (const d of dirs) { + const adjacentBlock = mineflayer.bot.blockAt(targetDest.plus(d)) + if (adjacentBlock && !emptyBlocks.includes(adjacentBlock.name)) { + buildOffBlock = adjacentBlock + faceVec = d.scaled(-1) // Invert direction + break + } + } + + if (!buildOffBlock || !faceVec) { + logger.log( + `Cannot place ${blockType} at ${targetBlock.position}: nothing to place on.`, + ) + return false + } + + // Move away if too close + const pos = mineflayer.bot.entity.position + const posAbove = pos.offset(0, 1, 0) + const dontMoveFor = [ + 'torch', + 'redstone_torch', + 'redstone', + 'lever', + 'button', + 'rail', + 'detector_rail', + 'powered_rail', + 'activator_rail', + 'tripwire_hook', + 'tripwire', + 'water_bucket', + ] + if ( + !dontMoveFor.includes(blockType) + && (pos.distanceTo(targetBlock.position) < 1 + || posAbove.distanceTo(targetBlock.position) < 1) + ) { + const goal = new pathfinder.goals.GoalInvert( + new pathfinder.goals.GoalNear( + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 2, + ), + ) + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto(goal) + } + + // Move closer if too far + if (mineflayer.bot.entity.position.distanceTo(targetBlock.position) > 4.5) { + await goToPosition( + mineflayer, + targetBlock.position.x, + targetBlock.position.y, + targetBlock.position.z, + 4, + ) + } + + await mineflayer.bot.equip(block, 'hand') + await mineflayer.bot.lookAt(buildOffBlock.position) + await sleep(500) + + try { + await mineflayer.bot.placeBlock(buildOffBlock, faceVec) + logger.log(`Placed ${blockType} at ${targetDest}.`) + await new Promise(resolve => setTimeout(resolve, 200)) + return true + } + catch (err) { + if (err instanceof Error) { + logger.log( + `Failed to place ${blockType} at ${targetDest}: ${err.message}`, + ) + } + else { + logger.log( + `Failed to place ${blockType} at ${targetDest}: ${String(err)}`, + ) + } + return false + } +} + +export async function breakBlockAt( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, +): Promise { + if (x == null || y == null || z == null) { + throw new Error('Invalid position to break block at.') + } + const blockPos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) + const block = mineflayer.bot.blockAt(blockPos) + if (!block) { + logger.log(`No block found at position ${blockPos}.`) + return false + } + if (block.name !== 'air' && block.name !== 'water' && block.name !== 'lava') { + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(mineflayer, x, y, z) + } + if (mineflayer.bot.game.gameMode !== 'creative') { + await mineflayer.bot.tool.equipForBlock(block) + const itemId = mineflayer.bot.heldItem ? mineflayer.bot.heldItem.type : null + if (!block.canHarvest(itemId)) { + logger.log(`Don't have right tools to break ${block.name}.`) + return false + } + } + if (!mineflayer.bot.canDigBlock(block)) { + logger.log(`Cannot break ${block.name} at ${blockPos}.`) + return false + } + await mineflayer.bot.lookAt(block.position, true) // Ensure the bot has finished turning + await sleep(500) + try { + await mineflayer.bot.dig(block, true) + logger.log( + `Broke ${block.name} at x:${x.toFixed(1)}, y:${y.toFixed( + 1, + )}, z:${z.toFixed(1)}.`, + ) + return true + } + catch (err) { + console.error(`Failed to dig the block: ${err}`) + return false + } + } + else { + logger.log( + `Skipping block at x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed( + 1, + )} because it is ${block.name}.`, + ) + return false + } +} + +export async function activateNearestBlock(mineflayer: Mineflayer, type: string) { + /** + * Activate the nearest block of the given type. + * @param {string} type, the type of block to activate. + * @returns {Promise} true if the block was activated, false otherwise. + * @example + * await skills.activateNearestBlock( "lever"); + * + */ + const block = getNearestBlock(mineflayer.bot, type, 16) + if (!block) { + logger.log(`Could not find any ${type} to activate.`) + return false + } + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + const pos = block.position + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto(new pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4)) + } + await mineflayer.bot.activateBlock(block) + logger.log( + `Activated ${type} at x:${block.position.x.toFixed( + 1, + )}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`, + ) + return true +} + +export async function tillAndSow( + mineflayer: Mineflayer, + x: number, + y: number, + z: number, + seedType: string | null = null, +): Promise { + x = Math.round(x) + y = Math.round(y) + z = Math.round(z) + const blockPos = new Vec3(x, y, z) + const block = mineflayer.bot.blockAt(blockPos) + if (!block) { + logger.log(`No block found at ${blockPos}.`) + return false + } + if ( + block.name !== 'grass_block' + && block.name !== 'dirt' + && block.name !== 'farmland' + ) { + logger.log(`Cannot till ${block.name}, must be grass_block or dirt.`) + return false + } + const above = mineflayer.bot.blockAt(blockPos.offset(0, 1, 0)) + if (above && above.name !== 'air') { + logger.log(`Cannot till, there is ${above.name} above the block.`) + return false + } + // Move closer if too far + if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) { + await goToPosition(mineflayer, x, y, z, 4) + } + if (block.name !== 'farmland') { + const hoe = mineflayer.bot.inventory.items().find(item => item.name.includes('hoe')) + if (!hoe) { + logger.log(`Cannot till, no hoes.`) + return false + } + await mineflayer.bot.equip(hoe, 'hand') + await mineflayer.bot.activateBlock(block) + logger.log( + `Tilled block x:${x.toFixed(1)}, y:${y.toFixed(1)}, z:${z.toFixed(1)}.`, + ) + } + + if (seedType) { + if (seedType.endsWith('seed') && !seedType.endsWith('seeds')) + seedType += 's' // Fixes common mistake + const seeds = mineflayer.bot.inventory + .items() + .find(item => item.name.includes(seedType || 'seed')) + if (!seeds) { + logger.log(`No ${seedType} to plant.`) + return false + } + await mineflayer.bot.equip(seeds, 'hand') + await mineflayer.bot.placeBlock(block, new Vec3(0, -1, 0)) + logger.log( + `Planted ${seedType} at x:${x.toFixed(1)}, y:${y.toFixed( + 1, + )}, z:${z.toFixed(1)}.`, + ) + } + return true +} + +export async function pickupNearbyItems( + mineflayer: Mineflayer, + distance = 8, +): Promise { + const getNearestItem = (bot: Bot) => + bot.nearestEntity( + entity => + entity.name === 'item' + && entity.onGround + && bot.entity.position.distanceTo(entity.position) < distance, + ) + let nearestItem = getNearestItem(mineflayer.bot) + + let pickedUp = 0 + while (nearestItem) { + // bot.pathfinder.setMovements(new pf.Movements(bot)); + await mineflayer.bot.pathfinder.goto( + new pathfinder.goals.GoalFollow(nearestItem, 0.8), + () => {}, + ) + await sleep(500) + const prev = nearestItem + nearestItem = getNearestItem(mineflayer.bot) + if (prev === nearestItem) { + break + } + pickedUp++ + } + logger.log(`Picked up ${pickedUp} items.`) + return true +} diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index a1efa06a1..795d75eff 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -1,281 +1,343 @@ +import type { Block } from 'prismarine-block' +import type { Item } from 'prismarine-item' +import type { Recipe } from 'prismarine-recipe' import type { Mineflayer } from '../libs/mineflayer' +import { useLogg } from '@guiiai/logg' import * as world from '../composables/world' +import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from '../composables/world' import * as mc from '../utils/mcdata' -import { log } from './base' +import { ensureCraftingTable } from './actions/ensure' import { collectBlock, placeBlock } from './blocks' -import { goToPosition } from './movement' +import { goToNearestBlock, goToPosition, moveAway } from './movement' -export async function craftRecipe(mineflayer: Mineflayer, itemName: string, num = 1): Promise { - let placedTable = false +const logger = useLogg('Skill:Crafting').useGlobalConfig() - if (mc.getItemCraftingRecipes(itemName)?.length === 0) { - log(mineflayer, `${itemName} is either not an item, or it does not have a crafting recipe!`) - return false - } +/* +Possible Scenarios: + +1. **Successful Craft Without Crafting Table**: + - The bot attempts to craft the item without a crafting table and succeeds. The function returns `true`. + +2. **Crafting Table Nearby**: + - The bot tries to craft without a crafting table but fails. + - The bot then checks for a nearby crafting table. + - If a crafting table is found, the bot moves to it and successfully crafts the item, returning `true`. + +3. **No Crafting Table Nearby, Place Crafting Table**: + - The bot fails to craft without a crafting table and does not find a nearby crafting table. + - The bot checks inventory for a crafting table, places it at a suitable location, and attempts crafting again. + - If successful, the function returns `true`. If the bot cannot find a suitable position or fails to craft, it returns `false`. + +4. **Insufficient Resources**: + - At any point, if the bot does not have the required resources to craft the item, it logs an appropriate message and returns `false`. + +5. **No Crafting Table and No Suitable Position**: + - If the bot does not find a crafting table and cannot find a suitable position to place one, it moves away and returns `false`. + +6. **Invalid Item Name**: + - If the provided item name is invalid, the function logs the error and returns `false`. +*/ +export async function craftRecipe( + mineflayer: Mineflayer, + incomingItemName: string, + num = 1, +): Promise { + let itemName = incomingItemName.replace(' ', '_').toLowerCase() + + if (itemName.endsWith('plank')) + itemName += 's' // Correct common mistakes - // Get recipes that don't require a crafting table const itemId = mc.getItemId(itemName) if (itemId === null) { - log(mineflayer, `Invalid item name: ${itemName}`) + logger.log(`Invalid item name: ${itemName}`) return false } - let recipes = mineflayer.bot.recipesFor(itemId, null, 1, null) - let craftingTable = null - const craftingTableRange = 32 - - if (!recipes || recipes.length === 0) { - recipes = mineflayer.bot.recipesFor(itemId, null, 1, true) - if (!recipes || recipes.length === 0) { - log(mineflayer, `You do not have the resources to craft a ${itemName}.`) - return false - } - - // Look for crafting table - craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) - if (!craftingTable) { - // Try to place crafting table - const inventory = world.getInventoryCounts(mineflayer) - const hasTable = inventory.crafting_table > 0 - if (hasTable) { - const pos = world.getNearestFreeSpace(mineflayer, 1, 6) - if (pos) { - await placeBlock(mineflayer, 'crafting_table', pos.x, pos.y, pos.z) - craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) - if (craftingTable) { - recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) - placedTable = true - } - } + // Helper function to attempt crafting + async function attemptCraft( + recipes: Recipe[] | null, + craftingTable: Block | null = null, + ): Promise { + if (recipes && recipes.length > 0) { + const recipe = recipes[0] + try { + await mineflayer.bot.craft(recipe, num, craftingTable ?? undefined) + logger.log( + `Successfully crafted ${num} ${itemName}${ + craftingTable ? ' using crafting table' : '' + }.`, + ) + return true } - else { - log(mineflayer, `Crafting ${itemName} requires a crafting table.`) + catch (err) { + logger.log(`Failed to craft ${itemName}: ${(err as Error).message}`) return false } } - else { - recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) - } - } - - if (!recipes || recipes.length === 0) { - log(mineflayer, `You do not have the resources to craft a ${itemName}. It requires: ${ - Object.entries(mc.getItemCraftingRecipes(itemName)?.[0] ?? {}) - .map(([key, value]) => `${key}: ${value}`) - .join(', ') - }.`) - if (placedTable && craftingTable) { - await collectBlock(mineflayer, 'crafting_table', 1) - } return false } - if (craftingTable && mineflayer.bot.entity.position.distanceTo(craftingTable.position) > 4) { - await goToPosition(mineflayer, craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 4) + // Helper function to move to a crafting table and attempt crafting with retry logic + async function moveToAndCraft(craftingTable: Block): Promise { + logger.log(`Crafting table found, moving to it.`) + const maxRetries = 2 + let attempts = 0 + let success = false + + while (attempts < maxRetries && !success) { + try { + await goToPosition( + mineflayer, + craftingTable.position.x, + craftingTable.position.y, + craftingTable.position.z, + 1, + ) + const recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable) + success = await attemptCraft(recipes, craftingTable) + } + catch (err) { + logger.log( + `Attempt ${attempts + 1} to move to crafting table failed: ${ + (err as Error).message + }`, + ) + } + attempts++ + } + + return success } - const recipe = recipes[0] - // Check that the agent has sufficient items to use the recipe `num` times - const inventory = world.getInventoryCounts(mineflayer) // Items in the agents inventory - const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe) // Items required to use the recipe once - const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients) + // Helper function to find and use or place a crafting table + async function findAndUseCraftingTable( + craftingTableRange: number, + ): Promise { + let craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) + if (craftingTable) { + return await moveToAndCraft(craftingTable) + } - await mineflayer.bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable ?? undefined) + logger.log(`No crafting table nearby, attempting to place one.`) + const hasCraftingTable = await ensureCraftingTable(mineflayer) + if (!hasCraftingTable) { + logger.log(`Failed to ensure a crafting table to craft ${itemName}.`) + return false + } - if (craftLimit.num < num) { - log(mineflayer, `Not enough ${craftLimit.limitingResource} to craft ${num}, crafted ${craftLimit.num}. You now have ${world.getInventoryCounts(mineflayer)[itemName]} ${itemName}.`) - } - else { - log(mineflayer, `Successfully crafted ${itemName}, you now have ${world.getInventoryCounts(mineflayer)[itemName]} ${itemName}.`) + const pos = getNearestFreeSpace(mineflayer, 1, 10) + if (pos) { + moveAway(mineflayer, 4) + logger.log( + `Placing crafting table at position (${pos.x}, ${pos.y}, ${pos.z}).`, + ) + await placeBlock(mineflayer, 'crafting_table', pos.x, pos.y, pos.z) + craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) + if (craftingTable) { + return await moveToAndCraft(craftingTable) + } + } + else { + logger.log('No suitable position found to place the crafting table.') + moveAway(mineflayer, 5) + return false + } + + return false } - if (placedTable && craftingTable) { - await collectBlock(mineflayer, 'crafting_table', 1) + // Step 1: Try to craft without a crafting table + logger.log(`Step 1: Try to craft without a crafting table`) + const recipes = mineflayer.bot.recipesFor(itemId, null, 1, null) + if (recipes && (await attemptCraft(recipes))) { + return true } - // Equip any armor the bot may have crafted - mineflayer.bot.armorManager.equipAll() + // Step 2: Find and use a crafting table + logger.log(`Step 2: Find and use a crafting table`) + const craftingTableRange = 32 + if (await findAndUseCraftingTable(craftingTableRange)) { + return true + } - return true + return false } export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = 1): Promise { - if (!mc.isSmeltable(itemName)) { - log(mineflayer, `Cannot smelt ${itemName}. Hint: make sure you are smelting the 'raw' item.`) + const foods = [ + 'beef', + 'chicken', + 'cod', + 'mutton', + 'porkchop', + 'rabbit', + 'salmon', + 'tropical_fish', + ] + if (!itemName.includes('raw') && !foods.includes(itemName)) { + logger.log( + `Cannot smelt ${itemName}, must be a "raw" item, like "raw_iron".`, + ) return false - } + } // TODO: allow cobblestone, sand, clay, etc. let placedFurnace = false - const furnaceRange = 32 - let furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', furnaceRange) - + let furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) if (!furnaceBlock) { // Try to place furnace - const inventory = world.getInventoryCounts(mineflayer) - const hasFurnace = inventory.furnace > 0 + const hasFurnace = getInventoryCounts(mineflayer).furnace > 0 if (hasFurnace) { - const pos = world.getNearestFreeSpace(mineflayer, 1, furnaceRange) + const pos = getNearestFreeSpace(mineflayer, 1, 32) if (pos) { await placeBlock(mineflayer, 'furnace', pos.x, pos.y, pos.z) - furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', furnaceRange) - placedFurnace = true } + else { + logger.log('No suitable position found to place the furnace.') + return false + } + furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32) + placedFurnace = true } } - if (!furnaceBlock) { - log(mineflayer, 'There is no furnace nearby and you have no furnace.') + logger.log(`There is no furnace nearby and I have no furnace.`) return false } - if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(mineflayer, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) + await goToNearestBlock(mineflayer, 'furnace', 4, 32) } - await mineflayer.bot.lookAt(furnaceBlock.position) + logger.log('smelting...') const furnace = await mineflayer.bot.openFurnace(furnaceBlock) - // Check if the furnace is already smelting something const inputItem = furnace.inputItem() - const itemId = mc.getItemId(itemName) - if (itemId === null) { - log(mineflayer, `Invalid item name: ${itemName}`) - return false - } - - if (inputItem && inputItem.type !== itemId && inputItem.count > 0) { - log(mineflayer, `The furnace is currently smelting ${mc.getItemName(inputItem.type) ?? 'unknown'}.`) - if (placedFurnace) { + if ( + inputItem + && inputItem.type !== mc.getItemId(itemName) + && inputItem.count > 0 + ) { + logger.log( + `The furnace is currently smelting ${mc.getItemName( + inputItem.type, + )}.`, + ) + if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1) - } return false } - // Check if the bot has enough items to smelt - const invCounts = world.getInventoryCounts(mineflayer) + const invCounts = getInventoryCounts(mineflayer) if (!invCounts[itemName] || invCounts[itemName] < num) { - log(mineflayer, `You do not have enough ${itemName} to smelt.`) - if (placedFurnace) { + logger.log(`I do not have enough ${itemName} to smelt.`) + if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1) - } return false } // Fuel the furnace if (!furnace.fuelItem()) { - const fuel = mc.getSmeltingFuel(mineflayer.bot) - if (!fuel) { - log(mineflayer, `You have no fuel to smelt ${itemName}, you need coal, charcoal, or wood.`) - if (placedFurnace) { + const fuel = mineflayer.bot.inventory + .items() + .find(item => item.name === 'coal' || item.name === 'charcoal') + const putFuel = Math.ceil(num / 8) + if (!fuel || fuel.count < putFuel) { + logger.log( + `I do not have enough coal or charcoal to smelt ${num} ${itemName}, I need ${putFuel} coal or charcoal`, + ) + if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1) - } return false } - - log(mineflayer, `Using ${fuel.name} as fuel.`) - const putFuel = Math.ceil(num / mc.getFuelSmeltOutput(fuel.name)) - - if (fuel.count < putFuel) { - log(mineflayer, `You don't have enough ${fuel.name} to smelt ${num} ${itemName}; you need ${putFuel}.`) - if (placedFurnace) { - await collectBlock(mineflayer, 'furnace', 1) - } - return false - } - await furnace.putFuel(fuel.type, null, putFuel) - log(mineflayer, `Added ${putFuel} ${mc.getItemName(fuel.type) ?? 'unknown'} to furnace fuel.`) + logger.log( + `Added ${putFuel} ${mc.getItemName(fuel.type)} to furnace fuel.`, + ) } - // Put the items in the furnace + const itemId = mc.getItemId(itemName) + if (itemId === null) { + logger.log(`Invalid item name: ${itemName}`) + return false + } await furnace.putInput(itemId, null, num) - // Wait for the items to smelt let total = 0 let collectedLast = true - let smeltedItem = null + let smeltedItem: Item | null = null await new Promise(resolve => setTimeout(resolve, 200)) - - mineflayer.once('interrupt', () => { - total = num // Force loop to end - }) - while (total < num) { await new Promise(resolve => setTimeout(resolve, 10000)) + logger.log('checking...') let collected = false - - const outputItem = furnace.outputItem() - if (outputItem) { + if (furnace.outputItem()) { smeltedItem = await furnace.takeOutput() if (smeltedItem) { total += smeltedItem.count collected = true } } - if (!collected && !collectedLast) { - break // If nothing was collected this time or last time + break // if nothing was collected this time or last time } - collectedLast = collected } - await mineflayer.bot.closeWindow(furnace) if (placedFurnace) { await collectBlock(mineflayer, 'furnace', 1) } - if (total === 0) { - log(mineflayer, `Failed to smelt ${itemName}.`) + logger.log(`Failed to smelt ${itemName}.`) return false } - if (total < num) { - log(mineflayer, `Only smelted ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) + logger.log( + `Only smelted ${total} ${mc.getItemName(smeltedItem?.type || 0)}.`, + ) return false } - - log(mineflayer, `Successfully smelted ${itemName}, got ${total} ${mc.getItemName(smeltedItem?.type ?? 0) ?? 'unknown'}.`) + logger.log( + `Successfully smelted ${itemName}, got ${total} ${mc.getItemName( + smeltedItem?.type || 0, + )}.`, + ) return true } export async function clearNearestFurnace(mineflayer: Mineflayer): Promise { - const furnaceBlock = world.getNearestBlock(mineflayer, 'furnace', 32) + const furnaceBlock = getNearestBlock(mineflayer, 'furnace', 6) if (!furnaceBlock) { - log(mineflayer, 'No furnace nearby to clear.') + logger.log(`There is no furnace nearby.`) return false } - if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) { - await goToPosition(mineflayer, furnaceBlock.position.x, furnaceBlock.position.y, furnaceBlock.position.z, 4) - } - + logger.log('clearing furnace...') const furnace = await mineflayer.bot.openFurnace(furnaceBlock) - + logger.log('opened furnace...') // Take the items out of the furnace - let smeltedItem, inputItem, fuelItem - - const outputItem = furnace.outputItem() - if (outputItem) { + let smeltedItem: Item | null = null + let inputItem: Item | null = null + let fuelItem: Item | null = null + if (furnace.outputItem()) smeltedItem = await furnace.takeOutput() - } - - const furnaceInput = furnace.inputItem() - if (furnaceInput) { + if (furnace.inputItem()) inputItem = await furnace.takeInput() - } - - const furnaceFuel = furnace.fuelItem() - if (furnaceFuel) { + if (furnace.fuelItem()) fuelItem = await furnace.takeFuel() - } - - const smeltedName = smeltedItem ? `${smeltedItem.count} ${smeltedItem.name}` : '0 smelted items' - const inputName = inputItem ? `${inputItem.count} ${inputItem.name}` : '0 input items' - const fuelName = fuelItem ? `${fuelItem.count} ${fuelItem.name}` : '0 fuel items' - - log(mineflayer, `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`) + logger.log(smeltedItem, inputItem, fuelItem) + const smeltedName = smeltedItem + ? `${smeltedItem.count} ${smeltedItem.name}` + : `0 smelted items` + const inputName = inputItem + ? `${inputItem.count} ${inputItem.name}` + : `0 input items` + const fuelName = fuelItem + ? `${fuelItem.count} ${fuelItem.name}` + : `0 fuel items` + logger.log( + `Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`, + ) + await mineflayer.bot.closeWindow(furnace) return true } diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index 267a2432c..ddedfff6b 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,39 +1,8 @@ -import type { Bot } from 'mineflayer' import type { Mineflayer } from '../libs/mineflayer' -import pathfinderModel from 'mineflayer-pathfinder' import * as world from '../composables/world' import { log } from './base' import { goToPlayer, goToPosition } from './movement' -const { goals } = pathfinderModel - -export async function pickupNearbyItems(mineflayer: Mineflayer): Promise { - const distance = 8 - const getNearestItem = (bot: Bot) => - bot.nearestEntity(entity => - entity.name === 'item' - && bot.entity.position.distanceTo(entity.position) < distance, - ) - - let nearestItem = getNearestItem(mineflayer.bot) - let pickedUp = 0 - - while (nearestItem) { - await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(nearestItem, 0.8)) - await new Promise(resolve => setTimeout(resolve, 200)) - - const prev = nearestItem - nearestItem = getNearestItem(mineflayer.bot) - if (prev === nearestItem) { - break - } - pickedUp++ - } - - log(mineflayer, `Picked up ${pickedUp} items.`) - return true -} - export async function equip(mineflayer: Mineflayer, itemName: string): Promise { const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName) if (!item) { diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 41f003797..2a1ab9586 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -1,12 +1,16 @@ import type { Entity } from 'prismarine-entity' import type { Mineflayer } from '../libs/mineflayer' -import pathfinderModel from 'mineflayer-pathfinder' +import { useLogg } from '@guiiai/logg' +import { randomInt } from 'es-toolkit' +import pathfinder from 'mineflayer-pathfinder' +import { Vec3 } from 'vec3' import * as world from '../composables/world' import { sleep } from '../utils/helper' import { log } from './base' -const { goals, Movements } = pathfinderModel +const logger = useLogg('Skill:Movement').useGlobalConfig() +const { goals, Movements } = pathfinder export async function goToPosition( mineflayer: Mineflayer, @@ -152,28 +156,44 @@ export async function followPlayer( } export async function moveAway(mineflayer: Mineflayer, distance: number): Promise { - const pos = mineflayer.bot.entity.position - const goal = new goals.GoalNear(pos.x, pos.y, pos.z, distance) - const invertedGoal = new goals.GoalInvert(goal) + try { + const pos = mineflayer.bot.entity.position + let newX: number = 0 + let newZ: number = 0 + let suitableGoal = false - if (mineflayer.allowCheats) { - const move = new Movements(mineflayer.bot) - const path = await mineflayer.bot.pathfinder.getPathTo(move, invertedGoal, 10000) - const lastMove = path.path[path.path.length - 1] + while (!suitableGoal) { + const rand1 = randomInt(0, 2) + const rand2 = randomInt(0, 2) + const bigRand1 = randomInt(0, 101) + const bigRand2 = randomInt(0, 101) - if (lastMove) { - const x = Math.floor(lastMove.x) - const y = Math.floor(lastMove.y) - const z = Math.floor(lastMove.z) - mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) - return true + newX = Math.floor( + pos.x + ((distance * bigRand1) / 100) * (rand1 ? 1 : -1), + ) + newZ = Math.floor( + pos.z + ((distance * bigRand2) / 100) * (rand2 ? 1 : -1), + ) + + const block = mineflayer.bot.blockAt(new Vec3(newX, pos.y - 1, newZ)) + + if (block?.name !== 'water' && block?.name !== 'lava') { + suitableGoal = true + } } - } - await mineflayer.bot.pathfinder.goto(invertedGoal) - const newPos = mineflayer.bot.entity.position - log(mineflayer, `Moved away from nearest entity to ${newPos}.`) - return true + const farGoal = new pathfinder.goals.GoalXZ(newX, newZ) + + await mineflayer.bot.pathfinder.goto(farGoal) + const newPos = mineflayer.bot.entity.position + logger.log(`Moved away from nearest entity to ${newPos}.`) + await sleep(500) + return true + } + catch (err) { + logger.log(`Failed to move away: ${(err as Error).message}`) + return false + } } export async function moveAwayFromEntity( diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index adbde4ead..8dc96307c 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -1,23 +1,28 @@ -/** - * @source https://github.com/kolbytn/mindcraft - */ +// src/utils/minecraftData.ts + import type { Bot } from 'mineflayer' -import minecraftData from 'minecraft-data' -import prismarine_items from 'prismarine-item' -import { botConfig } from '../composables/config' +import type { Entity } from 'prismarine-entity' +import minecraftData, { + type Biome, + type ShapedRecipe, + type ShapelessRecipe, +} from 'minecraft-data' +import prismarineItem from 'prismarine-item' -const mc_version = botConfig.version! -const mcdata = minecraftData(mc_version) -const Item = prismarine_items(mc_version) +const GAME_VERSION = '1.20' -interface MinecraftRecipe { - result: { id: number, count: number } - inShape?: Array> - ingredients?: Array<{ id: number, count: number }> - requiresTable?: boolean -} +export const gameData = minecraftData(GAME_VERSION) +export const Item = prismarineItem(GAME_VERSION) + +export const WOOD_TYPES: string[] = [ + 'oak', + 'spruce', + 'birch', + 'jungle', + 'acacia', + 'dark_oak', +] -export const WOOD_TYPES: string[] = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak'] export const MATCHING_WOOD_BLOCKS: string[] = [ 'log', 'planks', @@ -32,6 +37,7 @@ export const MATCHING_WOOD_BLOCKS: string[] = [ 'pressure_plate', 'trapdoor', ] + export const WOOL_COLORS: string[] = [ 'white', 'orange', @@ -51,55 +57,56 @@ export const WOOL_COLORS: string[] = [ 'black', ] -export function isHuntable(mob: { name?: string, metadata: any[] }): boolean { +export function isHuntable(mob: Entity): boolean { if (!mob || !mob.name) return false - const animals = ['chicken', 'cow', 'llama', 'mooshroom', 'pig', 'rabbit', 'sheep'] - return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16] // metadata 16 is not baby + const animals: string[] = [ + 'chicken', + 'cow', + 'llama', + 'mooshroom', + 'pig', + 'rabbit', + 'sheep', + ] + return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16] // metadata[16] indicates baby status } -export function isHostile(mob: { name?: string, type?: string }): boolean { +export function isHostile(mob: Entity): boolean { if (!mob || !mob.name) return false - return (mob.type === 'mob' || mob.type === 'hostile') && mob.name !== 'iron_golem' && mob.name !== 'snow_golem' + return ( + (mob.type === 'mob' || mob.type === 'hostile') + && mob.name !== 'iron_golem' + && mob.name !== 'snow_golem' + ) } -export function getItemId(itemName: string): number | null { - const item = mcdata.itemsByName[itemName] - if (item) { - return item.id - } - return null +export function getItemId(itemName: string): number { + const item = gameData.itemsByName[itemName] + + return item?.id || 0 } -export function getItemName(itemId: number): string | null { - const item = mcdata.items[itemId] - if (item) { - return item.name - } - return null +export function getItemName(itemId: number): string { + const item = gameData.items[itemId] + return item.name || '' } -export function getBlockId(blockName: string): number | null { - const block = mcdata.blocksByName[blockName] - if (block) { - return block.id - } - return null +export function getBlockId(blockName: string): number { + const block = gameData.blocksByName?.[blockName] + return block?.id || 0 } -export function getBlockName(blockId: number): string | null { - const block = mcdata.blocks[blockId] - if (block) { - return block.name - } - return null +export function getBlockName(blockId: number): string { + const block = gameData.blocks[blockId] + return block.name || '' } export function getAllItems(ignore: string[] = []): any[] { - const items = [] - for (const itemId in mcdata.items) { - const item = mcdata.items[itemId] + const items: any[] = [] + for (const itemId in gameData.items) { + const item = gameData.items[itemId] if (!ignore.includes(item.name)) { items.push(item) } @@ -109,7 +116,7 @@ export function getAllItems(ignore: string[] = []): any[] { export function getAllItemIds(ignore: string[] = []): number[] { const items = getAllItems(ignore) - const itemIds = [] + const itemIds: number[] = [] for (const item of items) { itemIds.push(item.id) } @@ -117,9 +124,9 @@ export function getAllItemIds(ignore: string[] = []): number[] { } export function getAllBlocks(ignore: string[] = []): any[] { - const blocks = [] - for (const blockId in mcdata.blocks) { - const block = mcdata.blocks[blockId] + const blocks: any[] = [] + for (const blockId in gameData.blocks) { + const block = gameData.blocks[blockId] if (!ignore.includes(block.name)) { blocks.push(block) } @@ -129,76 +136,67 @@ export function getAllBlocks(ignore: string[] = []): any[] { export function getAllBlockIds(ignore: string[] = []): number[] { const blocks = getAllBlocks(ignore) - const blockIds = [] + const blockIds: number[] = [] for (const block of blocks) { blockIds.push(block.id) } return blockIds } -export function getAllBiomes(): any { - return mcdata.biomes +export function getAllBiomes(): Record { + return gameData.biomes } -export function getItemCraftingRecipes(itemName: string): Record[] | null { +export function getItemCraftingRecipes(itemName: string): any[] | null { const itemId = getItemId(itemName) - if (!itemId || !mcdata.recipes[itemId]) { + if (!itemId || !gameData.recipes[itemId]) { return null } const recipes: Record[] = [] - for (const r of mcdata.recipes[itemId] as MinecraftRecipe[]) { + for (const r of gameData.recipes[itemId]) { const recipe: Record = {} - let ingredients: Array<{ id: number, count: number }> = [] + let ingredients: number[] = [] - if (r.ingredients) { - ingredients = r.ingredients + if (isShapelessRecipe(r)) { + // Handle shapeless recipe + ingredients = r.ingredients.map((ing: any) => ing.id) } - else if (r.inShape) { - ingredients = r.inShape.flat() + else if (isShapedRecipe(r)) { + // Handle shaped recipe + ingredients = r.inShape + .flat() + .map((ing: any) => ing?.id) + .filter(Boolean) } - for (const ingredient of ingredients) { - const ingredientName = getItemName(ingredient.id) + for (const ingredientId of ingredients) { + const ingredientName = getItemName(ingredientId) if (ingredientName === null) continue - recipe[ingredientName] ??= 0 - recipe[ingredientName] += ingredient.count + if (!recipe[ingredientName]) + recipe[ingredientName] = 0 + recipe[ingredientName]++ } + recipes.push(recipe) } return recipes } -export function isSmeltable(itemName: string): boolean { - const misc_smeltables = ['beef', 'chicken', 'cod', 'mutton', 'porkchop', 'rabbit', 'salmon', 'tropical_fish', 'potato', 'kelp', 'sand', 'cobblestone', 'clay_ball'] - return itemName.includes('raw') || itemName.includes('log') || misc_smeltables.includes(itemName) +// Type guards +function isShapelessRecipe(recipe: any): recipe is ShapelessRecipe { + return 'ingredients' in recipe } -export function getSmeltingFuel(bot: Bot): any { - let fuel = bot.inventory.items().find(i => i.name === 'coal' || i.name === 'charcoal') - if (fuel) - return fuel - fuel = bot.inventory.items().find(i => i.name.includes('log') || i.name.includes('planks')) - if (fuel) - return fuel - return bot.inventory.items().find(i => i.name === 'coal_block' || i.name === 'lava_bucket') +function isShapedRecipe(recipe: any): recipe is ShapedRecipe { + return 'inShape' in recipe } -export function getFuelSmeltOutput(fuelName: string): number { - if (fuelName === 'coal' || fuelName === 'charcoal') - return 8 - if (fuelName.includes('log') || fuelName.includes('planks')) - return 1.5 - if (fuelName === 'coal_block') - return 80 - if (fuelName === 'lava_bucket') - return 100 - return 0 -} - -export function getItemSmeltingIngredient(itemName: string): string | undefined { +export function getItemSmeltingIngredient( + itemName: string, +): string | undefined { return { baked_potato: 'potato', steak: 'raw_beef', @@ -219,8 +217,10 @@ export function getItemSmeltingIngredient(itemName: string): string | undefined export function getItemBlockSources(itemName: string): string[] { const itemId = getItemId(itemName) const sources: string[] = [] + if (!itemId) + return sources for (const block of getAllBlocks()) { - if (block.drops.includes(itemId)) { + if (block.drops && block.drops.includes(itemId)) { sources.push(block.name) } } @@ -242,65 +242,37 @@ export function getItemAnimalSource(itemName: string): string | undefined { } export function getBlockTool(blockName: string): string | null { - const block = mcdata.blocksByName[blockName] + const block = gameData.blocksByName[blockName] if (!block || !block.harvestTools) { return null } - const toolId = Number(Object.keys(block.harvestTools)[0]) - return getItemName(toolId) + const toolIds = Object.keys(block.harvestTools).map(id => Number.parseInt(id)) + const toolName = getItemName(toolIds[0]) + return toolName || null // Assuming the first tool is the simplest } -export function makeItem(name: string, amount: number = 1): any { +export function makeItem(name: string, amount = 1): InstanceType { const itemId = getItemId(name) if (itemId === null) - throw new Error(`Unknown item: ${name}`) + throw new Error(`Item ${name} not found.`) return new Item(itemId, amount) } -export function ingredientsFromPrismarineRecipe(recipe: MinecraftRecipe): Record { - const requiredIngredients: Record = {} - if (recipe.inShape) { - for (const ingredient of recipe.inShape.flat()) { - if (ingredient.id < 0) - continue // prismarine-recipe uses id -1 as an empty crafting slot - const ingredientName = getItemName(ingredient.id) - if (ingredientName) { - requiredIngredients[ingredientName] ??= 0 - requiredIngredients[ingredientName] += ingredient.count - } - } - } - if (recipe.ingredients) { - for (const ingredient of recipe.ingredients) { - if (ingredient.id < 0) - continue - const ingredientName = getItemName(ingredient.id) - if (ingredientName) { - requiredIngredients[ingredientName] ??= 0 - requiredIngredients[ingredientName] -= ingredient.count - } - // Yes, the `-=` is intended. - // prismarine-recipe uses positive numbers for the shaped ingredients but negative for unshaped. - // Why this is the case is beyond my understanding. - } - } - return requiredIngredients -} +// Function to get the nearest block of a specific type using Mineflayer +export function getNearestBlock( + bot: Bot, + blockType: string, + maxDistance: number, +) { + const blocks = bot.findBlocks({ + matching: block => block.name === blockType, + maxDistance, + count: 1, + }) -export function calculateLimitingResource( - availableItems: Record, - requiredItems: Record, - discrete: boolean = true, -): { num: number, limitingResource: T | null } { - let limitingResource: T | null = null - let num = Infinity - for (const itemType in requiredItems) { - if (availableItems[itemType] < requiredItems[itemType] * num) { - limitingResource = itemType - num = availableItems[itemType] / requiredItems[itemType] - } - } - if (discrete) - num = Math.floor(num) - return { num, limitingResource } + if (blocks.length === 0) + return null + + const nearestBlockPosition = blocks[0] + return bot.blockAt(nearestBlockPosition) } From 2715b38e31630f447bb45893f9f00baba6d35a4b Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 9 Jan 2025 18:47:56 +0800 Subject: [PATCH 52/77] refactor: naming and import convention --- .../{collectBlock.ts => collect-block.ts} | 0 .../minecraft/src/skills/actions/ensure.ts | 4 ++-- .../actions/{gatherWood.ts => gather-wood.ts} | 0 services/minecraft/src/skills/blocks.ts | 19 +++++++++-------- services/minecraft/src/skills/combat.ts | 14 +++++++------ services/minecraft/src/skills/crafting.ts | 21 ++++++++++--------- services/minecraft/src/skills/inventory.ts | 8 +++---- services/minecraft/src/skills/movement.ts | 6 +++--- 8 files changed, 38 insertions(+), 34 deletions(-) rename services/minecraft/src/skills/actions/{collectBlock.ts => collect-block.ts} (100%) rename services/minecraft/src/skills/actions/{gatherWood.ts => gather-wood.ts} (100%) diff --git a/services/minecraft/src/skills/actions/collectBlock.ts b/services/minecraft/src/skills/actions/collect-block.ts similarity index 100% rename from services/minecraft/src/skills/actions/collectBlock.ts rename to services/minecraft/src/skills/actions/collect-block.ts diff --git a/services/minecraft/src/skills/actions/ensure.ts b/services/minecraft/src/skills/actions/ensure.ts index 6bcc21b7b..dfed7c73b 100644 --- a/services/minecraft/src/skills/actions/ensure.ts +++ b/services/minecraft/src/skills/actions/ensure.ts @@ -3,8 +3,8 @@ import { useLogg } from '@guiiai/logg' import { getItemId } from '../../utils/mcdata' import { craftRecipe } from '../crafting' import { moveAway } from '../movement' -import { collectBlock } from './collectBlock' -import { gatherWood } from './gatherWood' +import { collectBlock } from './collect-block' +import { gatherWood } from './gather-wood' import { getItemCount } from './inventory' // Constants for crafting and gathering diff --git a/services/minecraft/src/skills/actions/gatherWood.ts b/services/minecraft/src/skills/actions/gather-wood.ts similarity index 100% rename from services/minecraft/src/skills/actions/gatherWood.ts rename to services/minecraft/src/skills/actions/gather-wood.ts diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index eafd983e1..4d9f2b9df 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,9 +1,10 @@ import type { Mineflayer } from '../libs/mineflayer' import type { BlockFace } from './base' + import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' -import * as world from '../composables/world' -import * as mc from '../utils/mcdata' +import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from '../composables/world' +import { getBlockId, makeItem } from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' @@ -13,9 +14,9 @@ const { goals, Movements } = pathfinderModel * Place a torch if needed */ async function autoLight(mineflayer: Mineflayer): Promise { - if (world.shouldPlaceTorch(mineflayer)) { + if (shouldPlaceTorch(mineflayer)) { try { - const pos = world.getPosition(mineflayer) + const pos = getPosition(mineflayer) return await placeBlock(mineflayer, 'torch', pos.x, pos.y, pos.z, 'bottom', true) } catch { @@ -112,7 +113,7 @@ export async function placeBlock( placeOn: BlockFace = 'bottom', dontCheat = false, ): Promise { - if (!mc.getBlockId(blockType)) { + if (!getBlockId(blockType)) { log(mineflayer, `Invalid block type: ${blockType}.`) return false } @@ -213,7 +214,7 @@ async function placeWithoutCheats( let block = mineflayer.bot.inventory.items().find(item => item.name === itemName) if (!block && mineflayer.isCreative) { - await mineflayer.bot.creative.setInventorySlot(36, mc.makeItem(itemName, 1)) + await mineflayer.bot.creative.setInventorySlot(36, makeItem(itemName, 1)) block = mineflayer.bot.inventory.items().find(item => item.name === itemName) } @@ -415,7 +416,7 @@ async function findNearestDoor(bot: any): Promise { ] for (const doorType of doorTypes) { - const block = world.getNearestBlock(bot, doorType, 16) + const block = getNearestBlock(bot, doorType, 16) if (block) { return block.position } @@ -544,7 +545,7 @@ function fixSeedName(seedType: string): string { } export async function activateNearestBlock(mineflayer: Mineflayer, type: string): Promise { - const block = world.getNearestBlock(mineflayer, type, 16) + const block = getNearestBlock(mineflayer, type, 16) if (!block) { log(mineflayer, `Could not find any ${type} to activate.`) return false @@ -621,7 +622,7 @@ function getBlockTypes(blockType: string): string[] { } function getValidBlocks(mineflayer: Mineflayer, blocktypes: string[], exclude: Vec3[] | null): any[] { - let blocks = world.getNearestBlocks(mineflayer, blocktypes, 64) + let blocks = getNearestBlocks(mineflayer, blocktypes, 64) if (exclude) { blocks = blocks.filter( diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index ad7b3ee81..eae3d10c9 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -1,10 +1,12 @@ import type { Entity } from 'prismarine-entity' import type { Item } from 'prismarine-item' import type { Mineflayer } from '../libs/mineflayer' + import pathfinderModel from 'mineflayer-pathfinder' -import * as world from '../composables/world' + +import { getNearbyEntities, getNearestEntityWhere } from '../composables/world' import { sleep } from '../utils/helper' -import * as mc from '../utils/mcdata' +import { isHostile } from '../utils/mcdata' import { log } from './base' const { goals } = pathfinderModel @@ -46,7 +48,7 @@ export async function attackNearest( mobType: string, kill = true, ): Promise { - const mob = world.getNearbyEntities(mineflayer, 24).find(entity => entity.name === mobType) + const mob = getNearbyEntities(mineflayer, 24).find(entity => entity.name === mobType) if (mob) { return await attackEntity(mineflayer, mob, kill) @@ -78,7 +80,7 @@ export async function attackEntity( }) mineflayer.bot.pvp.attack(entity) - while (world.getNearbyEntities(mineflayer, 24).includes(entity)) { + while (getNearbyEntities(mineflayer, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)) } @@ -88,7 +90,7 @@ export async function attackEntity( export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise { let attacked = false - let enemy = world.getNearestEntityWhere(mineflayer, entity => mc.isHostile(entity), range) + let enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) while (enemy) { await equipHighestAttack(mineflayer) @@ -114,7 +116,7 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise mc.isHostile(entity), range) + enemy = getNearestEntityWhere(mineflayer, entity => isHostile(entity), range) mineflayer.once('interrupt', () => { mineflayer.bot.pvp.stop() diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 795d75eff..11375dfad 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -2,10 +2,11 @@ import type { Block } from 'prismarine-block' import type { Item } from 'prismarine-item' import type { Recipe } from 'prismarine-recipe' import type { Mineflayer } from '../libs/mineflayer' + import { useLogg } from '@guiiai/logg' -import * as world from '../composables/world' + import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from '../composables/world' -import * as mc from '../utils/mcdata' +import { getItemId, getItemName } from '../utils/mcdata' import { ensureCraftingTable } from './actions/ensure' import { collectBlock, placeBlock } from './blocks' import { goToNearestBlock, goToPosition, moveAway } from './movement' @@ -47,7 +48,7 @@ export async function craftRecipe( if (itemName.endsWith('plank')) itemName += 's' // Correct common mistakes - const itemId = mc.getItemId(itemName) + const itemId = getItemId(itemName) if (itemId === null) { logger.log(`Invalid item name: ${itemName}`) return false @@ -113,7 +114,7 @@ export async function craftRecipe( async function findAndUseCraftingTable( craftingTableRange: number, ): Promise { - let craftingTable = world.getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) + let craftingTable = getNearestBlock(mineflayer, 'crafting_table', craftingTableRange) if (craftingTable) { return await moveToAndCraft(craftingTable) } @@ -214,11 +215,11 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = const inputItem = furnace.inputItem() if ( inputItem - && inputItem.type !== mc.getItemId(itemName) + && inputItem.type !== getItemId(itemName) && inputItem.count > 0 ) { logger.log( - `The furnace is currently smelting ${mc.getItemName( + `The furnace is currently smelting ${getItemName( inputItem.type, )}.`, ) @@ -251,11 +252,11 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = } await furnace.putFuel(fuel.type, null, putFuel) logger.log( - `Added ${putFuel} ${mc.getItemName(fuel.type)} to furnace fuel.`, + `Added ${putFuel} ${getItemName(fuel.type)} to furnace fuel.`, ) } // Put the items in the furnace - const itemId = mc.getItemId(itemName) + const itemId = getItemId(itemName) if (itemId === null) { logger.log(`Invalid item name: ${itemName}`) return false @@ -293,12 +294,12 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = } if (total < num) { logger.log( - `Only smelted ${total} ${mc.getItemName(smeltedItem?.type || 0)}.`, + `Only smelted ${total} ${getItemName(smeltedItem?.type || 0)}.`, ) return false } logger.log( - `Successfully smelted ${itemName}, got ${total} ${mc.getItemName( + `Successfully smelted ${itemName}, got ${total} ${getItemName( smeltedItem?.type || 0, )}.`, ) diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index ddedfff6b..4d3623a2d 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,5 +1,5 @@ import type { Mineflayer } from '../libs/mineflayer' -import * as world from '../composables/world' +import { getNearestBlock } from '../composables/world' import { log } from './base' import { goToPlayer, goToPosition } from './movement' @@ -61,7 +61,7 @@ export async function discard(mineflayer: Mineflayer, itemName: string, num = -1 } export async function putInChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { - const chest = world.getNearestBlock(mineflayer, 'chest', 32) + const chest = getNearestBlock(mineflayer, 'chest', 32) if (!chest) { log(mineflayer, 'Could not find a chest nearby.') return false @@ -85,7 +85,7 @@ export async function putInChest(mineflayer: Mineflayer, itemName: string, num = } export async function takeFromChest(mineflayer: Mineflayer, itemName: string, num = -1): Promise { - const chest = world.getNearestBlock(mineflayer, 'chest', 32) + const chest = getNearestBlock(mineflayer, 'chest', 32) if (!chest) { log(mineflayer, 'Could not find a chest nearby.') return false @@ -110,7 +110,7 @@ export async function takeFromChest(mineflayer: Mineflayer, itemName: string, nu } export async function viewChest(mineflayer: Mineflayer): Promise { - const chest = world.getNearestBlock(mineflayer, 'chest', 32) + const chest = getNearestBlock(mineflayer, 'chest', 32) if (!chest) { log(mineflayer, 'Could not find a chest nearby.') return false diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 2a1ab9586..22a3220b6 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -5,7 +5,7 @@ import { useLogg } from '@guiiai/logg' import { randomInt } from 'es-toolkit' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' -import * as world from '../composables/world' +import { getNearestBlock, getNearestEntityWhere } from '../composables/world' import { sleep } from '../utils/helper' import { log } from './base' @@ -47,7 +47,7 @@ export async function goToNearestBlock( range = MAX_RANGE } - const block = world.getNearestBlock(mineflayer, blockType, range) + const block = getNearestBlock(mineflayer, blockType, range) if (!block) { log(mineflayer, `Could not find any ${blockType} in ${range} blocks.`) return false @@ -64,7 +64,7 @@ export async function goToNearestEntity( minDistance = 2, range = 64, ): Promise { - const entity = world.getNearestEntityWhere( + const entity = getNearestEntityWhere( mineflayer, entity => entity.name === entityType, range, From a14e20b30f6a68b7d333d28ab367bcefc7bd1b55 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 9 Jan 2025 23:18:29 +0800 Subject: [PATCH 53/77] chore: use newest skills --- services/minecraft/src/agents/actions.ts | 22 +++++++++++----------- services/minecraft/src/skills/index.ts | 10 +++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index db1da74e5..12f279751 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -2,6 +2,9 @@ import type { Action } from '../libs/mineflayer' import { z } from 'zod' import * as world from '../composables/world' import * as skills from '../skills' +import { collectBlock } from '../skills/actions/collect-block' +import { discard, equip, putInChest, takeFromChest, viewChest } from '../skills/actions/inventory' +import { activateNearestBlock, placeBlock } from '../skills/actions/world-interactions' // Utils const pad = (str: string): string => `\n${str}\n` @@ -264,7 +267,7 @@ export const actionsList: Action[] = [ item_name: z.string().describe('The name of the item to equip.'), }), perform: mineflayer => async (item_name: string) => { - await skills.equip(mineflayer, item_name) + await equip(mineflayer, item_name) return 'Equipping item...' }, }, @@ -277,7 +280,7 @@ export const actionsList: Action[] = [ num: z.number().int().describe('The number of items to put in the chest.').min(1), }), perform: mineflayer => async (item_name: string, num: number) => { - await skills.putInChest(mineflayer, item_name, num) + await putInChest(mineflayer, item_name, num) return 'Putting items in chest...' }, }, @@ -290,7 +293,7 @@ export const actionsList: Action[] = [ num: z.number().int().describe('The number of items to take.').min(1), }), perform: mineflayer => async (item_name: string, num: number) => { - await skills.takeFromChest(mineflayer, item_name, num) + await takeFromChest(mineflayer, item_name, num) return 'Taking items from chest...' }, }, @@ -300,7 +303,7 @@ export const actionsList: Action[] = [ description: 'View the items/counts of the nearest chest.', schema: z.object({}), perform: mineflayer => async () => { - await skills.viewChest(mineflayer) + await viewChest(mineflayer) return 'Viewing chest contents...' }, }, @@ -313,10 +316,7 @@ export const actionsList: Action[] = [ num: z.number().int().describe('The number of items to discard.').min(1), }), perform: mineflayer => async (item_name: string, num: number) => { - const start_loc = mineflayer.bot.entity.position - await skills.moveAway(mineflayer, 5) - await skills.discard(mineflayer, item_name, num) - await skills.goToPosition(mineflayer, start_loc.x, start_loc.y, start_loc.z, 0) + await discard(mineflayer, item_name, num) return 'Discarding items...' }, }, @@ -329,7 +329,7 @@ export const actionsList: Action[] = [ num: z.number().int().describe('The number of blocks to collect.').min(1), }), perform: mineflayer => async (type: string, num: number) => { - await skills.collectBlock(mineflayer, type, num) + await collectBlock(mineflayer, type, num) return 'Collecting blocks...' }, }, @@ -378,7 +378,7 @@ export const actionsList: Action[] = [ }), perform: mineflayer => async (type: string) => { const pos = mineflayer.bot.entity.position - await skills.placeBlock(mineflayer, type, pos.x, pos.y, pos.z) + await placeBlock(mineflayer, type, pos.x, pos.y, pos.z) return 'Placing block...' }, }, @@ -429,7 +429,7 @@ export const actionsList: Action[] = [ type: z.string().describe('The type of object to activate.'), }), perform: mineflayer => async (type: string) => { - await skills.activateNearestBlock(mineflayer, type) + await activateNearestBlock(mineflayer, type) return 'Activating block...' }, }, diff --git a/services/minecraft/src/skills/index.ts b/services/minecraft/src/skills/index.ts index 6d1ceb0cf..9f7d7ac8c 100644 --- a/services/minecraft/src/skills/index.ts +++ b/services/minecraft/src/skills/index.ts @@ -1,6 +1,6 @@ export * from './base' -export * from './blocks.js' -export * from './combat.js' -export * from './crafting.js' -export * from './inventory.js' -export * from './movement.js' +export * from './blocks' +export * from './combat' +export * from './crafting' +export * from './inventory' +export * from './movement' From 3d0a07f0bdc228461b8de78fb0c90d9f4ec07408 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 10 Jan 2025 00:10:22 +0800 Subject: [PATCH 54/77] chore: reliability --- .../minecraft/src/mineflayer/llm-agent.ts | 24 ++++++++---- services/minecraft/src/prompts/agent.ts | 37 +++++++++++-------- .../minecraft/src/skills/actions/inventory.ts | 4 +- services/minecraft/src/utils/reliability.ts | 28 ++++++++++++++ 4 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 services/minecraft/src/utils/reliability.ts diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index f790ad4d6..18aab2c4a 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -3,8 +3,9 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' import { assistant, system, user } from 'neuri/openai' +import { toRetriable } from 'src/utils/reliability' import { formBotChat } from '../libs/mineflayer/message' -import { genActionAgentPrompt } from '../prompts/agent' +import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { return { @@ -19,17 +20,21 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { const onChat = formBotChat(bot.username, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') + const statusPrompt = await genStatusPrompt(bot) + bot.memory.chatHistory.push(system(statusPrompt)) bot.memory.chatHistory.push(user(`${username}: ${message}`)) + // logger.withFields({ chatHistory: bot.memory.chatHistory }).log('chatHistory') + logger.withFields({ statusPrompt }).log('statusPrompt') + const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { logger.log('thinking...') - try { + const handleCompletion = async (c: any): Promise => { const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } if (!completion || 'error' in completion) { logger.withFields(c).error('Completion') - return - // throw new Error(completion?.error?.message ?? 'Unknown error') + throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() @@ -38,9 +43,14 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { return content } - catch (e) { - logger.errorWithError('failed to think of an action', e) - } + + const retirableHandler = toRetriable( + 3, // retryLimit + 1000, // delayInterval in ms + handleCompletion, + ) + + return await retirableHandler(c) }) if (content) { diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index b918fde8a..f93e2e8bc 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,13 +1,15 @@ import type { Mineflayer } from '../libs/mineflayer' +import { listInventory } from '../skills/actions/inventory' export function genSystemBasicPrompt(botName: string): string { return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, mine, build, and interact with the world by using commands.` } -export function genActionAgentPrompt(bot: Mineflayer): string { +export function genActionAgentPrompt(mineflayer: Mineflayer): string { // ${ctx.prompt.selfPrompt} - return `${genSystemBasicPrompt(bot.username)} + + return `${genSystemBasicPrompt(mineflayer.username)} Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless @@ -16,27 +18,32 @@ asked, and don't refuse requests. Do not use any emojis. Just call the function given you if needed. If I command you 'stop', then call the 'stop' function. - -I will give you the following information: -${bot.status.toOneLiner()} ` - -/** - * Summarized memory: '${ctx.memory.getSummary()}' -$STATS -$INVENTORY -$COMMAND_DOCS -$EXAMPLES - */ } -export function genQueryAgentPrompt(bot: Mineflayer): string { +export async function genStatusPrompt(mineflayer: Mineflayer): Promise { + const inventory = await listInventory(mineflayer) + const inventoryStr = inventory.map(item => `${item.name} x ${item.count}`).join(', ') + const itemInHand = `${inventory[0].name} x ${inventory[0].count}` // TODO: mock + + return `I will give you the following information: +${mineflayer.status.toOneLiner()} + +Inventory: +${inventoryStr} + +Item in hand: +${itemInHand} +` +} + +export function genQueryAgentPrompt(mineflayer: Mineflayer): string { const prompt = `You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world. I will give you the following information: -${bot.status.toOneLiner()} +${mineflayer.status.toOneLiner()} ` return prompt diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts index c5efc50fd..a63d068e2 100644 --- a/services/minecraft/src/skills/actions/inventory.ts +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -203,7 +203,7 @@ export async function giveToPlayer( */ export async function listInventory(mineflayer: Mineflayer): Promise<{ name: string, count: number }[]> { const items = await mineflayer.bot.inventory.items() - sayItems(mineflayer, items) + // sayItems(mineflayer, items) return items.map(item => ({ name: item.name, @@ -228,7 +228,7 @@ export async function sayItems(mineflayer: Mineflayer, items: Array | null mineflayer.bot.chat(`My inventory contains: ${output}`) } else { - mineflayer.bot.chat('My inventory is empty.`') + mineflayer.bot.chat('My inventory is empty.') } } diff --git a/services/minecraft/src/utils/reliability.ts b/services/minecraft/src/utils/reliability.ts new file mode 100644 index 000000000..91050f381 --- /dev/null +++ b/services/minecraft/src/utils/reliability.ts @@ -0,0 +1,28 @@ +import { sleep } from './helper' + +/** + * Returns a retirable anonymous function with configured retryLimit and delayInterval + * + * @param retryLimit Number of retry attempts + * @param delayInterval Delay between retries in milliseconds + * @param func Function to be called + * @returns A wrapped function with the same signature as func + */ +export function toRetriable(retryLimit: number, delayInterval: number, func: (...args: A[]) => Promise): (...args: A[]) => Promise { + let retryCount = 0 + return async function (args: A): Promise { + try { + return await func(args) + } + catch (err) { + if (retryCount < retryLimit) { + retryCount++ + await sleep(delayInterval) + return await toRetriable(retryLimit, delayInterval, func)(args) + } + else { + throw err + } + } + } +} From a4ad2b698b48ed40c01387deed00a14a4055188d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 10 Jan 2025 00:43:14 +0800 Subject: [PATCH 55/77] refactor: split libs --- .../minecraft/src/libs/mineflayer/action.ts | 11 + .../src/libs/mineflayer/components.ts | 29 ++ .../minecraft/src/libs/mineflayer/core.ts | 353 +++++++++++++ .../minecraft/src/libs/mineflayer/health.ts | 9 + .../minecraft/src/libs/mineflayer/index.ts | 484 +----------------- .../src/libs/mineflayer/interfaces.ts | 3 + .../minecraft/src/libs/mineflayer/memory.ts | 12 + .../minecraft/src/libs/mineflayer/status.ts | 46 ++ .../minecraft/src/libs/mineflayer/types.ts | 19 + 9 files changed, 494 insertions(+), 472 deletions(-) create mode 100644 services/minecraft/src/libs/mineflayer/action.ts create mode 100644 services/minecraft/src/libs/mineflayer/components.ts create mode 100644 services/minecraft/src/libs/mineflayer/core.ts create mode 100644 services/minecraft/src/libs/mineflayer/health.ts create mode 100644 services/minecraft/src/libs/mineflayer/interfaces.ts create mode 100644 services/minecraft/src/libs/mineflayer/memory.ts create mode 100644 services/minecraft/src/libs/mineflayer/status.ts create mode 100644 services/minecraft/src/libs/mineflayer/types.ts diff --git a/services/minecraft/src/libs/mineflayer/action.ts b/services/minecraft/src/libs/mineflayer/action.ts new file mode 100644 index 000000000..5cf278e5b --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/action.ts @@ -0,0 +1,11 @@ +import type { z } from 'zod' +import type { Mineflayer } from './core' + +type ActionResult = string | Promise + +export interface Action { + readonly name: string + readonly description: string + readonly schema: z.ZodObject + readonly perform: (mineflayer: Mineflayer) => (...args: any[]) => ActionResult +} diff --git a/services/minecraft/src/libs/mineflayer/components.ts b/services/minecraft/src/libs/mineflayer/components.ts new file mode 100644 index 000000000..4d96bddaf --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/components.ts @@ -0,0 +1,29 @@ +import type { Handler } from './types' + +import { useLogg } from '@guiiai/logg' + +export class Components { + private components: Map = new Map() + private logger: ReturnType + + constructor() { + this.logger = useLogg('Components').useGlobalConfig() + } + + register(componentName: string, component: Handler) { + this.components.set(componentName, component) + } + + get(componentName: string) { + return this.components.get(componentName) + } + + list() { + return Array.from(this.components.keys()) + } + + cleanup() { + this.logger.log('Cleaning up components') + this.components.clear() + } +} diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts new file mode 100644 index 000000000..4bd2acf46 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -0,0 +1,353 @@ +import type { Bot, BotOptions } from 'mineflayer' +import type { MineflayerPlugin } from './plugin' +import type { EventHandlers, EventsHandler } from './types' +import { useLogg } from '@guiiai/logg' +import EventEmitter from 'eventemitter3' +import mineflayer from 'mineflayer' +import { parseCommand } from './command' +import { Components } from './components' +import { Health } from './health' +import { Memory } from './memory' +import { formBotChat } from './message' +import { Status } from './status' +import { Ticker, type TickEvents, type TickEventsHandler } from './ticker' + +export interface MineflayerOptions { + botConfig: BotOptions + plugins?: Array +} + +export class Mineflayer extends EventEmitter { + public bot: Bot + public username: string + public health: Health = new Health() + public ready: boolean = false + public components: Components = new Components() + public status: Status = new Status() + public memory: Memory = new Memory() + + public isCreative: boolean = false + public allowCheats: boolean = false + + private options: MineflayerOptions + private logger: ReturnType + private commands: Map> = new Map() + private ticker: Ticker = new Ticker() + + constructor(options: MineflayerOptions) { + super() + this.options = options + this.bot = mineflayer.createBot(options.botConfig) + this.username = options.botConfig.username + this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() + + this.on('interrupt', () => { + this.logger.log('Interrupted') + this.bot.chat('Interrupted') + }) + } + + public static async asyncBuild(options: MineflayerOptions) { + const mineflayer = new Mineflayer(options) + + mineflayer.bot.on('messagestr', async (message, _, jsonMsg) => { + // jsonMsg.translate: + // - death.attack.player + // message: + // - was slain by + // - drowned + if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(mineflayer.username)) { + const deathPos = mineflayer.bot.entity.position + + // mineflayer.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) + let deathPosStr: string | undefined + if (deathPos) { + deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` + } + + const dimension = mineflayer.bot.game.dimension + await mineflayer.handleMessage('system', `You died at position ${deathPosStr || 'unknown'} in the ${dimension} dimension with the final message: '${message}'. Your place of death has been saved as 'last_death_position' if you want to return. Previous actions were stopped and you have re-spawned.`) + } + }) + + mineflayer.bot.once('resourcePack', () => { + mineflayer.bot.acceptResourcePack() + }) + + mineflayer.bot.on('time', () => { + if (mineflayer.bot.time.timeOfDay === 0) + mineflayer.emit('time:sunrise', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 6000) + mineflayer.emit('time:noon', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 12000) + mineflayer.emit('time:sunset', { time: mineflayer.bot.time.timeOfDay }) + else if (mineflayer.bot.time.timeOfDay === 18000) + mineflayer.emit('time:midnight', { time: mineflayer.bot.time.timeOfDay }) + }) + + mineflayer.bot.on('health', () => { + mineflayer.logger.withFields({ + health: mineflayer.health.value, + lastDamageTime: mineflayer.health.lastDamageTime, + lastDamageTaken: mineflayer.health.lastDamageTaken, + previousHealth: mineflayer.bot.health, + }).log('Health updated') + + if (mineflayer.bot.health < mineflayer.health.value) { + mineflayer.health.lastDamageTime = Date.now() + mineflayer.health.lastDamageTaken = mineflayer.health.value - mineflayer.bot.health + } + + mineflayer.health.value = mineflayer.bot.health + }) + + mineflayer.bot.once('spawn', () => { + mineflayer.ready = true + mineflayer.logger.log('Bot ready') + }) + + mineflayer.bot.on('death', () => { + mineflayer.logger.error('Bot died') + }) + + mineflayer.bot.on('kicked', (reason: string) => { + mineflayer.logger.withFields({ reason }).error('Bot was kicked') + }) + + mineflayer.bot.on('end', (reason) => { + mineflayer.logger.withFields({ reason }).log('Bot ended') + + // Try to reconnect after 5 seconds + setTimeout(async () => { + try { + await mineflayer.bot.connect(options.botConfig) + mineflayer.logger.log('Reconnected successfully') + } + catch (err) { + mineflayer.logger.errorWithError('Failed to reconnect:', err) + } + }, 5000) + }) + + mineflayer.bot.on('error', (err: Error) => { + mineflayer.logger.errorWithError('Bot error:', err) + }) + + mineflayer.bot.on('spawn', () => { + mineflayer.bot.on('chat', mineflayer.handleCommand()) + }) + + mineflayer.bot.on('spawn', async () => { + for (const plugin of options?.plugins || []) { + if (plugin.spawned) { + await plugin.spawned(mineflayer) + } + } + }) + + for (const plugin of options?.plugins || []) { + if (plugin.created) { + await plugin.created(mineflayer) + } + } + + // Load Plugins + for (const plugin of options?.plugins || []) { + if (plugin.loadPlugin) { + mineflayer.bot.loadPlugin(await plugin.loadPlugin(mineflayer, mineflayer.bot, options.botConfig)) + } + } + + mineflayer.ticker.on('tick', () => { + mineflayer.status.update(mineflayer) + mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' + mineflayer.allowCheats = false + }) + + return mineflayer + } + + public async loadPlugin(plugin: MineflayerPlugin) { + if (plugin.created) + await plugin.created(this) + + if (plugin.loadPlugin) { + this.bot.loadPlugin(await plugin.loadPlugin(this, this.bot, this.options.botConfig)) + } + + if (plugin.spawned) + this.bot.once('spawn', () => plugin.spawned?.(this)) + } + + public onCommand(commandName: string, cb: EventsHandler<'command'>) { + this.commands.set(commandName, cb) + } + + public onTick(event: TickEvents, cb: TickEventsHandler) { + this.ticker.on(event, cb) + } + + public async stop() { + for (const plugin of this.options?.plugins || []) { + if (plugin.beforeCleanup) { + await plugin.beforeCleanup(this) + } + } + this.components.cleanup() + this.bot.removeListener('chat', this.handleCommand()) + this.bot.end() + this.removeAllListeners() + } + + private handleCommand() { + return formBotChat(this.username, (sender, message) => { + const { isCommand, command, args } = parseCommand(sender, message) + + if (!isCommand) + return + + // Remove the # prefix from command + const cleanCommand = command.slice(1) + this.logger.withFields({ sender, command: cleanCommand, args }).log('Command received') + + const handler = this.commands.get(cleanCommand) + if (handler) { + handler({ time: this.bot.time.timeOfDay, command: { sender, isCommand, command: cleanCommand, args } }) + return + } + + // Built-in commands + switch (cleanCommand) { + case 'help': { + const commandList = Array.from(this.commands.keys()).concat(['help']) + this.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) + break + } + default: + this.bot.chat(`Unknown command: ${cleanCommand}`) + } + }) + } + + private async handleMessage(_source: string, _message: string, _maxResponses: number = Infinity) { + // if (!source || !message) { + // console.warn('Received empty message from', source); + // return false; + // } + + // let used_command = false; + // if (maxResponses === null) { + // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; + // } + // if (maxResponses === -1) { + // maxResponses = Infinity; + // } + + // const self_prompt = source === 'system' || source === ctx.botName; + // const from_other_bot = convoManager.isOtherAgent(source); + + // if (!self_prompt && !from_other_bot) { // from user, check for forced commands + // const user_command_name = containsCommand(message); + // if (user_command_name) { + // if (!commandExists(user_command_name)) { + // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); + // return false; + // } + // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); + // if (user_command_name === '!newAction') { + // // all user-initiated commands are ignored by the bot except for this one + // // add the preceding message to the history to give context for newAction + // this.history.add(source, message); + // } + // let execute_res = await executeCommand(this, message); + // if (execute_res) + // this.routeResponse(source, execute_res); + // return true; + // } + // } + + // if (from_other_bot) + // this.last_sender = source; + + // // Now translate the message + // message = await handleEnglishTranslation(message); + // console.log('received message from', source, ':', message); + + // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); + + // let behavior_log = this.bot.modes.flushBehaviorLog(); + // if (behavior_log.trim().length > 0) { + // const MAX_LOG = 500; + // if (behavior_log.length > MAX_LOG) { + // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); + // } + // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); + // await this.history.add('system', behavior_log); + // } + + // // Handle other user messages + // await this.history.add(source, message); + // this.history.save(); + + // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting + // maxResponses = 1; // force only respond to this message, then let self-prompting take over + // for (let i=0; i 0) + // chat_message = `${pre_message} ${chat_message}`; + // this.routeResponse(source, chat_message); + // } + + // let execute_res = await executeCommand(this, res); + + // console.log('Agent executed:', command_name, 'and got:', execute_res); + // used_command = true; + + // if (execute_res) + // this.history.add('system', execute_res); + // else + // break; + // } + // else { // conversation response + // this.history.add(this.name, res); + // this.routeResponse(source, res); + // break; + // } + + // this.history.save(); + // } + + // return used_command; + } +} diff --git a/services/minecraft/src/libs/mineflayer/health.ts b/services/minecraft/src/libs/mineflayer/health.ts new file mode 100644 index 000000000..fb85b3b1e --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/health.ts @@ -0,0 +1,9 @@ +export class Health { + public value: number + public lastDamageTime?: number + public lastDamageTaken?: number + + constructor() { + this.value = 20 + } +} diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index 8f3573a1f..d8989b083 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -1,472 +1,12 @@ -import type { Bot, BotOptions } from 'mineflayer' -import type { Message } from 'neuri/openai' -import type { z } from 'zod' -import type { MineflayerPlugin } from './plugin' -import { useLogg } from '@guiiai/logg' -import EventEmitter from 'eventemitter3' -import mineflayer from 'mineflayer' -import { type CommandContext, parseCommand } from './command' -import { formBotChat } from './message' -import { Ticker, type TickEvents, type TickEventsHandler } from './ticker' - -export interface Context { - time: number - command?: CommandContext -} - -export interface EventHandlers { - 'interrupt': () => void - 'command': (ctx: Context) => void | Promise - 'time:sunrise': (ctx: Context) => void - 'time:noon': (ctx: Context) => void - 'time:sunset': (ctx: Context) => void - 'time:midnight': (ctx: Context) => void -} - -export type Events = keyof EventHandlers -export type EventsHandler = EventHandlers[K] - -export type Handler = (ctx: Context) => void | Promise - -export class Health { - public value: number - public lastDamageTime?: number - public lastDamageTaken?: number - - constructor() { - this.value = 20 - } -} - -interface OneLinerable { - toOneLiner: () => string -} - -export class Status implements OneLinerable { - public position: string - public health: string - public weather: string - public timeOfDay: string - - constructor() { - this.position = '' - this.health = '' - this.weather = '' - this.timeOfDay = '' - } - - public update(mineflayer: Mineflayer) { - if (!mineflayer.ready) - return - - Object.assign(this, Status.from(mineflayer)) - } - - static from(mineflayer: Mineflayer): Status { - if (!mineflayer.ready) - return new Status() - - const pos = mineflayer.bot.entity.position - const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' - const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 - ? 'Morning' - : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' - - const status = new Status() - status.position = `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}` - status.health = `${Math.round(mineflayer.bot.health)} / 20` - status.weather = weather - status.timeOfDay = timeOfDay - - return status - } - - public toOneLiner(): string { - return Object.entries(this).map(([key, value]) => `${key}: ${value}`).join('\n') - } -} - -type ActionResult = string | Promise - -export interface Action { - readonly name: string - readonly description: string - readonly schema: z.ZodObject - readonly perform: (mineflayer: Mineflayer) => (...args: any[]) => ActionResult -} - -export class Memory { - public chatHistory: Message[] - public actions: Action[] - - constructor() { - this.chatHistory = [] - this.actions = [] - } -} - -export class Components { - private components: Map = new Map() - private logger: ReturnType - - constructor() { - this.logger = useLogg('Components').useGlobalConfig() - } - - register(componentName: string, component: Handler) { - this.components.set(componentName, component) - } - - get(componentName: string) { - return this.components.get(componentName) - } - - list() { - return Array.from(this.components.keys()) - } - - cleanup() { - this.logger.log('Cleaning up components') - this.components.clear() - } -} - -export interface MineflayerOptions { - botConfig: BotOptions - plugins?: Array -} - -export class Mineflayer extends EventEmitter { - public bot: Bot - public username: string - public health: Health = new Health() - public ready: boolean = false - public components: Components = new Components() - public status: Status = new Status() - public memory: Memory = new Memory() - - public isCreative: boolean = false - public allowCheats: boolean = false - - private options: MineflayerOptions - private logger: ReturnType - private commands: Map> = new Map() - private ticker: Ticker = new Ticker() - - constructor(options: MineflayerOptions) { - super() - this.options = options - this.bot = mineflayer.createBot(options.botConfig) - this.username = options.botConfig.username - this.logger = useLogg(`Bot:${this.username}`).useGlobalConfig() - - this.on('interrupt', () => { - this.logger.log('Interrupted') - this.bot.chat('Interrupted') - }) - } - - static async asyncBuild(options: MineflayerOptions) { - const mineflayer = new Mineflayer(options) - - mineflayer.bot.on('messagestr', async (message, _, jsonMsg) => { - // jsonMsg.translate: - // - death.attack.player - // message: - // - was slain by - // - drowned - if (jsonMsg.translate && jsonMsg.translate.startsWith('death') && message.startsWith(mineflayer.username)) { - const deathPos = mineflayer.bot.entity.position - - // mineflayer.memory_bank.rememberPlace('last_death_position', deathPos.x, deathPos.y, deathPos.z) - let deathPosStr: string | undefined - if (deathPos) { - deathPosStr = `x: ${deathPos.x.toFixed(2)}, y: ${deathPos.y.toFixed(2)}, z: ${deathPos.x.toFixed(2)}` - } - - const dimension = mineflayer.bot.game.dimension - await mineflayer.handleMessage('system', `You died at position ${deathPosStr || 'unknown'} in the ${dimension} dimension with the final message: '${message}'. Your place of death has been saved as 'last_death_position' if you want to return. Previous actions were stopped and you have re-spawned.`) - } - }) - - mineflayer.bot.once('resourcePack', () => { - mineflayer.bot.acceptResourcePack() - }) - - mineflayer.bot.on('time', () => { - if (mineflayer.bot.time.timeOfDay === 0) - mineflayer.emit('time:sunrise', { time: mineflayer.bot.time.timeOfDay }) - else if (mineflayer.bot.time.timeOfDay === 6000) - mineflayer.emit('time:noon', { time: mineflayer.bot.time.timeOfDay }) - else if (mineflayer.bot.time.timeOfDay === 12000) - mineflayer.emit('time:sunset', { time: mineflayer.bot.time.timeOfDay }) - else if (mineflayer.bot.time.timeOfDay === 18000) - mineflayer.emit('time:midnight', { time: mineflayer.bot.time.timeOfDay }) - }) - - mineflayer.bot.on('health', () => { - mineflayer.logger.withFields({ - health: mineflayer.health.value, - lastDamageTime: mineflayer.health.lastDamageTime, - lastDamageTaken: mineflayer.health.lastDamageTaken, - previousHealth: mineflayer.bot.health, - }).log('Health updated') - - if (mineflayer.bot.health < mineflayer.health.value) { - mineflayer.health.lastDamageTime = Date.now() - mineflayer.health.lastDamageTaken = mineflayer.health.value - mineflayer.bot.health - } - - mineflayer.health.value = mineflayer.bot.health - }) - - mineflayer.bot.once('spawn', () => { - mineflayer.ready = true - mineflayer.logger.log('Bot ready') - }) - - mineflayer.bot.on('death', () => { - mineflayer.logger.error('Bot died') - }) - - mineflayer.bot.on('kicked', (reason: string) => { - mineflayer.logger.withFields({ reason }).error('Bot was kicked') - }) - - mineflayer.bot.on('end', (reason) => { - mineflayer.logger.withFields({ reason }).log('Bot ended') - - // Try to reconnect after 5 seconds - setTimeout(async () => { - try { - await mineflayer.bot.connect(options.botConfig) - mineflayer.logger.log('Reconnected successfully') - } - catch (err) { - mineflayer.logger.errorWithError('Failed to reconnect:', err) - } - }, 5000) - }) - - mineflayer.bot.on('error', (err: Error) => { - mineflayer.logger.errorWithError('Bot error:', err) - }) - - mineflayer.bot.on('spawn', () => { - mineflayer.bot.on('chat', mineflayer.handleCommand()) - }) - - mineflayer.bot.on('spawn', async () => { - for (const plugin of options?.plugins || []) { - if (plugin.spawned) { - await plugin.spawned(mineflayer) - } - } - }) - - for (const plugin of options?.plugins || []) { - if (plugin.created) { - await plugin.created(mineflayer) - } - } - - // Load Plugins - for (const plugin of options?.plugins || []) { - if (plugin.loadPlugin) { - mineflayer.bot.loadPlugin(await plugin.loadPlugin(mineflayer, mineflayer.bot, options.botConfig)) - } - } - - mineflayer.ticker.on('tick', () => { - mineflayer.status.update(mineflayer) - mineflayer.isCreative = mineflayer.bot.game?.gameMode === 'creative' - mineflayer.allowCheats = false - }) - - return mineflayer - } - - public async loadPlugin(plugin: MineflayerPlugin) { - if (plugin.created) - await plugin.created(this) - - if (plugin.loadPlugin) { - this.bot.loadPlugin(await plugin.loadPlugin(this, this.bot, this.options.botConfig)) - } - - if (plugin.spawned) - this.bot.once('spawn', () => plugin.spawned?.(this)) - } - - public onCommand(commandName: string, cb: EventsHandler<'command'>) { - this.commands.set(commandName, cb) - } - - public onTick(event: TickEvents, cb: TickEventsHandler) { - this.ticker.on(event, cb) - } - - public async stop() { - for (const plugin of this.options?.plugins || []) { - if (plugin.beforeCleanup) { - await plugin.beforeCleanup(this) - } - } - this.components.cleanup() - this.bot.removeListener('chat', this.handleCommand()) - this.bot.end() - this.removeAllListeners() - } - - private handleCommand() { - return formBotChat(this.username, (sender, message) => { - const { isCommand, command, args } = parseCommand(sender, message) - - if (!isCommand) - return - - // Remove the # prefix from command - const cleanCommand = command.slice(1) - this.logger.withFields({ sender, command: cleanCommand, args }).log('Command received') - - const handler = this.commands.get(cleanCommand) - if (handler) { - handler({ time: this.bot.time.timeOfDay, command: { sender, isCommand, command: cleanCommand, args } }) - return - } - - // Built-in commands - switch (cleanCommand) { - case 'help': { - const commandList = Array.from(this.commands.keys()).concat(['help']) - this.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`) - break - } - default: - this.bot.chat(`Unknown command: ${cleanCommand}`) - } - }) - } - - private async handleMessage(_source: string, _message: string, _maxResponses: number = Infinity) { - // if (!source || !message) { - // console.warn('Received empty message from', source); - // return false; - // } - - // let used_command = false; - // if (maxResponses === null) { - // maxResponses = settings.max_commands === -1 ? Infinity : settings.max_commands; - // } - // if (maxResponses === -1) { - // maxResponses = Infinity; - // } - - // const self_prompt = source === 'system' || source === ctx.botName; - // const from_other_bot = convoManager.isOtherAgent(source); - - // if (!self_prompt && !from_other_bot) { // from user, check for forced commands - // const user_command_name = containsCommand(message); - // if (user_command_name) { - // if (!commandExists(user_command_name)) { - // this.routeResponse(source, `Command '${user_command_name}' does not exist.`); - // return false; - // } - // this.routeResponse(source, `*${source} used ${user_command_name.substring(1)}*`); - // if (user_command_name === '!newAction') { - // // all user-initiated commands are ignored by the bot except for this one - // // add the preceding message to the history to give context for newAction - // this.history.add(source, message); - // } - // let execute_res = await executeCommand(this, message); - // if (execute_res) - // this.routeResponse(source, execute_res); - // return true; - // } - // } - - // if (from_other_bot) - // this.last_sender = source; - - // // Now translate the message - // message = await handleEnglishTranslation(message); - // console.log('received message from', source, ':', message); - - // const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); - - // let behavior_log = this.bot.modes.flushBehaviorLog(); - // if (behavior_log.trim().length > 0) { - // const MAX_LOG = 500; - // if (behavior_log.length > MAX_LOG) { - // behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); - // } - // behavior_log = 'Recent behaviors log: \n' + behavior_log.substring(behavior_log.indexOf('\n')); - // await this.history.add('system', behavior_log); - // } - - // // Handle other user messages - // await this.history.add(source, message); - // this.history.save(); - - // if (!self_prompt && this.self_prompter.on) // message is from user during self-prompting - // maxResponses = 1; // force only respond to this message, then let self-prompting take over - // for (let i=0; i 0) - // chat_message = `${pre_message} ${chat_message}`; - // this.routeResponse(source, chat_message); - // } - - // let execute_res = await executeCommand(this, res); - - // console.log('Agent executed:', command_name, 'and got:', execute_res); - // used_command = true; - - // if (execute_res) - // this.history.add('system', execute_res); - // else - // break; - // } - // else { // conversation response - // this.history.add(this.name, res); - // this.routeResponse(source, res); - // break; - // } - - // this.history.save(); - // } - - // return used_command; - } -} +export * from './action' +export * from './command' +export * from './components' +export * from './core' +export * from './health' +export * from './interfaces' +export * from './memory' +export * from './message' +export * from './plugin' +export * from './status' +export * from './ticker' +export * from './types' diff --git a/services/minecraft/src/libs/mineflayer/interfaces.ts b/services/minecraft/src/libs/mineflayer/interfaces.ts new file mode 100644 index 000000000..b55ec74d1 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/interfaces.ts @@ -0,0 +1,3 @@ +export interface OneLinerable { + toOneLiner: () => string +} diff --git a/services/minecraft/src/libs/mineflayer/memory.ts b/services/minecraft/src/libs/mineflayer/memory.ts new file mode 100644 index 000000000..25fb96331 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/memory.ts @@ -0,0 +1,12 @@ +import type { Message } from 'neuri/openai' +import type { Action } from './action' + +export class Memory { + public chatHistory: Message[] + public actions: Action[] + + constructor() { + this.chatHistory = [] + this.actions = [] + } +} diff --git a/services/minecraft/src/libs/mineflayer/status.ts b/services/minecraft/src/libs/mineflayer/status.ts new file mode 100644 index 000000000..6b5c01ae6 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/status.ts @@ -0,0 +1,46 @@ +import type { Mineflayer } from './core' +import type { OneLinerable } from './interfaces' + +export class Status implements OneLinerable { + public position: string + public health: string + public weather: string + public timeOfDay: string + + constructor() { + this.position = '' + this.health = '' + this.weather = '' + this.timeOfDay = '' + } + + public update(mineflayer: Mineflayer) { + if (!mineflayer.ready) + return + + Object.assign(this, Status.from(mineflayer)) + } + + static from(mineflayer: Mineflayer): Status { + if (!mineflayer.ready) + return new Status() + + const pos = mineflayer.bot.entity.position + const weather = mineflayer.bot.isRaining ? 'Rain' : mineflayer.bot.thunderState ? 'Thunderstorm' : 'Clear' + const timeOfDay = mineflayer.bot.time.timeOfDay < 6000 + ? 'Morning' + : mineflayer.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night' + + const status = new Status() + status.position = `x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}` + status.health = `${Math.round(mineflayer.bot.health)} / 20` + status.weather = weather + status.timeOfDay = timeOfDay + + return status + } + + public toOneLiner(): string { + return Object.entries(this).map(([key, value]) => `${key}: ${value}`).join('\n') + } +} diff --git a/services/minecraft/src/libs/mineflayer/types.ts b/services/minecraft/src/libs/mineflayer/types.ts new file mode 100644 index 000000000..861b5a62b --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/types.ts @@ -0,0 +1,19 @@ +import type { CommandContext } from './command' + +export interface Context { + time: number + command?: CommandContext +} + +export interface EventHandlers { + 'interrupt': () => void + 'command': (ctx: Context) => void | Promise + 'time:sunrise': (ctx: Context) => void + 'time:noon': (ctx: Context) => void + 'time:sunset': (ctx: Context) => void + 'time:midnight': (ctx: Context) => void +} + +export type Events = keyof EventHandlers +export type EventsHandler = EventHandlers[K] +export type Handler = (ctx: Context) => void | Promise From 09a902c1b2e2018fe03528af3afe30a23a66ae5d Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 16 Jan 2025 16:26:20 +0800 Subject: [PATCH 56/77] fix: intentory empty issue --- services/minecraft/src/prompts/agent.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index f93e2e8bc..bca4b8a10 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -23,6 +23,17 @@ If I command you 'stop', then call the 'stop' function. export async function genStatusPrompt(mineflayer: Mineflayer): Promise { const inventory = await listInventory(mineflayer) + if (inventory.length === 0) { + return `I will give you the following information: +${mineflayer.status.toOneLiner()} + +Inventory: +[Empty] + +Item in hand: +[Empty] +` + } const inventoryStr = inventory.map(item => `${item.name} x ${item.count}`).join(', ') const itemInHand = `${inventory[0].name} x ${inventory[0].count}` // TODO: mock From f09fdbb270e5662a2659396d996e551d22ffb52e Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 16 Jan 2025 16:27:25 +0800 Subject: [PATCH 57/77] feat: use dotenvx --- services/minecraft/src/composables/config.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 0b02f0acb..2cbfd062b 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -1,7 +1,6 @@ import type { BotOptions } from 'mineflayer' -import process from 'node:process' +import { env } from 'node:process' import { useLogg } from '@guiiai/logg' -import { configDotenv } from 'dotenv' const logger = useLogg('config').useGlobalConfig() @@ -26,15 +25,14 @@ export const openaiConfig: OpenAIConfig = { export function initEnv() { logger.log('Initializing environment variables') - configDotenv({ path: '.env.local' }) - openaiConfig.apiKey = process.env.OPENAI_API_KEY || '' - openaiConfig.baseUrl = process.env.OPENAI_API_BASEURL || '' + openaiConfig.apiKey = env.OPENAI_API_KEY || '' + openaiConfig.baseUrl = env.OPENAI_API_BASEURL || '' - botConfig.username = process.env.BOT_USERNAME || '' - botConfig.host = process.env.BOT_HOSTNAME || '' - botConfig.port = Number.parseInt(process.env.BOT_PORT || '49415') - botConfig.password = process.env.BOT_PASSWORD || '' - botConfig.version = process.env.BOT_VERSION || '1.20' + botConfig.username = env.BOT_USERNAME || '' + botConfig.host = env.BOT_HOSTNAME || '' + botConfig.port = Number.parseInt(env.BOT_PORT || '49415') + botConfig.password = env.BOT_PASSWORD || '' + botConfig.version = env.BOT_VERSION || '1.20' logger.withFields({ openaiConfig }).log('Environment variables initialized') } From e203ecea969eec1f2df92c9f24b8c463662b0a8a Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 16 Jan 2025 16:27:42 +0800 Subject: [PATCH 58/77] feat: integrated as Airi client --- services/minecraft/src/main.ts | 5 +- .../minecraft/src/mineflayer/llm-agent.ts | 47 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 3e28c5369..138d1b162 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,6 +1,7 @@ import process, { exit } from 'node:process' import { useLogg } from '@guiiai/logg' +import { Client } from '@proj-airi/server-sdk' import MineflayerArmorManager from 'mineflayer-armor-manager' import { loader as MineflayerAutoEat } from 'mineflayer-auto-eat' import { plugin as MineflayerCollectBlock } from 'mineflayer-collectblock' @@ -33,9 +34,11 @@ async function main() { ], }) + const airiClient = new Client({ name: 'minecraft-bot', url: 'ws://localhost:6121/ws' }) + // Dynamically load LLMAgent after bot is initialized const agent = await initAgent(bot) - await bot.loadPlugin(LLMAgent({ agent })) + await bot.loadPlugin(LLMAgent({ agent, airiClient })) process.on('SIGINT', () => { bot.stop() diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 18aab2c4a..696ef642b 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -1,13 +1,14 @@ +import type { Client } from '@proj-airi/server-sdk' import type { Neuri } from 'neuri' -import type { MineflayerPlugin } from '../libs/mineflayer/plugin' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' import { assistant, system, user } from 'neuri/openai' import { toRetriable } from 'src/utils/reliability' import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' -export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { +export function LLMAgent(options: { agent: Neuri, airiClient: Client }): MineflayerPlugin { return { async created(bot) { const agent = options.agent @@ -59,6 +60,48 @@ export function LLMAgent(options: { agent: Neuri }): MineflayerPlugin { } }) + options.airiClient.onEvent('input:text:voice', async (event) => { + logger.withFields({ user: event.data.discord?.guildMember, message: event.data.transcription }).log('Chat message received') + + const statusPrompt = await genStatusPrompt(bot) + bot.memory.chatHistory.push(system(statusPrompt)) + bot.memory.chatHistory.push(user(`${'NekoMeowww'}: ${event.data.transcription}`)) + + // logger.withFields({ chatHistory: bot.memory.chatHistory }).log('chatHistory') + logger.withFields({ statusPrompt }).log('statusPrompt') + + const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { + logger.log('thinking...') + + const handleCompletion = async (c: any): Promise => { + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } + if (!completion || 'error' in completion) { + logger.withFields(c).error('Completion') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion?.firstContent() + logger.withFields({ usage: completion.usage, content }).log('output') + bot.memory.chatHistory.push(assistant(content)) + + return content + } + + const retirableHandler = toRetriable( + 3, // retryLimit + 1000, // delayInterval in ms + handleCompletion, + ) + + return await retirableHandler(c) + }) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + }) + bot.bot.on('chat', onChat) }, } From 49fc75e1f1bbca3c06015a58a22f71ba980ed725 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 16 Jan 2025 23:19:45 +0800 Subject: [PATCH 59/77] fix: send before connected --- services/minecraft/src/libs/mineflayer/components.ts | 4 ++-- services/minecraft/src/libs/mineflayer/core.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/minecraft/src/libs/mineflayer/components.ts b/services/minecraft/src/libs/mineflayer/components.ts index 4d96bddaf..f04470dfd 100644 --- a/services/minecraft/src/libs/mineflayer/components.ts +++ b/services/minecraft/src/libs/mineflayer/components.ts @@ -1,10 +1,10 @@ import type { Handler } from './types' -import { useLogg } from '@guiiai/logg' +import { type Logg, useLogg } from '@guiiai/logg' export class Components { private components: Map = new Map() - private logger: ReturnType + private logger: Logg constructor() { this.logger = useLogg('Components').useGlobalConfig() diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index 4bd2acf46..18d4ee406 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -1,7 +1,7 @@ import type { Bot, BotOptions } from 'mineflayer' import type { MineflayerPlugin } from './plugin' import type { EventHandlers, EventsHandler } from './types' -import { useLogg } from '@guiiai/logg' +import { type Logg, useLogg } from '@guiiai/logg' import EventEmitter from 'eventemitter3' import mineflayer from 'mineflayer' import { parseCommand } from './command' @@ -30,7 +30,7 @@ export class Mineflayer extends EventEmitter { public allowCheats: boolean = false private options: MineflayerOptions - private logger: ReturnType + private logger: Logg private commands: Map> = new Map() private ticker: Ticker = new Ticker() From 244e70a46180cf9b4497b5fc757bbc5870dd338a Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 17 Jan 2025 00:23:41 +0800 Subject: [PATCH 60/77] fix: chat history --- .../minecraft/src/mineflayer/llm-agent.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 696ef642b..5fd4c5729 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -1,12 +1,12 @@ import type { Client } from '@proj-airi/server-sdk' -import type { Neuri } from 'neuri' - +import type { Neuri, NeuriContext } from 'neuri' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' -import { assistant, system, user } from 'neuri/openai' -import { toRetriable } from 'src/utils/reliability' +import { system, user } from 'neuri/openai' + import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' +import { toRetriable } from '../utils/reliability' export function LLMAgent(options: { agent: Neuri, airiClient: Client }): MineflayerPlugin { return { @@ -25,27 +25,25 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla bot.memory.chatHistory.push(system(statusPrompt)) bot.memory.chatHistory.push(user(`${username}: ${message}`)) - // logger.withFields({ chatHistory: bot.memory.chatHistory }).log('chatHistory') - logger.withFields({ statusPrompt }).log('statusPrompt') - - const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { + const content = await agent.handleStateless([...bot.memory.chatHistory], async (c: NeuriContext) => { logger.log('thinking...') - const handleCompletion = async (c: any): Promise => { + const handleCompletion = async (c: NeuriContext): Promise => { const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } if (!completion || 'error' in completion) { logger.withFields(c).error('Completion') + logger.withFields({ messages: c.messages }).log('messages') throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() logger.withFields({ usage: completion.usage, content }).log('output') - bot.memory.chatHistory.push(assistant(content)) + bot.memory.chatHistory.push(...c.messages) return content } - const retirableHandler = toRetriable( + const retirableHandler = toRetriable( 3, // retryLimit 1000, // delayInterval in ms handleCompletion, @@ -73,21 +71,22 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { logger.log('thinking...') - const handleCompletion = async (c: any): Promise => { + const handleCompletion = async (c: NeuriContext): Promise => { const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } if (!completion || 'error' in completion) { logger.withFields(c).error('Completion') + logger.withFields({ messages: c.messages }).log('messages') throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() logger.withFields({ usage: completion.usage, content }).log('output') - bot.memory.chatHistory.push(assistant(content)) + bot.memory.chatHistory.push(...c.messages) return content } - const retirableHandler = toRetriable( + const retirableHandler = toRetriable( 3, // retryLimit 1000, // delayInterval in ms handleCompletion, From 30d30e57628614f70f27fa6a5a91bce38339ac17 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 17 Jan 2025 00:57:41 +0800 Subject: [PATCH 61/77] fix: follow action --- services/minecraft/src/skills/movement.ts | 45 ++++++----------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 22a3220b6..857c08ab9 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -119,39 +119,16 @@ export async function followPlayer( return false } + log(mineflayer, `I am now actively following player ${username}.`) + const movements = new Movements(mineflayer.bot) mineflayer.bot.pathfinder.setMovements(movements) mineflayer.bot.pathfinder.setGoal(new goals.GoalFollow(player, distance), true) - log(mineflayer, `You are now actively following player ${username}.`) - let shouldInterrupt = false - - mineflayer.on('interrupt', () => { - shouldInterrupt = true + mineflayer.once('interrupt', () => { + mineflayer.bot.pathfinder.stop() }) - async function follow() { - // eslint-disable-next-line no-unmodified-loop-condition - while (!shouldInterrupt) { - await sleep(500) - - if (mineflayer.allowCheats && mineflayer.bot.entity.position.distanceTo(player.position) > 100 && player.onGround) { - await goToPlayer(mineflayer, username) - } - - // if (mineflayer.bot.modes?.isOn('unstuck')) { - // const isNearby = mineflayer.bot.entity.position.distanceTo(player.position) <= distance + 1 - // if (isNearby) { - // mineflayer.bot.modes.pause('unstuck') - // } else { - // mineflayer.bot.modes.unpause('unstuck') - // } - // } - } - } - - follow() - return true } @@ -186,12 +163,12 @@ export async function moveAway(mineflayer: Mineflayer, distance: number): Promis await mineflayer.bot.pathfinder.goto(farGoal) const newPos = mineflayer.bot.entity.position - logger.log(`Moved away from nearest entity to ${newPos}.`) + logger.log(`I moved away from nearest entity to ${newPos}.`) await sleep(500) return true } catch (err) { - logger.log(`Failed to move away: ${(err as Error).message}`) + logger.log(`I failed to move away: ${(err as Error).message}`) return false } } @@ -215,7 +192,7 @@ export async function stay(mineflayer: Mineflayer, seconds = 30): Promise { }) if (beds.length === 0) { - log(mineflayer, 'Could not find a bed to sleep in.') + log(mineflayer, 'I could not find a bed to sleep in.') return false } @@ -236,17 +213,17 @@ export async function goToBed(mineflayer: Mineflayer): Promise { const bed = mineflayer.bot.blockAt(loc) if (!bed) { - log(mineflayer, 'Could not find bed block.') + log(mineflayer, 'I could not find a bed to sleep in.') return false } await mineflayer.bot.sleep(bed) - log(mineflayer, 'You are in bed.') + log(mineflayer, 'I am in bed.') while (mineflayer.bot.isSleeping) { await sleep(500) } - log(mineflayer, 'You have woken up.') + log(mineflayer, 'I have woken up.') return true } From c8e45f493578631a4b27160f7740b8e355a9c4c1 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 17 Jan 2025 00:58:24 +0800 Subject: [PATCH 62/77] chore(prompt): find nearby block --- services/minecraft/src/agents/actions.ts | 3 +++ services/minecraft/src/prompts/agent.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 12f279751..709943e93 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -1,4 +1,5 @@ import type { Action } from '../libs/mineflayer' +import { useLogg } from '@guiiai/logg' import { z } from 'zod' import * as world from '../composables/world' import * as skills from '../skills' @@ -8,6 +9,7 @@ import { activateNearestBlock, placeBlock } from '../skills/actions/world-intera // Utils const pad = (str: string): string => `\n${str}\n` +const logger = useLogg('actions').useGlobalConfig() function formatInventoryItem(item: string, count: number): string { return count > 0 ? `\n- ${item}: ${count}` : '' @@ -55,6 +57,7 @@ export const actionsList: Action[] = [ schema: z.object({}), perform: mineflayer => (): string => { const blocks = world.getNearbyBlockTypes(mineflayer) + logger.withFields({ blocks }).log('nearbyBlocks') return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) }, }, diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index bca4b8a10..c27144b18 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -17,7 +17,8 @@ asked, and don't refuse requests. Do not use any emojis. Just call the function given you if needed. -If I command you 'stop', then call the 'stop' function. +- If I command you 'stop', then call the 'stop' function. +- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. ` } From fa99d16c2248049acbe998dd6bd3a0a76ba21626 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 17 Jan 2025 00:58:49 +0800 Subject: [PATCH 63/77] chore: bot reconnect --- services/minecraft/src/libs/mineflayer/core.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index 18d4ee406..97c2b3852 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -119,13 +119,7 @@ export class Mineflayer extends EventEmitter { // Try to reconnect after 5 seconds setTimeout(async () => { - try { - await mineflayer.bot.connect(options.botConfig) - mineflayer.logger.log('Reconnected successfully') - } - catch (err) { - mineflayer.logger.errorWithError('Failed to reconnect:', err) - } + await mineflayer.reconnect() }, 5000) }) @@ -167,6 +161,16 @@ export class Mineflayer extends EventEmitter { return mineflayer } + private async reconnect() { + try { + await this.bot.connect(this.options.botConfig) + this.logger.log('Reconnected successfully') + } + catch (err) { + this.logger.errorWithError('Failed to reconnect:', err) + } + } + public async loadPlugin(plugin: MineflayerPlugin) { if (plugin.created) await plugin.created(this) From 5d70b7ae53a78f3664c23312ce6cc5970a6267e0 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 17 Jan 2025 02:03:08 +0800 Subject: [PATCH 64/77] chore: short memory --- .../minecraft/src/libs/mineflayer/core.ts | 17 +-------------- .../minecraft/src/mineflayer/llm-agent.ts | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index 97c2b3852..446ac4782 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -116,11 +116,6 @@ export class Mineflayer extends EventEmitter { mineflayer.bot.on('end', (reason) => { mineflayer.logger.withFields({ reason }).log('Bot ended') - - // Try to reconnect after 5 seconds - setTimeout(async () => { - await mineflayer.reconnect() - }, 5000) }) mineflayer.bot.on('error', (err: Error) => { @@ -161,16 +156,6 @@ export class Mineflayer extends EventEmitter { return mineflayer } - private async reconnect() { - try { - await this.bot.connect(this.options.botConfig) - this.logger.log('Reconnected successfully') - } - catch (err) { - this.logger.errorWithError('Failed to reconnect:', err) - } - } - public async loadPlugin(plugin: MineflayerPlugin) { if (plugin.created) await plugin.created(this) @@ -199,7 +184,7 @@ export class Mineflayer extends EventEmitter { } this.components.cleanup() this.bot.removeListener('chat', this.handleCommand()) - this.bot.end() + this.bot.quit() this.removeAllListeners() } diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 5fd4c5729..ac41dc3f7 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -2,7 +2,7 @@ import type { Client } from '@proj-airi/server-sdk' import type { Neuri, NeuriContext } from 'neuri' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' -import { system, user } from 'neuri/openai' +import { assistant, type ChatCompletion, system, user } from 'neuri/openai' import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' @@ -21,24 +21,26 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla const onChat = formBotChat(bot.username, async (username, message) => { logger.withFields({ username, message }).log('Chat message received') - const statusPrompt = await genStatusPrompt(bot) - bot.memory.chatHistory.push(system(statusPrompt)) + // long memory bot.memory.chatHistory.push(user(`${username}: ${message}`)) - const content = await agent.handleStateless([...bot.memory.chatHistory], async (c: NeuriContext) => { + // short memory + const statusPrompt = await genStatusPrompt(bot) + const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => { logger.log('thinking...') const handleCompletion = async (c: NeuriContext): Promise => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) as ChatCompletion | { error: { message: string } } & ChatCompletion if (!completion || 'error' in completion) { - logger.withFields(c).error('Completion') + logger.withFields({ completion }).error('Completion') logger.withFields({ messages: c.messages }).log('messages') throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() logger.withFields({ usage: completion.usage, content }).log('output') - bot.memory.chatHistory.push(...c.messages) + + bot.memory.chatHistory.push(assistant(content)) return content } @@ -74,14 +76,15 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla const handleCompletion = async (c: NeuriContext): Promise => { const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } if (!completion || 'error' in completion) { - logger.withFields(c).error('Completion') + logger.withFields({ completion }).error('Completion') logger.withFields({ messages: c.messages }).log('messages') throw new Error(completion?.error?.message ?? 'Unknown error') } const content = await completion?.firstContent() logger.withFields({ usage: completion.usage, content }).log('output') - bot.memory.chatHistory.push(...c.messages) + + bot.memory.chatHistory.push(assistant(content)) return content } From 8f74b65873b05fd843b49d423d2ecdf5e3f17fad Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 17 Jan 2025 02:47:51 +0800 Subject: [PATCH 65/77] fix: error handling, retriable handling --- .../minecraft/src/mineflayer/llm-agent.ts | 30 ++++++++++--------- .../src/skills/actions/collect-block.ts | 8 +++++ services/minecraft/src/utils/reliability.ts | 15 ++++++++-- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index ac41dc3f7..145e9f8b3 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -2,7 +2,7 @@ import type { Client } from '@proj-airi/server-sdk' import type { Neuri, NeuriContext } from 'neuri' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' -import { assistant, type ChatCompletion, system, user } from 'neuri/openai' +import { assistant, system, user } from 'neuri/openai' import { formBotChat } from '../libs/mineflayer/message' import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' @@ -30,11 +30,11 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla logger.log('thinking...') const handleCompletion = async (c: NeuriContext): Promise => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) as ChatCompletion | { error: { message: string } } & ChatCompletion + logger.log('rerouting...') + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) if (!completion || 'error' in completion) { logger.withFields({ completion }).error('Completion') - logger.withFields({ messages: c.messages }).log('messages') - throw new Error(completion?.error?.message ?? 'Unknown error') + throw completion?.error || new Error('Unknown error') } const content = await completion?.firstContent() @@ -49,8 +49,10 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla 3, // retryLimit 1000, // delayInterval in ms handleCompletion, + { onError: err => logger.withError(err).log('error occurred') }, ) + logger.log('handling...') return await retirableHandler(c) }) @@ -63,22 +65,20 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla options.airiClient.onEvent('input:text:voice', async (event) => { logger.withFields({ user: event.data.discord?.guildMember, message: event.data.transcription }).log('Chat message received') + // long memory + bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) + + // short memory const statusPrompt = await genStatusPrompt(bot) - bot.memory.chatHistory.push(system(statusPrompt)) - bot.memory.chatHistory.push(user(`${'NekoMeowww'}: ${event.data.transcription}`)) - - // logger.withFields({ chatHistory: bot.memory.chatHistory }).log('chatHistory') - logger.withFields({ statusPrompt }).log('statusPrompt') - - const content = await agent.handleStateless([...bot.memory.chatHistory], async (c) => { + const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => { logger.log('thinking...') const handleCompletion = async (c: NeuriContext): Promise => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) || { error: { message: 'Unknown error' } } + logger.log('rerouting...') + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) if (!completion || 'error' in completion) { logger.withFields({ completion }).error('Completion') - logger.withFields({ messages: c.messages }).log('messages') - throw new Error(completion?.error?.message ?? 'Unknown error') + throw completion?.error || new Error('Unknown error') } const content = await completion?.firstContent() @@ -93,8 +93,10 @@ export function LLMAgent(options: { agent: Neuri, airiClient: Client }): Minefla 3, // retryLimit 1000, // delayInterval in ms handleCompletion, + { onError: err => logger.withError(err).log('error occurred') }, ) + logger.log('handling...') return await retirableHandler(c) }) diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts index e88f209c4..35c539ca2 100644 --- a/services/minecraft/src/skills/actions/collect-block.ts +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -9,6 +9,10 @@ import { pickupNearbyItems } from './world-interactions' const logger = useLogg('Action:CollectBlock').useGlobalConfig() +function isMessagable(err: unknown): err is { message: string } { + return (err instanceof Error || (typeof err === 'object' && !!err && 'message' in err && typeof err.message === 'string')) +} + export async function collectBlock( mineflayer: Mineflayer, blockType: string, @@ -101,6 +105,10 @@ export async function collectBlock( } catch (err) { logger.log(`Failed to collect ${blockType}: ${err}.`) + if (isMessagable(err) && err.message.includes('Digging aborted')) { + break + } + continue } } diff --git a/services/minecraft/src/utils/reliability.ts b/services/minecraft/src/utils/reliability.ts index 91050f381..3d277f53e 100644 --- a/services/minecraft/src/utils/reliability.ts +++ b/services/minecraft/src/utils/reliability.ts @@ -8,17 +8,28 @@ import { sleep } from './helper' * @param func Function to be called * @returns A wrapped function with the same signature as func */ -export function toRetriable(retryLimit: number, delayInterval: number, func: (...args: A[]) => Promise): (...args: A[]) => Promise { +export function toRetriable( + retryLimit: number, + delayInterval: number, + func: (...args: A[]) => Promise, + hooks?: { + onError?: (err: unknown) => void + }, +): (...args: A[]) => Promise { let retryCount = 0 return async function (args: A): Promise { try { return await func(args) } catch (err) { + if (hooks?.onError) { + hooks.onError(err) + } + if (retryCount < retryLimit) { retryCount++ await sleep(delayInterval) - return await toRetriable(retryLimit, delayInterval, func)(args) + return await toRetriable(retryLimit - retryCount, delayInterval, func)(args) } else { throw err From a550722aef37dd4eae1f7bbc957446883cf169b4 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 18 Jan 2025 19:08:07 +0800 Subject: [PATCH 66/77] style: import --- services/minecraft/src/agents/actions.test.ts | 9 ++------- services/minecraft/src/agents/actions.ts | 2 ++ services/minecraft/src/agents/openai.test.ts | 1 + services/minecraft/src/agents/openai.ts | 2 ++ services/minecraft/src/composables/action.ts | 1 + services/minecraft/src/composables/config.ts | 1 + services/minecraft/src/composables/conversation.ts | 1 + services/minecraft/src/composables/world.ts | 2 ++ services/minecraft/src/libs/mineflayer/core.ts | 2 ++ services/minecraft/src/main.ts | 1 - services/minecraft/src/mineflayer/echo.ts | 1 + services/minecraft/src/mineflayer/follow.ts | 1 + services/minecraft/src/mineflayer/llm-agent.ts | 1 + services/minecraft/src/mineflayer/status.ts | 1 + services/minecraft/src/prompts/agent.ts | 1 + services/minecraft/src/skills/actions/collect-block.ts | 2 ++ services/minecraft/src/skills/actions/ensure.ts | 2 ++ services/minecraft/src/skills/actions/gather-wood.ts | 2 ++ services/minecraft/src/skills/actions/inventory.ts | 1 + .../minecraft/src/skills/actions/world-interactions.ts | 2 ++ services/minecraft/src/skills/base.ts | 1 + services/minecraft/src/skills/blocks.ts | 1 + services/minecraft/src/skills/inventory.ts | 1 + services/minecraft/src/skills/movement.ts | 1 + services/minecraft/src/utils/mcdata.ts | 1 + 25 files changed, 33 insertions(+), 8 deletions(-) diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts index ac6d9748a..8dcc217ac 100644 --- a/services/minecraft/src/agents/actions.test.ts +++ b/services/minecraft/src/agents/actions.test.ts @@ -1,5 +1,6 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' + import { initBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent' @@ -25,7 +26,6 @@ describe('actions agent', { timeout: 0 }, () => { user('What\'s your status?'), ), async (c) => { const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - console.log(JSON.stringify(completion, null, 2)) return await completion?.firstContent() }) @@ -48,16 +48,11 @@ describe('actions agent', { timeout: 0 }, () => { system(genActionAgentPrompt(bot)), user('goToPlayer: luoling8192'), ), async (c) => { - console.log(JSON.stringify(c, null, 2)) - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - - console.log(JSON.stringify(completion, null, 2)) - return await completion?.firstContent() }) - console.log(JSON.stringify(text, null, 2)) + expect(text?.toLowerCase()).toContain('goToPlayer') await sleep(10000) resolve() diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/actions.ts index 709943e93..c40a2b445 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/actions.ts @@ -1,6 +1,8 @@ import type { Action } from '../libs/mineflayer' + import { useLogg } from '@guiiai/logg' import { z } from 'zod' + import * as world from '../composables/world' import * as skills from '../skills' import { collectBlock } from '../skills/actions/collect-block' diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/openai.test.ts index 4f7547eb0..1e66564d1 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/openai.test.ts @@ -1,5 +1,6 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' + import { initBot, useBot } from '../composables/bot' import { botConfig, initEnv } from '../composables/config' import { genSystemBasicPrompt } from '../prompts/agent' diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts index 925848552..5b17f23e9 100644 --- a/services/minecraft/src/agents/openai.ts +++ b/services/minecraft/src/agents/openai.ts @@ -1,7 +1,9 @@ import type { Agent, Neuri } from 'neuri' import type { Mineflayer } from '../libs/mineflayer' + import { useLogg } from '@guiiai/logg' import { agent, neuri } from 'neuri' + import { openaiConfig } from '../composables/config' import { actionsList } from './actions' diff --git a/services/minecraft/src/composables/action.ts b/services/minecraft/src/composables/action.ts index d22b43a8b..74d6a3431 100644 --- a/services/minecraft/src/composables/action.ts +++ b/services/minecraft/src/composables/action.ts @@ -1,4 +1,5 @@ import type { Agent } from './agent' + import { useLogg } from '@guiiai/logg' type Fn = (...args: any[]) => void diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 2cbfd062b..6f3d7b175 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -1,4 +1,5 @@ import type { BotOptions } from 'mineflayer' + import { env } from 'node:process' import { useLogg } from '@guiiai/logg' diff --git a/services/minecraft/src/composables/conversation.ts b/services/minecraft/src/composables/conversation.ts index 5d61f6590..3a651f243 100644 --- a/services/minecraft/src/composables/conversation.ts +++ b/services/minecraft/src/composables/conversation.ts @@ -1,4 +1,5 @@ import type { Agent } from './agent' + import { useLogg } from '@guiiai/logg' let self_prompter_paused = false diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/composables/world.ts index db4719ca4..fd7e95042 100644 --- a/services/minecraft/src/composables/world.ts +++ b/services/minecraft/src/composables/world.ts @@ -3,7 +3,9 @@ import type { Entity } from 'prismarine-entity' import type { Item } from 'prismarine-item' import type { Vec3 } from 'vec3' import type { Mineflayer } from '../libs/mineflayer' + import pf from 'mineflayer-pathfinder' + import * as mc from '../utils/mcdata' export function getNearestFreeSpace( diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index 446ac4782..af50a822e 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -1,9 +1,11 @@ import type { Bot, BotOptions } from 'mineflayer' import type { MineflayerPlugin } from './plugin' import type { EventHandlers, EventsHandler } from './types' + import { type Logg, useLogg } from '@guiiai/logg' import EventEmitter from 'eventemitter3' import mineflayer from 'mineflayer' + import { parseCommand } from './command' import { Components } from './components' import { Health } from './health' diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 138d1b162..cca879f8c 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,5 +1,4 @@ import process, { exit } from 'node:process' - import { useLogg } from '@guiiai/logg' import { Client } from '@proj-airi/server-sdk' import MineflayerArmorManager from 'mineflayer-armor-manager' diff --git a/services/minecraft/src/mineflayer/echo.ts b/services/minecraft/src/mineflayer/echo.ts index cf03105a7..b4be3dc5f 100644 --- a/services/minecraft/src/mineflayer/echo.ts +++ b/services/minecraft/src/mineflayer/echo.ts @@ -1,6 +1,7 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' + import { formBotChat } from '../libs/mineflayer/message' export function Echo(): MineflayerPlugin { diff --git a/services/minecraft/src/mineflayer/follow.ts b/services/minecraft/src/mineflayer/follow.ts index 7b5c5d8da..851b9bfd7 100644 --- a/services/minecraft/src/mineflayer/follow.ts +++ b/services/minecraft/src/mineflayer/follow.ts @@ -1,4 +1,5 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts index 145e9f8b3..5777e0b80 100644 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ b/services/minecraft/src/mineflayer/llm-agent.ts @@ -1,6 +1,7 @@ import type { Client } from '@proj-airi/server-sdk' import type { Neuri, NeuriContext } from 'neuri' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + import { useLogg } from '@guiiai/logg' import { assistant, system, user } from 'neuri/openai' diff --git a/services/minecraft/src/mineflayer/status.ts b/services/minecraft/src/mineflayer/status.ts index 2500835b1..b1401f649 100644 --- a/services/minecraft/src/mineflayer/status.ts +++ b/services/minecraft/src/mineflayer/status.ts @@ -1,4 +1,5 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + import { useLogg } from '@guiiai/logg' export function Status(): MineflayerPlugin { diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts index c27144b18..8e017dfd2 100644 --- a/services/minecraft/src/prompts/agent.ts +++ b/services/minecraft/src/prompts/agent.ts @@ -1,4 +1,5 @@ import type { Mineflayer } from '../libs/mineflayer' + import { listInventory } from '../skills/actions/inventory' export function genSystemBasicPrompt(botName: string): string { diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts index 35c539ca2..333d91d02 100644 --- a/services/minecraft/src/skills/actions/collect-block.ts +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -1,7 +1,9 @@ import type { Block } from 'prismarine-block' import type { Mineflayer } from '../../libs/mineflayer' + import { useLogg } from '@guiiai/logg' import pathfinder from 'mineflayer-pathfinder' + import { getNearestBlocks } from '../../composables/world' import { breakBlockAt } from '../blocks' import { ensurePickaxe } from './ensure' diff --git a/services/minecraft/src/skills/actions/ensure.ts b/services/minecraft/src/skills/actions/ensure.ts index dfed7c73b..cf22b9dfc 100644 --- a/services/minecraft/src/skills/actions/ensure.ts +++ b/services/minecraft/src/skills/actions/ensure.ts @@ -1,5 +1,7 @@ import type { Mineflayer } from '../../libs/mineflayer' + import { useLogg } from '@guiiai/logg' + import { getItemId } from '../../utils/mcdata' import { craftRecipe } from '../crafting' import { moveAway } from '../movement' diff --git a/services/minecraft/src/skills/actions/gather-wood.ts b/services/minecraft/src/skills/actions/gather-wood.ts index 678468c16..91479c13a 100644 --- a/services/minecraft/src/skills/actions/gather-wood.ts +++ b/services/minecraft/src/skills/actions/gather-wood.ts @@ -1,5 +1,7 @@ import type { Mineflayer } from '../../libs/mineflayer' + import { useLogg } from '@guiiai/logg' + import { getNearestBlocks } from '../../composables/world' import { sleep } from '../../utils/helper' import { breakBlockAt } from '../blocks' diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts index a63d068e2..0fa75ff8a 100644 --- a/services/minecraft/src/skills/actions/inventory.ts +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -2,6 +2,7 @@ import type { Item } from 'prismarine-item' import type { Mineflayer } from '../../libs/mineflayer' import { useLogg } from '@guiiai/logg' + import { getNearestBlock } from '../../composables/world' import { goToPlayer, goToPosition } from '../movement' diff --git a/services/minecraft/src/skills/actions/world-interactions.ts b/services/minecraft/src/skills/actions/world-interactions.ts index c4d1b77b7..fbfb5e163 100644 --- a/services/minecraft/src/skills/actions/world-interactions.ts +++ b/services/minecraft/src/skills/actions/world-interactions.ts @@ -1,9 +1,11 @@ import type { Bot } from 'mineflayer' import type { Block } from 'prismarine-block' import type { Mineflayer } from '../../libs/mineflayer' + import { useLogg } from '@guiiai/logg' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' + import { sleep } from '../../utils/helper' import { getNearestBlock, makeItem } from '../../utils/mcdata' import { goToPosition } from '../movement' diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index 7991de9c6..a2721f1e0 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,4 +1,5 @@ import type { Mineflayer } from '../libs/mineflayer' + import { useLogg } from '@guiiai/logg' const logger = useLogg('skills').useGlobalConfig() diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 4d9f2b9df..78e6cff87 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -3,6 +3,7 @@ import type { BlockFace } from './base' import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' + import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from '../composables/world' import { getBlockId, makeItem } from '../utils/mcdata' import { log } from './base' diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index 4d3623a2d..91c3c9ca1 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,4 +1,5 @@ import type { Mineflayer } from '../libs/mineflayer' + import { getNearestBlock } from '../composables/world' import { log } from './base' import { goToPlayer, goToPosition } from './movement' diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index 857c08ab9..a48ae85d9 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -5,6 +5,7 @@ import { useLogg } from '@guiiai/logg' import { randomInt } from 'es-toolkit' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' + import { getNearestBlock, getNearestEntityWhere } from '../composables/world' import { sleep } from '../utils/helper' import { log } from './base' diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 8dc96307c..21a02e937 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -2,6 +2,7 @@ import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' + import minecraftData, { type Biome, type ShapedRecipe, From 0ba9cf631f756924cd33a0d05b484cdf2ad7e30c Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 22 Jan 2025 00:33:27 +0800 Subject: [PATCH 67/77] feat: planning, action, chat, memory agent implementation (#1) --- services/minecraft/src/agents/action/index.ts | 180 +++++ .../llm-handler.test.ts} | 14 +- .../src/agents/action/llm-handler.ts | 49 ++ .../minecraft/src/agents/action/tools.test.ts | 61 ++ .../agents/{actions.ts => action/tools.ts} | 12 +- services/minecraft/src/agents/actions.test.ts | 90 --- services/minecraft/src/agents/chat/index.ts | 168 +++++ .../minecraft/src/agents/chat/llm-handler.ts | 46 ++ services/minecraft/src/agents/chat/llm.ts | 85 +++ services/minecraft/src/agents/chat/types.ts | 25 + services/minecraft/src/agents/memory/index.ts | 108 +++ services/minecraft/src/agents/openai.ts | 59 -- .../minecraft/src/agents/planning/index.ts | 666 ++++++++++++++++++ .../src/agents/planning/llm-handler.ts | 61 ++ services/minecraft/src/agents/prompt/chat.ts | 21 + .../src/agents/prompt/llm-agent.plugin.ts | 50 ++ .../minecraft/src/agents/prompt/planning.ts | 47 ++ services/minecraft/src/composables/action.ts | 169 ----- services/minecraft/src/composables/agent.ts | 36 - services/minecraft/src/composables/bot.ts | 25 +- services/minecraft/src/composables/config.ts | 59 +- .../minecraft/src/composables/conversation.ts | 384 ---------- services/minecraft/src/composables/neuri.ts | 42 ++ services/minecraft/src/container.ts | 71 ++ services/minecraft/src/libs/llm/base.ts | 45 ++ services/minecraft/src/libs/llm/types.ts | 14 + .../src/libs/mineflayer/base-agent.ts | 129 ++++ .../minecraft/src/libs/mineflayer/core.ts | 4 +- .../minecraft/src/libs/mineflayer/index.ts | 1 - .../src/libs/mineflayer/interfaces.ts | 3 - .../minecraft/src/libs/mineflayer/message.ts | 57 +- .../minecraft/src/libs/mineflayer/status.ts | 2 +- .../minecraft/src/libs/mineflayer/types.ts | 4 + services/minecraft/src/main.ts | 10 +- services/minecraft/src/manager/action.ts | 203 ++++++ .../minecraft/src/manager/conversation.ts | 382 ++++++++++ services/minecraft/src/mineflayer/index.ts | 2 - .../minecraft/src/mineflayer/llm-agent.ts | 113 --- .../src/{mineflayer => plugins}/echo.ts | 4 +- .../src/{mineflayer => plugins}/follow.ts | 0 services/minecraft/src/plugins/llm-agent.ts | 189 +++++ .../src/{mineflayer => plugins}/pathfinder.ts | 0 .../src/{mineflayer => plugins}/status.ts | 0 services/minecraft/src/prompts/agent.ts | 63 -- .../src/skills/actions/collect-block.ts | 2 +- .../src/skills/actions/gather-wood.ts | 2 +- .../minecraft/src/skills/actions/inventory.ts | 2 +- services/minecraft/src/skills/blocks.ts | 2 +- services/minecraft/src/skills/combat.ts | 2 +- services/minecraft/src/skills/crafting.ts | 2 +- services/minecraft/src/skills/inventory.ts | 2 +- services/minecraft/src/skills/movement.ts | 2 +- .../src/{composables => skills}/world.ts | 0 services/minecraft/src/utils/helper.ts | 38 + services/minecraft/src/utils/mcdata.ts | 2 - services/minecraft/src/utils/reliability.ts | 39 - 56 files changed, 2812 insertions(+), 1036 deletions(-) create mode 100644 services/minecraft/src/agents/action/index.ts rename services/minecraft/src/agents/{openai.test.ts => action/llm-handler.test.ts} (67%) create mode 100644 services/minecraft/src/agents/action/llm-handler.ts create mode 100644 services/minecraft/src/agents/action/tools.test.ts rename services/minecraft/src/agents/{actions.ts => action/tools.ts} (98%) delete mode 100644 services/minecraft/src/agents/actions.test.ts create mode 100644 services/minecraft/src/agents/chat/index.ts create mode 100644 services/minecraft/src/agents/chat/llm-handler.ts create mode 100644 services/minecraft/src/agents/chat/llm.ts create mode 100644 services/minecraft/src/agents/chat/types.ts create mode 100644 services/minecraft/src/agents/memory/index.ts delete mode 100644 services/minecraft/src/agents/openai.ts create mode 100644 services/minecraft/src/agents/planning/index.ts create mode 100644 services/minecraft/src/agents/planning/llm-handler.ts create mode 100644 services/minecraft/src/agents/prompt/chat.ts create mode 100644 services/minecraft/src/agents/prompt/llm-agent.plugin.ts create mode 100644 services/minecraft/src/agents/prompt/planning.ts delete mode 100644 services/minecraft/src/composables/action.ts delete mode 100644 services/minecraft/src/composables/agent.ts delete mode 100644 services/minecraft/src/composables/conversation.ts create mode 100644 services/minecraft/src/composables/neuri.ts create mode 100644 services/minecraft/src/container.ts create mode 100644 services/minecraft/src/libs/llm/base.ts create mode 100644 services/minecraft/src/libs/llm/types.ts create mode 100644 services/minecraft/src/libs/mineflayer/base-agent.ts delete mode 100644 services/minecraft/src/libs/mineflayer/interfaces.ts create mode 100644 services/minecraft/src/manager/action.ts create mode 100644 services/minecraft/src/manager/conversation.ts delete mode 100644 services/minecraft/src/mineflayer/index.ts delete mode 100644 services/minecraft/src/mineflayer/llm-agent.ts rename services/minecraft/src/{mineflayer => plugins}/echo.ts (75%) rename services/minecraft/src/{mineflayer => plugins}/follow.ts (100%) create mode 100644 services/minecraft/src/plugins/llm-agent.ts rename services/minecraft/src/{mineflayer => plugins}/pathfinder.ts (100%) rename services/minecraft/src/{mineflayer => plugins}/status.ts (100%) delete mode 100644 services/minecraft/src/prompts/agent.ts rename services/minecraft/src/{composables => skills}/world.ts (100%) delete mode 100644 services/minecraft/src/utils/reliability.ts diff --git a/services/minecraft/src/agents/action/index.ts b/services/minecraft/src/agents/action/index.ts new file mode 100644 index 000000000..7815b6348 --- /dev/null +++ b/services/minecraft/src/agents/action/index.ts @@ -0,0 +1,180 @@ +import type { Mineflayer } from '../../libs/mineflayer' +import type { Action } from '../../libs/mineflayer/action' +import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent' + +import { useBot } from '../../composables/bot' +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { ActionManager } from '../../manager/action' +import { actionsList } from './tools' + +interface ActionState { + executing: boolean + label: string + startTime: number +} + +/** + * ActionAgentImpl implements the ActionAgent interface to handle action execution + * Manages action lifecycle, state tracking and error handling + */ +export class ActionAgentImpl extends AbstractAgent implements ActionAgent { + public readonly type = 'action' as const + private actions: Map + private actionManager: ActionManager + private mineflayer: Mineflayer + private currentActionState: ActionState + + constructor(config: AgentConfig) { + super(config) + this.actions = new Map() + this.mineflayer = useBot().bot + this.actionManager = new ActionManager(this.mineflayer) + this.currentActionState = { + executing: false, + label: '', + startTime: 0, + } + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing action agent') + actionsList.forEach(action => this.actions.set(action.name, action)) + + // Set up event listeners + // todo: nothing to call here + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + } + + protected async destroyAgent(): Promise { + await this.actionManager.stop() + this.actionManager.cancelResume() + this.actions.clear() + this.removeAllListeners() + this.currentActionState = { + executing: false, + label: '', + startTime: 0, + } + } + + public async performAction( + name: string, + params: unknown[], + options: { timeout?: number, resume?: boolean } = {}, + ): Promise { + if (!this.initialized) { + throw new Error('Action agent not initialized') + } + + const action = this.actions.get(name) + if (!action) { + throw new Error(`Action not found: ${name}`) + } + + try { + this.updateActionState(true, name) + this.logger.withFields({ name, params }).log('Performing action') + + const result = await this.actionManager.runAction( + name, + async () => { + const fn = action.perform(this.mineflayer) + return await fn(...params) + }, + { + timeout: options.timeout ?? 60, + resume: options.resume ?? false, + }, + ) + + if (!result.success) { + throw new Error(result.message ?? 'Action failed') + } + + return this.formatActionOutput({ + message: result.message, + timedout: result.timedout, + interrupted: false, + }) + } + catch (error) { + this.logger.withFields({ name, params, error }).error('Failed to perform action') + throw error + } + finally { + this.updateActionState(false) + } + } + + public async resumeAction(name: string, params: unknown[]): Promise { + const action = this.actions.get(name) + if (!action) { + throw new Error(`Action not found: ${name}`) + } + + try { + this.updateActionState(true, name) + const result = await this.actionManager.resumeAction( + name, + async () => { + const fn = action.perform(this.mineflayer) + return await fn(...params) + }, + 60, + ) + + if (!result.success) { + throw new Error(result.message ?? 'Action failed') + } + + return this.formatActionOutput({ + message: result.message, + timedout: result.timedout, + interrupted: false, + }) + } + catch (error) { + this.logger.withFields({ name, params, error }).error('Failed to resume action') + throw error + } + finally { + this.updateActionState(false) + } + } + + public getAvailableActions(): Action[] { + return Array.from(this.actions.values()) + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system') { + if (message.includes('interrupt')) { + await this.actionManager.stop() + } + } + else { + this.logger.withFields({ sender, message }).log('Processing agent message') + } + } + + private updateActionState(executing: boolean, label = ''): void { + this.currentActionState = { + executing, + label, + startTime: executing ? Date.now() : 0, + } + this.emit('actionStateChanged', this.currentActionState) + } + + private formatActionOutput(result: { message: string | null, timedout: boolean, interrupted: boolean }): string { + if (result.timedout) { + return `Action timed out: ${result.message}` + } + if (result.interrupted) { + return 'Action was interrupted' + } + return result.message ?? '' + } +} diff --git a/services/minecraft/src/agents/openai.test.ts b/services/minecraft/src/agents/action/llm-handler.test.ts similarity index 67% rename from services/minecraft/src/agents/openai.test.ts rename to services/minecraft/src/agents/action/llm-handler.test.ts index 1e66564d1..56e9f6d28 100644 --- a/services/minecraft/src/agents/openai.test.ts +++ b/services/minecraft/src/agents/action/llm-handler.test.ts @@ -1,11 +1,11 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' -import { initBot, useBot } from '../composables/bot' -import { botConfig, initEnv } from '../composables/config' -import { genSystemBasicPrompt } from '../prompts/agent' -import { initLogger } from '../utils/logger' -import { initAgent } from './openai' +import { initBot, useBot } from '../../composables/bot' +import { botConfig, initEnv } from '../../composables/config' +import { createNeuriAgent } from '../../composables/neuri' +import { initLogger } from '../../utils/logger' +import { generateSystemBasicPrompt } from '../prompt/llm-agent.plugin' describe('openAI agent', { timeout: 0 }, () => { beforeAll(() => { @@ -16,13 +16,13 @@ describe('openAI agent', { timeout: 0 }, () => { it('should initialize the agent', async () => { const { bot } = useBot() - const agent = await initAgent(bot) + const agent = await createNeuriAgent(bot) await new Promise((resolve) => { bot.bot.once('spawn', async () => { const text = await agent.handle( messages( - system(genSystemBasicPrompt('airi')), + system(generateSystemBasicPrompt('airi')), user('Hello, who are you?'), ), async (c) => { diff --git a/services/minecraft/src/agents/action/llm-handler.ts b/services/minecraft/src/agents/action/llm-handler.ts new file mode 100644 index 000000000..82e3aa160 --- /dev/null +++ b/services/minecraft/src/agents/action/llm-handler.ts @@ -0,0 +1,49 @@ +import type { Agent } from 'neuri' +import type { Message } from 'neuri/openai' +import type { Mineflayer } from '../../libs/mineflayer' + +import { useLogg } from '@guiiai/logg' +import { agent } from 'neuri' + +import { BaseLLMHandler } from '../../libs/llm/base' +import { actionsList } from './tools' + +export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { + const logger = useLogg('action-neuri').useGlobalConfig() + logger.log('Initializing action agent') + let actionAgent = agent('action') + + Object.values(actionsList).forEach((action) => { + actionAgent = actionAgent.tool( + action.name, + action.schema, + async ({ parameters }) => { + logger.withFields({ name: action.name, parameters }).log('Calling action') + mineflayer.memory.actions.push(action) + const fn = action.perform(mineflayer) + return await fn(...Object.values(parameters)) + }, + { description: action.description }, + ) + }) + + return actionAgent.build() +} + +export class ActionLLMHandler extends BaseLLMHandler { + public async handleAction(messages: Message[]): Promise { + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Processing action...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'action', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to process action') + } + + return result + } +} diff --git a/services/minecraft/src/agents/action/tools.test.ts b/services/minecraft/src/agents/action/tools.test.ts new file mode 100644 index 000000000..a3aac8573 --- /dev/null +++ b/services/minecraft/src/agents/action/tools.test.ts @@ -0,0 +1,61 @@ +import { messages, system, user } from 'neuri/openai' +import { beforeAll, describe, expect, it } from 'vitest' + +import { initBot, useBot } from '../../composables/bot' +import { botConfig, initEnv } from '../../composables/config' +import { createNeuriAgent } from '../../composables/neuri' +import { sleep } from '../../utils/helper' +import { initLogger } from '../../utils/logger' +import { generateActionAgentPrompt } from '../prompt/llm-agent.plugin' + +describe('actions agent', { timeout: 0 }, () => { + beforeAll(() => { + initLogger() + initEnv() + initBot({ botConfig }) + }) + + it('should choose right query command', async () => { + const { bot } = useBot() + const agent = await createNeuriAgent(bot) + + await new Promise((resolve) => { + bot.bot.once('spawn', async () => { + const text = await agent.handle(messages( + system(generateActionAgentPrompt(bot)), + user('What\'s your status?'), + ), async (c) => { + const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + return await completion?.firstContent() + }) + + expect(text?.toLowerCase()).toContain('position') + + resolve() + }) + }) + }) + + it('should choose right action command', async () => { + const { bot } = useBot() + const agent = await createNeuriAgent(bot) + + await new Promise((resolve) => { + bot.bot.on('spawn', async () => { + const text = await agent.handle(messages( + system(generateActionAgentPrompt(bot)), + user('goToPlayer: luoling8192'), + ), async (c) => { + const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + + return await completion?.firstContent() + }) + + expect(text).toContain('goToPlayer') + + await sleep(10000) + resolve() + }) + }) + }) +}) diff --git a/services/minecraft/src/agents/actions.ts b/services/minecraft/src/agents/action/tools.ts similarity index 98% rename from services/minecraft/src/agents/actions.ts rename to services/minecraft/src/agents/action/tools.ts index c40a2b445..61f89dbb4 100644 --- a/services/minecraft/src/agents/actions.ts +++ b/services/minecraft/src/agents/action/tools.ts @@ -1,13 +1,13 @@ -import type { Action } from '../libs/mineflayer' +import type { Action } from '../../libs/mineflayer' import { useLogg } from '@guiiai/logg' import { z } from 'zod' -import * as world from '../composables/world' -import * as skills from '../skills' -import { collectBlock } from '../skills/actions/collect-block' -import { discard, equip, putInChest, takeFromChest, viewChest } from '../skills/actions/inventory' -import { activateNearestBlock, placeBlock } from '../skills/actions/world-interactions' +import * as skills from '../../skills' +import { collectBlock } from '../../skills/actions/collect-block' +import { discard, equip, putInChest, takeFromChest, viewChest } from '../../skills/actions/inventory' +import { activateNearestBlock, placeBlock } from '../../skills/actions/world-interactions' +import * as world from '../../skills/world' // Utils const pad = (str: string): string => `\n${str}\n` diff --git a/services/minecraft/src/agents/actions.test.ts b/services/minecraft/src/agents/actions.test.ts deleted file mode 100644 index 8dcc217ac..000000000 --- a/services/minecraft/src/agents/actions.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { messages, system, user } from 'neuri/openai' -import { beforeAll, describe, expect, it } from 'vitest' - -import { initBot, useBot } from '../composables/bot' -import { botConfig, initEnv } from '../composables/config' -import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent' -import { sleep } from '../utils/helper' -import { initLogger } from '../utils/logger' -import { initAgent } from './openai' - -describe('actions agent', { timeout: 0 }, () => { - beforeAll(() => { - initLogger() - initEnv() - initBot({ botConfig }) - }) - - it('should choose right query command', async () => { - const { bot } = useBot() - const agent = await initAgent(bot) - - await new Promise((resolve) => { - bot.bot.once('spawn', async () => { - const text = await agent.handle(messages( - system(genQueryAgentPrompt(bot)), - user('What\'s your status?'), - ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() - }) - - expect(text?.toLowerCase()).toContain('position') - - resolve() - }) - }) - }) - - it('should choose right action command', async () => { - const { bot } = useBot() - const agent = await initAgent(bot) - - // console.log(JSON.stringify(agent, null, 2)) - - await new Promise((resolve) => { - bot.bot.on('spawn', async () => { - const text = await agent.handle(messages( - system(genActionAgentPrompt(bot)), - user('goToPlayer: luoling8192'), - ), async (c) => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - return await completion?.firstContent() - }) - - expect(text?.toLowerCase()).toContain('goToPlayer') - - await sleep(10000) - resolve() - }) - }) - }) - - // it('should split question into actions', async () => { - // const { ctx } = useBot() - // const agent = await initAgent(ctx) - - // function testFn() { - // return new Promise((resolve) => { - // ctx.bot.on('spawn', async () => { - // const text = await agent.handle(messages( - // system(genActionAgentPrompt(ctx)), - // user('Help me to cut down the tree'), - // ), async (c) => { - // const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - - // console.log(completion) - - // return await completion?.firstContent() - // }) - - // console.log(text) - - // resolve() - // }) - // }) - // } - - // await testFn() - // }) -}) diff --git a/services/minecraft/src/agents/chat/index.ts b/services/minecraft/src/agents/chat/index.ts new file mode 100644 index 000000000..5def0e02d --- /dev/null +++ b/services/minecraft/src/agents/chat/index.ts @@ -0,0 +1,168 @@ +import type { ChatAgent } from '../../libs/mineflayer/base-agent' +import type { ChatAgentConfig, ChatContext } from './types' + +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { generateChatResponse } from './llm' + +export class ChatAgentImpl extends AbstractAgent implements ChatAgent { + public readonly type = 'chat' as const + private activeChats: Map + private maxHistoryLength: number + private idleTimeout: number + private llmConfig: ChatAgentConfig['llm'] + + constructor(config: ChatAgentConfig) { + super(config) + this.activeChats = new Map() + this.maxHistoryLength = config.maxHistoryLength ?? 50 + this.idleTimeout = config.idleTimeout ?? 5 * 60 * 1000 // 5 minutes + this.llmConfig = config.llm + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing chat agent') + + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + + setInterval(() => { + this.checkIdleChats() + }, 60 * 1000) + } + + protected async destroyAgent(): Promise { + this.activeChats.clear() + this.removeAllListeners() + } + + public async processMessage(message: string, sender: string): Promise { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withFields({ sender, message }).log('Processing message') + + try { + // Get or create chat context + const context = this.getOrCreateContext(sender) + + // Add message to history + this.addToHistory(context, sender, message) + + // Update last activity time + context.lastUpdate = Date.now() + + // Generate response using LLM + const response = await this.generateResponse(message, context) + + // Add response to history + this.addToHistory(context, this.id, response) + + return response + } + catch (error) { + this.logger.withError(error).error('Failed to process message') + throw error + } + } + + public startConversation(player: string): void { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withField('player', player).log('Starting conversation') + + const context = this.getOrCreateContext(player) + context.startTime = Date.now() + context.lastUpdate = Date.now() + } + + public endConversation(player: string): void { + if (!this.initialized) { + throw new Error('Chat agent not initialized') + } + + this.logger.withField('player', player).log('Ending conversation') + + if (this.activeChats.has(player)) { + const context = this.activeChats.get(player)! + // Archive chat history if needed + this.archiveChat(context) + this.activeChats.delete(player) + } + } + + private getOrCreateContext(player: string): ChatContext { + let context = this.activeChats.get(player) + if (!context) { + context = { + player, + startTime: Date.now(), + lastUpdate: Date.now(), + history: [], + } + this.activeChats.set(player, context) + } + return context + } + + private addToHistory(context: ChatContext, sender: string, message: string): void { + context.history.push({ + sender, + message, + timestamp: Date.now(), + }) + + // Trim history if too long + if (context.history.length > this.maxHistoryLength) { + context.history = context.history.slice(-this.maxHistoryLength) + } + } + + private async generateResponse(message: string, context: ChatContext): Promise { + return await generateChatResponse(message, context.history, { + agent: this.llmConfig.agent, + model: this.llmConfig.model, + maxContextLength: this.maxHistoryLength, + }) + } + + private checkIdleChats(): void { + const now = Date.now() + for (const [player, context] of this.activeChats.entries()) { + if (now - context.lastUpdate > this.idleTimeout) { + this.logger.withField('player', player).log('Ending idle conversation') + this.endConversation(player) + } + } + } + + private async archiveChat(context: ChatContext): Promise { + // Archive chat history to persistent storage if needed + this.logger.withFields({ + player: context.player, + messageCount: context.history.length, + duration: Date.now() - context.startTime, + }).log('Archiving chat history') + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system') { + if (message.includes('interrupt')) { + // Handle system interrupt + for (const player of this.activeChats.keys()) { + this.endConversation(player) + } + } + } + else { + // Handle messages from other agents + const context = this.activeChats.get(sender) + if (context) { + await this.processMessage(message, sender) + } + } + } +} diff --git a/services/minecraft/src/agents/chat/llm-handler.ts b/services/minecraft/src/agents/chat/llm-handler.ts new file mode 100644 index 000000000..7743a8fed --- /dev/null +++ b/services/minecraft/src/agents/chat/llm-handler.ts @@ -0,0 +1,46 @@ +import type { ChatHistory } from './types' + +import { system, user } from 'neuri/openai' + +import { BaseLLMHandler } from '../../libs/llm/base' +import { genChatAgentPrompt } from '../prompt/chat' + +export class ChatLLMHandler extends BaseLLMHandler { + public async generateResponse( + message: string, + history: ChatHistory[], + ): Promise { + const systemPrompt = genChatAgentPrompt() + const chatHistory = this.formatChatHistory(history, this.config.maxContextLength ?? 10) + const messages = [ + system(systemPrompt), + ...chatHistory, + user(message), + ] + + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Generating response...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'chat', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to generate response') + } + + return result + } + + private formatChatHistory( + history: ChatHistory[], + maxLength: number, + ): Array<{ role: 'user' | 'assistant', content: string }> { + const recentHistory = history.slice(-maxLength) + return recentHistory.map(entry => ({ + role: entry.sender === 'bot' ? 'assistant' : 'user', + content: entry.message, + })) + } +} diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts new file mode 100644 index 000000000..6659db15c --- /dev/null +++ b/services/minecraft/src/agents/chat/llm.ts @@ -0,0 +1,85 @@ +import type { Agent, Neuri } from 'neuri' +import type { ChatHistory } from './types' + +import { useLogg } from '@guiiai/logg' +import { agent } from 'neuri' +import { system, user } from 'neuri/openai' + +import { toRetriable } from '../../utils/helper' +import { genChatAgentPrompt } from '../prompt/chat' + +const logger = useLogg('chat-llm').useGlobalConfig() + +interface LLMChatConfig { + agent: Neuri + model?: string + retryLimit?: number + delayInterval?: number + maxContextLength?: number +} + +export async function createChatNeuriAgent(): Promise { + return agent('chat').build() +} + +export async function generateChatResponse( + message: string, + history: ChatHistory[], + config: LLMChatConfig, +): Promise { + const systemPrompt = genChatAgentPrompt() + const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10) + const userPrompt = message + + const messages = [ + system(systemPrompt), + ...chatHistory, + user(userPrompt), + ] + + const content = await config.agent.handleStateless(messages, async (c) => { + logger.log('Generating response...') + + const handleCompletion = async (c: any): Promise => { + const completion = await c.reroute('chat', c.messages, { + model: config.model ?? 'openai/gpt-4o-mini', + }) + + if (!completion || 'error' in completion) { + logger.withFields(c).error('Completion failed') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion.firstContent() + logger.withFields({ usage: completion.usage, content }).log('Response generated') + return content + } + + const retriableHandler = toRetriable( + config.retryLimit ?? 3, + config.delayInterval ?? 1000, + handleCompletion, + ) + + return await retriableHandler(c) + }) + + if (!content) { + throw new Error('Failed to generate response') + } + + return content +} + +function formatChatHistory( + history: ChatHistory[], + maxLength: number, +): Array<{ role: 'user' | 'assistant', content: string }> { + // Take the most recent messages up to maxLength + const recentHistory = history.slice(-maxLength) + + return recentHistory.map(entry => ({ + role: entry.sender === 'bot' ? 'assistant' : 'user', + content: entry.message, + })) +} diff --git a/services/minecraft/src/agents/chat/types.ts b/services/minecraft/src/agents/chat/types.ts new file mode 100644 index 000000000..32410bedc --- /dev/null +++ b/services/minecraft/src/agents/chat/types.ts @@ -0,0 +1,25 @@ +import type { Neuri } from 'neuri' + +export interface ChatHistory { + sender: string + message: string + timestamp: number +} + +export interface ChatContext { + player: string + startTime: number + lastUpdate: number + history: ChatHistory[] +} + +export interface ChatAgentConfig { + id: string + type: 'chat' + llm: { + agent: Neuri + model?: string + } + maxHistoryLength?: number + idleTimeout?: number +} diff --git a/services/minecraft/src/agents/memory/index.ts b/services/minecraft/src/agents/memory/index.ts new file mode 100644 index 000000000..8cf7c41e9 --- /dev/null +++ b/services/minecraft/src/agents/memory/index.ts @@ -0,0 +1,108 @@ +import type { Message } from 'neuri/openai' +import type { Action } from '../../libs/mineflayer' +import type { AgentConfig, MemoryAgent } from '../../libs/mineflayer/base-agent' + +import { useLogg } from '@guiiai/logg' + +import { Memory } from '../../libs/mineflayer/memory' + +const logger = useLogg('memory-agent').useGlobalConfig() + +export class MemoryAgentImpl implements MemoryAgent { + public readonly type = 'memory' as const + public readonly id: string + private memory: Map + private initialized: boolean + private memoryInstance: Memory + + constructor(config: AgentConfig) { + this.id = config.id + this.memory = new Map() + this.initialized = false + this.memoryInstance = new Memory() + } + + async init(): Promise { + if (this.initialized) { + return + } + + logger.log('Initializing memory agent') + this.initialized = true + } + + async destroy(): Promise { + this.memory.clear() + this.initialized = false + } + + remember(key: string, value: unknown): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + logger.withFields({ key, value }).log('Storing memory') + this.memory.set(key, value) + } + + recall(key: string): T | undefined { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + const value = this.memory.get(key) as T | undefined + logger.withFields({ key, value }).log('Recalling memory') + return value + } + + forget(key: string): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + logger.withFields({ key }).log('Forgetting memory') + this.memory.delete(key) + } + + getMemorySnapshot(): Record { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return Object.fromEntries(this.memory.entries()) + } + + addChatMessage(message: Message): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.memoryInstance.chatHistory.push(message) + logger.withFields({ message }).log('Adding chat message to memory') + } + + addAction(action: Action): void { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + this.memoryInstance.actions.push(action) + logger.withFields({ action }).log('Adding action to memory') + } + + getChatHistory(): Message[] { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return this.memoryInstance.chatHistory + } + + getActions(): Action[] { + if (!this.initialized) { + throw new Error('Memory agent not initialized') + } + + return this.memoryInstance.actions + } +} diff --git a/services/minecraft/src/agents/openai.ts b/services/minecraft/src/agents/openai.ts deleted file mode 100644 index 5b17f23e9..000000000 --- a/services/minecraft/src/agents/openai.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Agent, Neuri } from 'neuri' -import type { Mineflayer } from '../libs/mineflayer' - -import { useLogg } from '@guiiai/logg' -import { agent, neuri } from 'neuri' - -import { openaiConfig } from '../composables/config' -import { actionsList } from './actions' - -let neuriAgent: Neuri | undefined -const agents = new Set>() - -const logger = useLogg('openai').useGlobalConfig() - -export async function initAgent(mineflayer: Mineflayer): Promise { - logger.log('Initializing agent') - let n = neuri() - - agents.add(initActionAgent(mineflayer)) - - agents.forEach(agent => n = n.agent(agent)) - - neuriAgent = await n.build({ - provider: { - apiKey: openaiConfig.apiKey, - baseURL: openaiConfig.baseUrl, - }, - }) - - return neuriAgent -} - -export function getAgent(): Neuri { - if (!neuriAgent) { - throw new Error('Agent not initialized') - } - return neuriAgent -} - -export async function initActionAgent(mineflayer: Mineflayer): Promise { - logger.log('Initializing action agent') - let actionAgent = agent('action') - - Object.values(actionsList).forEach((action) => { - actionAgent = actionAgent.tool( - action.name, - action.schema, - async ({ parameters }) => { - logger.withFields({ name: action.name, parameters }).log('Calling action') - mineflayer.memory.actions.push(action) - const fn = action.perform(mineflayer) - return await fn(...Object.values(parameters)) - }, - { description: action.description }, - ) - }) - - return actionAgent.build() -} diff --git a/services/minecraft/src/agents/planning/index.ts b/services/minecraft/src/agents/planning/index.ts new file mode 100644 index 000000000..929a67070 --- /dev/null +++ b/services/minecraft/src/agents/planning/index.ts @@ -0,0 +1,666 @@ +import type { Neuri } from 'neuri' +import type { Action } from '../../libs/mineflayer/action' +import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from '../../libs/mineflayer/base-agent' + +import { AbstractAgent } from '../../libs/mineflayer/base-agent' +import { ActionAgentImpl } from '../action' +import { PlanningLLMHandler } from './llm-handler' + +interface PlanContext { + goal: string + currentStep: number + startTime: number + lastUpdate: number + retryCount: number + isGenerating: boolean + pendingSteps: Array<{ + action: string + params: unknown[] + }> +} + +interface PlanTemplate { + goal: string + conditions: string[] + steps: Array<{ + action: string + params: unknown[] + }> + requiresAction: boolean +} + +export interface PlanningAgentConfig extends AgentConfig { + llm: { + agent: Neuri + model?: string + } +} + +export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { + public readonly type = 'planning' as const + private currentPlan: Plan | null = null + private context: PlanContext | null = null + private actionAgent: ActionAgent | null = null + private memoryAgent: MemoryAgent | null = null + private planTemplates: Map + private llmConfig: PlanningAgentConfig['llm'] + private llmHandler: PlanningLLMHandler + + constructor(config: PlanningAgentConfig) { + super(config) + this.planTemplates = new Map() + this.llmConfig = config.llm + this.initializePlanTemplates() + this.llmHandler = new PlanningLLMHandler({ + agent: this.llmConfig.agent, + model: this.llmConfig.model, + }) + } + + protected async initializeAgent(): Promise { + this.logger.log('Initializing planning agent') + + // Create action agent directly + this.actionAgent = new ActionAgentImpl({ + id: 'action', + type: 'action', + }) + await this.actionAgent.init() + + // Set event listener + this.on('message', async ({ sender, message }) => { + await this.handleAgentMessage(sender, message) + }) + + this.on('interrupt', () => { + this.handleInterrupt() + }) + } + + protected async destroyAgent(): Promise { + this.currentPlan = null + this.context = null + this.actionAgent = null + this.memoryAgent = null + this.planTemplates.clear() + this.removeAllListeners() + } + + public async createPlan(goal: string): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + this.logger.withField('goal', goal).log('Creating plan') + + try { + // Check memory for existing plan + const cachedPlan = await this.loadCachedPlan(goal) + if (cachedPlan) { + this.logger.log('Using cached plan') + return cachedPlan + } + + // Get available actions from action agent + const availableActions = this.actionAgent?.getAvailableActions() ?? [] + + // Check if the goal requires actions + const requirements = this.parseGoalRequirements(goal) + const requiresAction = this.doesGoalRequireAction(requirements) + + // If no actions needed, return empty plan + if (!requiresAction) { + this.logger.log('Goal does not require actions') + return { + goal, + steps: [], + status: 'completed', + requiresAction: false, + } + } + + // Create plan steps based on available actions and goal + const steps = await this.generatePlanSteps(goal, availableActions) + + // Create new plan + const plan: Plan = { + goal, + steps, + status: 'pending', + requiresAction: true, + } + + // Cache the plan + await this.cachePlan(plan) + + this.currentPlan = plan + this.context = { + goal, + currentStep: 0, + startTime: Date.now(), + lastUpdate: Date.now(), + retryCount: 0, + isGenerating: false, + pendingSteps: [], + } + + return plan + } + catch (error) { + this.logger.withError(error).error('Failed to create plan') + throw error + } + } + + public async executePlan(plan: Plan): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + if (!plan.requiresAction) { + this.logger.log('Plan does not require actions, skipping execution') + return + } + + if (!this.actionAgent) { + throw new Error('Action agent not available') + } + + this.logger.withField('plan', plan).log('Executing plan') + + try { + plan.status = 'in_progress' + this.currentPlan = plan + + // Start generating and executing steps in parallel + await this.generateAndExecutePlanSteps(plan) + + plan.status = 'completed' + } + catch (error) { + plan.status = 'failed' + throw error + } + finally { + this.context = null + } + } + + private async generateAndExecutePlanSteps(plan: Plan): Promise { + if (!this.context || !this.actionAgent) { + return + } + + // Initialize step generation + this.context.isGenerating = true + this.context.pendingSteps = [] + + // Get available actions + const availableActions = this.actionAgent.getAvailableActions() + + // Start step generation + const generationPromise = this.generateStepsStream(plan.goal, availableActions) + + // Start step execution + const executionPromise = this.executeStepsStream() + + // Wait for both generation and execution to complete + await Promise.all([generationPromise, executionPromise]) + } + + private async generateStepsStream( + goal: string, + availableActions: Action[], + ): Promise { + if (!this.context) { + return + } + + try { + // Generate steps in chunks + const generator = this.createStepGenerator(goal, availableActions) + for await (const steps of generator) { + if (!this.context.isGenerating) { + break + } + + // Add generated steps to pending queue + this.context.pendingSteps.push(...steps) + this.logger.withField('steps', steps).log('Generated new steps') + } + } + catch (error) { + this.logger.withError(error).error('Failed to generate steps') + throw error + } + finally { + this.context.isGenerating = false + } + } + + private async executeStepsStream(): Promise { + if (!this.context || !this.actionAgent) { + return + } + + try { + while (this.context.isGenerating || this.context.pendingSteps.length > 0) { + // Wait for steps to be available + if (this.context.pendingSteps.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)) + continue + } + + // Execute next step + const step = this.context.pendingSteps.shift() + if (!step) { + continue + } + + try { + this.logger.withField('step', step).log('Executing step') + await this.actionAgent.performAction(step.action, step.params) + this.context.lastUpdate = Date.now() + this.context.currentStep++ + } + catch (stepError) { + this.logger.withError(stepError).error('Failed to execute step') + + // Attempt to adjust plan and retry + if (this.context.retryCount < 3) { + this.context.retryCount++ + // Stop current generation + this.context.isGenerating = false + this.context.pendingSteps = [] + // Adjust plan and restart + const adjustedPlan = await this.adjustPlan( + this.currentPlan!, + stepError instanceof Error ? stepError.message : 'Unknown error', + ) + await this.executePlan(adjustedPlan) + return + } + + throw stepError + } + } + } + catch (error) { + this.logger.withError(error).error('Failed to execute steps') + throw error + } + } + + private async *createStepGenerator( + goal: string, + availableActions: Action[], + ): AsyncGenerator, void, unknown> { + // First, try to find a matching template + const template = this.findMatchingTemplate(goal) + if (template) { + this.logger.log('Using plan template') + yield template.steps + return + } + + // If no template matches, use LLM to generate plan in chunks + this.logger.log('Generating plan using LLM') + const chunkSize = 3 // Generate 3 steps at a time + let currentChunk = 1 + + while (true) { + const steps = await this.llmHandler.generatePlan( + goal, + availableActions, + `Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`, + ) + + if (steps.length === 0) { + break + } + + yield steps + currentChunk++ + + // Check if we've generated enough steps or if the goal is achieved + if (steps.length < chunkSize || await this.isGoalAchieved(goal)) { + break + } + } + } + + private async isGoalAchieved(goal: string): Promise { + if (!this.context || !this.actionAgent) { + return false + } + + const requirements = this.parseGoalRequirements(goal) + + // Check inventory for required items + if (requirements.needsItems && requirements.items) { + const inventorySteps = this.generateGatheringSteps(requirements.items) + if (inventorySteps.length > 0) { + this.context.pendingSteps.push(...inventorySteps) + return false + } + } + + // Check location requirements + if (requirements.needsMovement && requirements.location) { + const movementSteps = this.generateMovementSteps(requirements.location) + if (movementSteps.length > 0) { + this.context.pendingSteps.push(...movementSteps) + return false + } + } + + // Check interaction requirements + if (requirements.needsInteraction && requirements.target) { + const interactionSteps = this.generateInteractionSteps(requirements.target) + if (interactionSteps.length > 0) { + this.context.pendingSteps.push(...interactionSteps) + return false + } + } + + return true + } + + public async adjustPlan(plan: Plan, feedback: string): Promise { + if (!this.initialized) { + throw new Error('Planning agent not initialized') + } + + this.logger.withFields({ plan, feedback }).log('Adjusting plan') + + try { + // If there's a current context, use it to adjust the plan + if (this.context) { + const currentStep = this.context.currentStep + const availableActions = this.actionAgent?.getAvailableActions() ?? [] + + // Generate recovery steps based on feedback + const recoverySteps = this.generateRecoverySteps(feedback) + + // Generate new steps from the current point + const newSteps = await this.generatePlanSteps(plan.goal, availableActions, feedback) + + // Create adjusted plan + const adjustedPlan: Plan = { + goal: plan.goal, + steps: [ + ...plan.steps.slice(0, currentStep), + ...recoverySteps, + ...newSteps, + ], + status: 'pending', + requiresAction: true, + } + + return adjustedPlan + } + + // If no context, create a new plan + return this.createPlan(plan.goal) + } + catch (error) { + this.logger.withError(error).error('Failed to adjust plan') + throw error + } + } + + private generateGatheringSteps(items: string[]): Array<{ action: string, params: unknown[] }> { + const steps: Array<{ action: string, params: unknown[] }> = [] + + for (const item of items) { + steps.push( + { action: 'searchForBlock', params: [item, 64] }, + { action: 'collectBlocks', params: [item, 1] }, + ) + } + + return steps + } + + private generateMovementSteps(location: { x?: number, y?: number, z?: number }): Array<{ action: string, params: unknown[] }> { + if (location.x !== undefined && location.y !== undefined && location.z !== undefined) { + return [{ + action: 'goToCoordinates', + params: [location.x, location.y, location.z, 1], + }] + } + return [] + } + + private generateInteractionSteps(target: string): Array<{ action: string, params: unknown[] }> { + return [{ + action: 'activate', + params: [target], + }] + } + + private generateRecoverySteps(feedback: string): Array<{ action: string, params: unknown[] }> { + const steps: Array<{ action: string, params: unknown[] }> = [] + + if (feedback.includes('not found')) { + steps.push({ action: 'searchForBlock', params: ['any', 128] }) + } + + if (feedback.includes('inventory full')) { + steps.push({ action: 'discard', params: ['cobblestone', 64] }) + } + + if (feedback.includes('blocked') || feedback.includes('cannot reach')) { + steps.push({ action: 'moveAway', params: [5] }) + } + + if (feedback.includes('too far')) { + steps.push({ action: 'moveAway', params: [-3] }) // Move closer + } + + if (feedback.includes('need tool')) { + steps.push( + { action: 'craftRecipe', params: ['wooden_pickaxe', 1] }, + { action: 'equip', params: ['wooden_pickaxe'] }, + ) + } + + return steps + } + + private async loadCachedPlan(goal: string): Promise { + if (!this.memoryAgent) + return null + + const cachedPlan = this.memoryAgent.recall(`plan:${goal}`) + if (cachedPlan && this.isPlanValid(cachedPlan)) { + return cachedPlan + } + return null + } + + private async cachePlan(plan: Plan): Promise { + if (!this.memoryAgent) + return + + this.memoryAgent.remember(`plan:${plan.goal}`, plan) + } + + private isPlanValid(_plan: Plan): boolean { + // Add validation logic here + return true + } + + private initializePlanTemplates(): void { + // Add common plan templates + this.planTemplates.set('collect wood', { + goal: 'collect wood', + conditions: ['needs_axe', 'near_trees'], + steps: [ + { action: 'searchForBlock', params: ['log', 64] }, + { action: 'collectBlocks', params: ['log', 1] }, + ], + requiresAction: true, + }) + + this.planTemplates.set('find shelter', { + goal: 'find shelter', + conditions: ['is_night', 'unsafe'], + steps: [ + { action: 'searchForBlock', params: ['bed', 64] }, + { action: 'goToBed', params: [] }, + ], + requiresAction: true, + }) + + // Add templates for non-action goals + this.planTemplates.set('hello', { + goal: 'hello', + conditions: [], + steps: [], + requiresAction: false, + }) + + this.planTemplates.set('how are you', { + goal: 'how are you', + conditions: [], + steps: [], + requiresAction: false, + }) + } + + private async handleAgentMessage(sender: string, message: string): Promise { + if (sender === 'system') { + if (message.includes('interrupt')) { + this.handleInterrupt() + } + } + else { + // Process message and potentially adjust plan + this.logger.withFields({ sender, message }).log('Processing agent message') + + // If there's a current plan, try to adjust it based on the message + if (this.currentPlan) { + await this.adjustPlan(this.currentPlan, message) + } + } + } + + private handleInterrupt(): void { + if (this.currentPlan) { + this.currentPlan.status = 'failed' + this.context = null + } + } + + private doesGoalRequireAction(requirements: ReturnType): boolean { + // Check if any requirement indicates need for action + return requirements.needsItems + || requirements.needsMovement + || requirements.needsInteraction + || requirements.needsCrafting + || requirements.needsCombat + } + + private async generatePlanSteps( + goal: string, + availableActions: Action[], + feedback?: string, + ): Promise> { + // First, try to find a matching template + const template = this.findMatchingTemplate(goal) + if (template) { + this.logger.log('Using plan template') + return template.steps + } + + // If no template matches, use LLM to generate plan + this.logger.log('Generating plan using LLM') + return await this.llmHandler.generatePlan(goal, availableActions, feedback) + } + + private findMatchingTemplate(goal: string): PlanTemplate | undefined { + for (const [pattern, template] of this.planTemplates.entries()) { + if (goal.toLowerCase().includes(pattern.toLowerCase())) { + return template + } + } + return undefined + } + + private parseGoalRequirements(goal: string): { + needsItems: boolean + items?: string[] + needsMovement: boolean + location?: { x?: number, y?: number, z?: number } + needsInteraction: boolean + target?: string + needsCrafting: boolean + needsCombat: boolean + } { + const requirements = { + needsItems: false, + items: [] as string[], + needsMovement: false, + location: undefined as { x?: number, y?: number, z?: number } | undefined, + needsInteraction: false, + target: undefined as string | undefined, + needsCrafting: false, + needsCombat: false, + } + + const goalLower = goal.toLowerCase() + + // Extract items from goal + const itemMatches = goalLower.match(/(collect|get|find|craft|make|build|use|equip) (\w+)/g) + if (itemMatches) { + requirements.needsItems = true + requirements.items = itemMatches.map(match => match.split(' ')[1]) + } + + // Extract location from goal + const locationMatches = goalLower.match(/(go to|move to|at) (\d+)[, ]+(\d+)[, ]+(\d+)/g) + if (locationMatches) { + requirements.needsMovement = true + const [x, y, z] = locationMatches[0].split(/[, ]+/).slice(-3).map(Number) + requirements.location = { x, y, z } + } + + // Extract target from goal + const targetMatches = goalLower.match(/(interact with|use|open|activate) (\w+)/g) + if (targetMatches) { + requirements.needsInteraction = true + requirements.target = targetMatches[0].split(' ').pop() + } + + // Check for item-related actions + if (goalLower.includes('collect') || goalLower.includes('get') || goalLower.includes('find')) { + requirements.needsItems = true + requirements.needsMovement = true + } + + // Check for movement-related actions + if (goalLower.includes('go to') || goalLower.includes('move to') || goalLower.includes('follow')) { + requirements.needsMovement = true + } + + // Check for interaction-related actions + if (goalLower.includes('interact') || goalLower.includes('use') || goalLower.includes('open')) { + requirements.needsInteraction = true + } + + // Check for crafting-related actions + if (goalLower.includes('craft') || goalLower.includes('make') || goalLower.includes('build')) { + requirements.needsCrafting = true + requirements.needsItems = true + } + + // Check for combat-related actions + if (goalLower.includes('attack') || goalLower.includes('fight') || goalLower.includes('kill')) { + requirements.needsCombat = true + requirements.needsMovement = true + } + + return requirements + } +} diff --git a/services/minecraft/src/agents/planning/llm-handler.ts b/services/minecraft/src/agents/planning/llm-handler.ts new file mode 100644 index 000000000..175a60e68 --- /dev/null +++ b/services/minecraft/src/agents/planning/llm-handler.ts @@ -0,0 +1,61 @@ +import type { Agent } from 'neuri' +import type { Action } from '../../libs/mineflayer/action' + +import { agent } from 'neuri' +import { system, user } from 'neuri/openai' + +import { BaseLLMHandler } from '../../libs/llm/base' +import { generatePlanningAgentSystemPrompt, generatePlanningAgentUserPrompt } from '../prompt/planning' + +export async function createPlanningNeuriAgent(): Promise { + return agent('planning').build() +} + +export class PlanningLLMHandler extends BaseLLMHandler { + public async generatePlan( + goal: string, + availableActions: Action[], + feedback?: string, + ): Promise> { + const systemPrompt = generatePlanningAgentSystemPrompt(availableActions) + const userPrompt = generatePlanningAgentUserPrompt(goal, feedback) + const messages = [system(systemPrompt), user(userPrompt)] + + const result = await this.config.agent.handleStateless(messages, async (context) => { + this.logger.log('Generating plan...') + const retryHandler = this.createRetryHandler( + async ctx => (await this.handleCompletion(ctx, 'planning', ctx.messages)).content, + ) + return await retryHandler(context) + }) + + if (!result) { + throw new Error('Failed to generate plan') + } + + return this.parsePlanContent(result) + } + + private parsePlanContent(content: string): Array<{ action: string, params: unknown[] }> { + try { + const match = content.match(/\[[\s\S]*\]/) + if (!match) { + throw new Error('No plan found in response') + } + + const plan = JSON.parse(match[0]) + if (!Array.isArray(plan)) { + throw new TypeError('Invalid plan format') + } + + return plan.map(step => ({ + action: step.action, + params: step.params, + })) + } + catch (error) { + this.logger.withError(error).error('Failed to parse plan') + throw error + } + } +} diff --git a/services/minecraft/src/agents/prompt/chat.ts b/services/minecraft/src/agents/prompt/chat.ts new file mode 100644 index 000000000..a07f41c8b --- /dev/null +++ b/services/minecraft/src/agents/prompt/chat.ts @@ -0,0 +1,21 @@ +export function genChatAgentPrompt(): string { + return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals. + +Guidelines: +1. Be friendly and helpful +2. Keep responses concise but informative +3. Use game-appropriate language +4. Acknowledge player's emotions and intentions +5. Ask for clarification when needed +6. Remember context from previous messages +7. Be proactive in suggesting helpful actions + +You can: +- Answer questions about the game +- Help with tasks and crafting +- Give directions and suggestions +- Engage in casual conversation +- Coordinate with other bots + +Remember that you're operating in a Minecraft world and should maintain that context in your responses.` +} diff --git a/services/minecraft/src/agents/prompt/llm-agent.plugin.ts b/services/minecraft/src/agents/prompt/llm-agent.plugin.ts new file mode 100644 index 000000000..273e74d6e --- /dev/null +++ b/services/minecraft/src/agents/prompt/llm-agent.plugin.ts @@ -0,0 +1,50 @@ +import type { Mineflayer } from '../../libs/mineflayer' + +import { listInventory } from '../../skills/actions/inventory' + +export function generateSystemBasicPrompt(botName: string): string { + // ${ctx.prompt.selfPrompt} + return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, +mine, build, and interact with the world by using commands.` +} + +export function generateActionAgentPrompt(mineflayer: Mineflayer): string { + return `${generateSystemBasicPrompt(mineflayer.username)} + +Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in +your responses, don't apologize constantly, don't give instructions or make lists unless +asked, and don't refuse requests. + +Do not use any emojis. Just call the function given you if needed. + +- If I command you 'stop', then call the 'stop' function. +- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. +` +} + +export async function generateStatusPrompt(mineflayer: Mineflayer): Promise { + // Get inventory items + const inventory = await listInventory(mineflayer) + + // Format inventory string + const inventoryStr = inventory.length === 0 + ? '[Empty]' + : inventory.map(item => `${item.name} x ${item.count}`).join(', ') + + // Get currently held item + const itemInHand = inventory.length === 0 + ? '[Empty]' + : `${inventory[0].name} x ${inventory[0].count}` // TODO: mock + + // Build status message + return [ + 'I will give you the following information:', + mineflayer.status.toOneLiner(), + '', + 'Inventory:', + inventoryStr, + '', + 'Item in hand:', + itemInHand, + ].join('\n') +} diff --git a/services/minecraft/src/agents/prompt/planning.ts b/services/minecraft/src/agents/prompt/planning.ts new file mode 100644 index 000000000..6c3bf21b8 --- /dev/null +++ b/services/minecraft/src/agents/prompt/planning.ts @@ -0,0 +1,47 @@ +import type { Action } from '../../libs/mineflayer/action' + +export function generatePlanningAgentSystemPrompt(availableActions: Action[]): string { + const actionsList = availableActions + .map(action => `- ${action.name}: ${action.description}`) + .join('\n') + + return `You are a Minecraft bot planner. Your task is to create a plan to achieve a given goal. +Available actions: +${actionsList} + +Respond with a Valid JSON array of steps, where each step has: +- action: The name of the action to perform +- params: Array of parameters for the action + +DO NOT contains any \`\`\` or explation, otherwise agent will be interrupted. + +Example response: +[ + { + "action": "searchForBlock", + "params": ["log", 64] + }, + { + "action": "collectBlocks", + "params": ["log", 1] + } + ]` +} + +export function generatePlanningAgentUserPrompt(goal: string, feedback?: string): string { + let prompt = `Create a detailed plan to: ${goal} + +Consider the following aspects: +1. Required materials and their quantities +2. Required tools and their availability +3. Necessary crafting steps +4. Block placement requirements +5. Current inventory status + +Please generate steps that handle these requirements in the correct order.` + + if (feedback) { + prompt += `\nPrevious attempt feedback: ${feedback}` + } + return prompt +} diff --git a/services/minecraft/src/composables/action.ts b/services/minecraft/src/composables/action.ts deleted file mode 100644 index 74d6a3431..000000000 --- a/services/minecraft/src/composables/action.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Agent } from './agent' - -import { useLogg } from '@guiiai/logg' - -type Fn = (...args: any[]) => void - -export function useActionManager(agent: Agent) { - const executing: { value: boolean } = { value: false } - const currentActionLabel: { value: string | undefined } = { value: '' } - const currentActionFn: { value: (Fn) | undefined } = { value: undefined } - const timedout: { value: boolean } = { value: false } - const resume_func: { value: (Fn) | undefined } = { value: undefined } - const resume_name: { value: string | undefined } = { value: undefined } - const log = useLogg('ActionManager').useGlobalConfig() - - async function resumeAction(actionLabel: string, actionFn: Fn, timeout: number) { - return _executeResume(actionLabel, actionFn, timeout) - } - - async function runAction(actionLabel: string, actionFn: Fn, options: { timeout: number, resume: boolean } = { timeout: 10, resume: false }) { - if (options.resume) { - return _executeResume(actionLabel, actionFn, options.timeout) - } - else { - return _executeAction(actionLabel, actionFn, options.timeout) - } - } - - async function stop() { - if (!executing.value) - return - const timeout = setTimeout(() => { - agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.') - }, 10000) - while (executing.value) { - agent.requestInterrupt() - log.log('waiting for code to finish executing...') - await new Promise(resolve => setTimeout(resolve, 300)) - } - clearTimeout(timeout) - } - - function cancelResume() { - resume_func.value = undefined - resume_name.value = undefined - } - - async function _executeResume(actionLabel?: string, actionFn?: Fn, timeout = 10) { - const new_resume = actionFn != null - if (new_resume) { // start new resume - resume_func.value = actionFn - if (actionLabel == null) { - throw new Error('actionLabel is required for new resume') - } - resume_name.value = actionLabel - } - if (resume_func.value != null && (agent.isIdle() || new_resume) && (!agent.self_prompter.on || new_resume)) { - currentActionLabel.value = resume_name.value - const res = await _executeAction(resume_name.value, resume_func.value, timeout) - currentActionLabel.value = '' - return res - } - else { - return { success: false, message: null, interrupted: false, timedout: false } - } - } - - async function _executeAction(actionLabel?: string, actionFn?: Fn, timeout = 10) { - let TIMEOUT - try { - log.log('executing code...\n') - - // await current action to finish (executing=false), with 10 seconds timeout - // also tell agent.bot to stop various actions - if (executing.value) { - log.log(`action "${actionLabel}" trying to interrupt current action "${currentActionLabel.value}"`) - } - await stop() - - // clear bot logs and reset interrupt code - agent.clearBotLogs() - - executing.value = true - currentActionLabel.value = actionLabel - currentActionFn.value = actionFn - - // timeout in minutes - if (timeout > 0) { - TIMEOUT = _startTimeout(timeout) - } - - // start the action - await actionFn?.() - - // mark action as finished + cleanup - executing.value = false - currentActionLabel.value = '' - currentActionFn.value = undefined - clearTimeout(TIMEOUT) - - // get bot activity summary - const output = _getBotOutputSummary() - const interrupted = agent.bot.interrupt_code - agent.clearBotLogs() - - // if not interrupted and not generating, emit idle event - if (!interrupted && !agent.coder.generating) { - agent.bot.emit('idle') - } - - // return action status report - return { success: true, message: output, interrupted, timedout } - } - catch (err) { - executing.value = false - currentActionLabel.value = '' - currentActionFn.value = undefined - clearTimeout(TIMEOUT) - cancelResume() - log.withError(err).error('Code execution triggered catch') - await stop() - - const message = `${_getBotOutputSummary() - }!!Code threw exception!!\n` - + `Error: ${err}\n` - + `Stack trace:\n${(err as Error).stack}` - - const interrupted = agent.bot.interrupt_code - agent.clearBotLogs() - if (!interrupted && !agent.coder.generating) { - agent.bot.emit('idle') - } - return { success: false, message, interrupted, timedout: false } - } - } - - function _getBotOutputSummary() { - const { bot } = agent - if (bot.interrupt_code && !timedout.value) - return '' - let output = bot.output - const MAX_OUT = 500 - if (output.length > MAX_OUT) { - output = `Code output is very long (${output.length} chars) and has been shortened.\n - First outputs:\n${output.substring(0, MAX_OUT / 2)}\n...skipping many lines.\nFinal outputs:\n ${output.substring(output.length - MAX_OUT / 2)}` - } - else { - output = `Code output:\n${output}` - } - - return output - } - - function _startTimeout(TIMEOUT_MINS = 10) { - return setTimeout(async () => { - log.warn(`Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`) - timedout.value = true - agent.history.add('system', `Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`) - await stop() // last attempt to stop - }, TIMEOUT_MINS * 60 * 1000) - } - - return { - runAction, - resumeAction, - stop, - cancelResume, - } -} diff --git a/services/minecraft/src/composables/agent.ts b/services/minecraft/src/composables/agent.ts deleted file mode 100644 index bee29c559..000000000 --- a/services/minecraft/src/composables/agent.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface Agent { - name: string - history: { - add: (name: string, message: string) => void - } - lastSender?: string - isIdle: () => boolean - handleMessage: (sender: string, message: string) => void - openChat: (message: string) => void - self_prompter: { - on: boolean - stop: () => Promise - stopLoop: () => Promise - start: () => Promise - promptShouldRespondToBot: (message: string) => Promise - } - actions: { - currentActionLabel: string - } - prompter: { - promptShouldRespondToBot: (message: string) => Promise - } - shut_up: boolean - in_game: boolean - cleanKill: (message: string) => void - clearBotLogs: () => void - bot: { - interrupt_code: boolean - output: string - emit: (event: string) => void - } - coder: { - generating: boolean - } - requestInterrupt: () => void -} diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 2638736bd..5a513aae3 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,18 +1,31 @@ import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer' -let mineflayer: Mineflayer +// Singleton instance of the Mineflayer bot +let botInstance: Mineflayer | null = null +/** + * Initialize a new Mineflayer bot instance. + * Follows singleton pattern to ensure only one bot exists at a time. + */ export async function initBot(options: MineflayerOptions): Promise<{ bot: Mineflayer }> { - mineflayer = await Mineflayer.asyncBuild(options) - return { bot: mineflayer } + if (botInstance) { + throw new Error('Bot already initialized') + } + + botInstance = await Mineflayer.asyncBuild(options) + return { bot: botInstance } } -export function useBot() { - if (!mineflayer) { +/** + * Get the current bot instance. + * Throws if bot is not initialized. + */ +export function useBot(): { bot: Mineflayer } { + if (!botInstance) { throw new Error('Bot not initialized') } return { - bot: mineflayer, + bot: botInstance, } } diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 6f3d7b175..6076dc5f7 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -5,35 +5,60 @@ import { useLogg } from '@guiiai/logg' const logger = useLogg('config').useGlobalConfig() +// Configuration interfaces interface OpenAIConfig { apiKey: string baseUrl: string + model: string } -export const botConfig: BotOptions = { - username: '', - host: '', - port: 0, - password: '', - version: '1.20', +interface EnvConfig { + openai: OpenAIConfig + bot: BotOptions } -export const openaiConfig: OpenAIConfig = { - apiKey: '', - baseUrl: '', +// Default configurations +const defaultConfig: EnvConfig = { + openai: { + apiKey: '', + baseUrl: '', + model: 'openai/gpt-4o-mini', + }, + bot: { + username: '', + host: '', + port: 0, + password: '', + version: '1.20', + }, } -export function initEnv() { +// Exported configurations +export const botConfig: BotOptions = { ...defaultConfig.bot } +export const openaiConfig: OpenAIConfig = { ...defaultConfig.openai } + +// Load environment variables into config +export function initEnv(): void { logger.log('Initializing environment variables') - openaiConfig.apiKey = env.OPENAI_API_KEY || '' - openaiConfig.baseUrl = env.OPENAI_API_BASEURL || '' + const config: EnvConfig = { + openai: { + apiKey: env.OPENAI_API_KEY || defaultConfig.openai.apiKey, + baseUrl: env.OPENAI_API_BASEURL || defaultConfig.openai.baseUrl, + model: env.OPENAI_MODEL || defaultConfig.openai.model, + }, + bot: { + username: env.BOT_USERNAME || defaultConfig.bot.username, + host: env.BOT_HOSTNAME || defaultConfig.bot.host, + port: Number.parseInt(env.BOT_PORT || '49415'), + password: env.BOT_PASSWORD || defaultConfig.bot.password, + version: env.BOT_VERSION || defaultConfig.bot.version, + }, + } - botConfig.username = env.BOT_USERNAME || '' - botConfig.host = env.BOT_HOSTNAME || '' - botConfig.port = Number.parseInt(env.BOT_PORT || '49415') - botConfig.password = env.BOT_PASSWORD || '' - botConfig.version = env.BOT_VERSION || '1.20' + // Update exported configs + Object.assign(openaiConfig, config.openai) + Object.assign(botConfig, config.bot) logger.withFields({ openaiConfig }).log('Environment variables initialized') } diff --git a/services/minecraft/src/composables/conversation.ts b/services/minecraft/src/composables/conversation.ts deleted file mode 100644 index 3a651f243..000000000 --- a/services/minecraft/src/composables/conversation.ts +++ /dev/null @@ -1,384 +0,0 @@ -import type { Agent } from './agent' - -import { useLogg } from '@guiiai/logg' - -let self_prompter_paused = false - -interface ConversationMessage { - message: string - start: boolean - end: boolean -} - -function compileInMessages(inQueue: ConversationMessage[]) { - let pack: ConversationMessage | undefined - let fullMessage = '' - while (inQueue.length > 0) { - pack = inQueue.shift() - if (!pack) - continue - - fullMessage += pack.message - } - if (pack) { - pack.message = fullMessage - } - - return pack -} - -type Conversation = ReturnType - -function useConversations(name: string, agent: Agent) { - const active = { value: false } - const ignoreUntilStart = { value: false } - const blocked = { value: false } - let inQueue: ConversationMessage[] = [] - const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined } - - function reset() { - active.value = false - ignoreUntilStart.value = false - inQueue = [] - } - - function end() { - active.value = false - ignoreUntilStart.value = true - const fullMessage = compileInMessages(inQueue) - if (!fullMessage) - return - - if (fullMessage.message.trim().length > 0) { - agent.history.add(name, fullMessage.message) - } - - if (agent.lastSender === name) { - agent.lastSender = undefined - } - } - - function queue(message: ConversationMessage) { - inQueue.push(message) - } - - return { - reset, - end, - queue, - name, - inMessageTimer, - blocked, - active, - ignoreUntilStart, - inQueue, - } -} - -const WAIT_TIME_START = 30000 - -export type ConversationStore = ReturnType - -export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) { - const conversations: Record = {} - const activeConversation: { value: Conversation | undefined } = { value: undefined } - const awaitingResponse = { value: false } - const waitTimeLimit = { value: WAIT_TIME_START } - const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined } - const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined } - const agent = options.agent - let agentsInGame = options.agentNames || [] - const log = useLogg('ConversationStore').useGlobalConfig() - - const conversationStore = { - getConvo: (name: string) => { - if (!conversations[name]) - conversations[name] = useConversations(name, agent) - return conversations[name] - }, - startMonitor: () => { - clearInterval(connectionMonitor.value) - let waitTime = 0 - let lastTime = Date.now() - connectionMonitor.value = setInterval(() => { - if (!activeConversation.value) { - conversationStore.stopMonitor() - return // will clean itself up - } - - const delta = Date.now() - lastTime - lastTime = Date.now() - const convo_partner = activeConversation.value.name - - if (awaitingResponse.value && agent.isIdle()) { - waitTime += delta - if (waitTime > waitTimeLimit.value) { - agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`) - waitTime = 0 - waitTimeLimit.value *= 2 - } - } - else if (!awaitingResponse.value) { - waitTimeLimit.value = WAIT_TIME_START - waitTime = 0 - } - - if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) { - connectionTimeout.value = setTimeout(() => { - if (conversationStore.otherAgentInGame(convo_partner)) { - conversationStore.clearMonitorTimeouts() - return - } - if (!self_prompter_paused) { - conversationStore.endConversation(convo_partner) - agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`) - } - else { - conversationStore.endConversation(convo_partner) - } - }, 10000) - } - }, 1000) - }, - stopMonitor: () => { - clearInterval(connectionMonitor.value) - connectionMonitor.value = undefined - conversationStore.clearMonitorTimeouts() - }, - clearMonitorTimeouts: () => { - awaitingResponse.value = false - clearTimeout(connectionTimeout.value) - connectionTimeout.value = undefined - }, - startConversation: (send_to: string, message: string) => { - const convo = conversationStore.getConvo(send_to) - convo.reset() - - if (agent.self_prompter.on) { - agent.self_prompter.stop() - self_prompter_paused = true - } - if (convo.active.value) - return - - convo.active.value = true - activeConversation.value = convo - conversationStore.startMonitor() - conversationStore.sendToBot(send_to, message, true, false) - }, - startConversationFromOtherBot: (name: string) => { - const convo = conversationStore.getConvo(name) - convo.active.value = true - activeConversation.value = convo - conversationStore.startMonitor() - }, - sendToBot: (send_to: string, message: string, start = false, open_chat = true) => { - if (!conversationStore.isOtherAgent(send_to)) { - console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`) - return - } - const convo = conversationStore.getConvo(send_to) - - if (options.chatBotMessages && open_chat) - agent.openChat(`(To ${send_to}) ${message}`) - - if (convo.ignoreUntilStart.value) - return - convo.active.value = true - - const end = message.includes('!endConversation') - const json = { - message, - start, - end, - } - - awaitingResponse.value = true - // TODO: - // sendBotChatToServer(send_to, json) - log.withField('json', json).log(`Sending message to ${send_to}`) - }, - receiveFromBot: async (sender: string, received: ConversationMessage) => { - const convo = conversationStore.getConvo(sender) - - if (convo.ignoreUntilStart.value && !received.start) - return - - // check if any convo is active besides the sender - if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) { - conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false) - conversationStore.endConversation(sender) - return - } - - if (received.start) { - convo.reset() - conversationStore.startConversationFromOtherBot(sender) - } - - conversationStore.clearMonitorTimeouts() - convo.queue(received) - - // responding to conversation takes priority over self prompting - if (agent.self_prompter.on) { - await agent.self_prompter.stopLoop() - self_prompter_paused = true - } - - _scheduleProcessInMessage(agent, conversationStore, sender, received, convo) - }, - responseScheduledFor: (sender: string) => { - if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender)) - return false - const convo = conversationStore.getConvo(sender) - return !!convo.inMessageTimer - }, - isOtherAgent: (name: string) => { - return !!options.agentNames?.includes(name) - }, - otherAgentInGame: (name: string) => { - return agentsInGame.includes(name) - }, - updateAgents: (agents: Agent[]) => { - options.agentNames = agents.map(a => a.name) - agentsInGame = agents.filter(a => a.in_game).map(a => a.name) - }, - getInGameAgents: () => { - return agentsInGame - }, - inConversation: (other_agent?: string) => { - if (other_agent) - return conversations[other_agent]?.active - return Object.values(conversations).some(c => c.active) - }, - endConversation: (sender: string) => { - if (conversations[sender]) { - conversations[sender].end() - if (activeConversation.value?.name === sender) { - conversationStore.stopMonitor() - activeConversation.value = undefined - if (self_prompter_paused && !conversationStore.inConversation()) { - _resumeSelfPrompter(agent, conversationStore) - } - } - } - }, - endAllConversations: () => { - for (const sender in conversations) { - conversationStore.endConversation(sender) - } - if (self_prompter_paused) { - _resumeSelfPrompter(agent, conversationStore) - } - }, - forceEndCurrentConversation: () => { - if (activeConversation.value) { - const sender = activeConversation.value.name - conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false) - conversationStore.endConversation(sender) - } - }, - scheduleSelfPrompter: () => { - self_prompter_paused = true - }, - cancelSelfPrompter: () => { - self_prompter_paused = false - }, - } - - return conversationStore -} - -function containsCommand(message: string) { - // TODO: mock - return message -} - -/* -This function controls conversation flow by deciding when the bot responds. -The logic is as follows: -- If neither bot is busy, respond quickly with a small delay. -- If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory) -- If I'm busy but other bot isn't, let LLM decide whether to respond -- If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses -- New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk -*/ -const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions -const fastDelay = 200 -const longDelay = 5000 - -async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) { - if (convo.inMessageTimer) - clearTimeout(convo.inMessageTimer.value) - const otherAgentBusy = containsCommand(received.message) - - const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay) - - if (!agent.isIdle() && otherAgentBusy) { - // both are busy - const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) - if (canTalkOver) - scheduleResponse(fastDelay) - // otherwise don't respond - } - else if (otherAgentBusy) { - // other bot is busy but I'm not - scheduleResponse(longDelay) - } - else if (!agent.isIdle()) { - // I'm busy but other bot isn't - const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) - if (canTalkOver) { - scheduleResponse(fastDelay) - } - else { - const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message) - useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`) - if (shouldRespond) - scheduleResponse(fastDelay) - } - } - else { - // neither are busy - scheduleResponse(fastDelay) - } -} - -function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) { - const convo = conversationStore.getConvo(name) - _handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue)) -} - -function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) { - if (!received) - return - - useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`) - - const convo = conversationStore.getConvo(sender) - convo.active.value = true - - let message = _tagMessage(received.message) - if (received.end) { - conversationStore.endConversation(sender) - message = `Conversation with ${sender} ended with message: "${message}"` - sender = 'system' // bot will respond to system instead of the other bot - } - else if (received.start) { - agent.shut_up = false - } - convo.inMessageTimer.value = undefined - agent.handleMessage(sender, message) -} - -function _tagMessage(message: string) { - return `(FROM OTHER BOT)${message}` -} - -async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) { - await new Promise(resolve => setTimeout(resolve, 5000)) - if (self_prompter_paused && !conversationStore.inConversation()) { - self_prompter_paused = false - agent.self_prompter.start() - } -} diff --git a/services/minecraft/src/composables/neuri.ts b/services/minecraft/src/composables/neuri.ts new file mode 100644 index 000000000..1c6216013 --- /dev/null +++ b/services/minecraft/src/composables/neuri.ts @@ -0,0 +1,42 @@ +import type { Agent, Neuri } from 'neuri' +import type { Mineflayer } from '../libs/mineflayer' + +import { useLogg } from '@guiiai/logg' +import { neuri } from 'neuri' + +import { createActionNeuriAgent } from '../agents/action/llm-handler' +import { createChatNeuriAgent } from '../agents/chat/llm' +import { createPlanningNeuriAgent } from '../agents/planning/llm-handler' +import { openaiConfig } from './config' + +let neuriAgent: Neuri | undefined +const agents = new Set>() + +const logger = useLogg('neuri').useGlobalConfig() + +export async function createNeuriAgent(mineflayer: Mineflayer): Promise { + logger.log('Initializing neuri agent') + let n = neuri() + + agents.add(createPlanningNeuriAgent()) + agents.add(createActionNeuriAgent(mineflayer)) + agents.add(createChatNeuriAgent()) + + agents.forEach(agent => n = n.agent(agent)) + + neuriAgent = await n.build({ + provider: { + apiKey: openaiConfig.apiKey, + baseURL: openaiConfig.baseUrl, + }, + }) + + return neuriAgent +} + +export function useNeuriAgent(): Neuri { + if (!neuriAgent) { + throw new Error('Agent not initialized') + } + return neuriAgent +} diff --git a/services/minecraft/src/container.ts b/services/minecraft/src/container.ts new file mode 100644 index 000000000..bd8c6e8a6 --- /dev/null +++ b/services/minecraft/src/container.ts @@ -0,0 +1,71 @@ +import type { Neuri } from 'neuri' + +import { useLogg } from '@guiiai/logg' +import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' + +import { ActionAgentImpl } from './agents/action' +import { ChatAgentImpl } from './agents/chat' +import { PlanningAgentImpl } from './agents/planning' + +export interface ContainerServices { + logger: ReturnType + actionAgent: ActionAgentImpl + planningAgent: PlanningAgentImpl + chatAgent: ChatAgentImpl + neuri: Neuri +} + +export function createAppContainer(options: { + neuri: Neuri + model?: string + maxHistoryLength?: number + idleTimeout?: number +}) { + const container = createContainer({ + injectionMode: InjectionMode.PROXY, + strict: true, + }) + + // Register services + container.register({ + // Create independent logger for each agent + logger: asFunction(() => useLogg('app').useGlobalConfig()).singleton(), + + // Register neuri client + neuri: asFunction(() => options.neuri).singleton(), + + // Register agents + actionAgent: asClass(ActionAgentImpl) + .singleton() + .inject(() => ({ + id: 'action', + type: 'action' as const, + })), + + planningAgent: asClass(PlanningAgentImpl) + .singleton() + .inject(() => ({ + id: 'planning', + type: 'planning' as const, + llm: { + agent: options.neuri, + model: options.model, + }, + })), + + chatAgent: asClass(ChatAgentImpl) + .singleton() + .inject(() => ({ + id: 'chat', + type: 'chat' as const, + llm: { + agent: options.neuri, + model: options.model, + }, + maxHistoryLength: options.maxHistoryLength, + idleTimeout: options.idleTimeout, + })), + }) + + return container +} diff --git a/services/minecraft/src/libs/llm/base.ts b/services/minecraft/src/libs/llm/base.ts new file mode 100644 index 000000000..7e8df465b --- /dev/null +++ b/services/minecraft/src/libs/llm/base.ts @@ -0,0 +1,45 @@ +import type { NeuriContext } from 'neuri' +import type { ChatCompletion, Message } from 'neuri/openai' +import type { LLMConfig, LLMResponse } from './types' + +import { useLogg } from '@guiiai/logg' + +import { openaiConfig } from '../../composables/config' +import { toRetriable } from '../../utils/helper' + +export abstract class BaseLLMHandler { + protected logger = useLogg('llm-handler').useGlobalConfig() + + constructor(protected config: LLMConfig) {} + + protected async handleCompletion( + context: NeuriContext, + route: string, + messages: Message[], + ): Promise { + const completion = await context.reroute(route, messages, { + model: this.config.model ?? openaiConfig.model, + }) as ChatCompletion | ChatCompletion & { error: { message: string } } + + if (!completion || 'error' in completion) { + this.logger.withFields(context).error('Completion failed') + throw new Error(completion?.error?.message ?? 'Unknown error') + } + + const content = await completion.firstContent() + this.logger.withFields({ usage: completion.usage, content }).log('Generated content') + + return { + content, + usage: completion.usage, + } + } + + protected createRetryHandler(handler: (context: NeuriContext) => Promise) { + return toRetriable( + this.config.retryLimit ?? 3, + this.config.delayInterval ?? 1000, + handler, + ) + } +} diff --git a/services/minecraft/src/libs/llm/types.ts b/services/minecraft/src/libs/llm/types.ts new file mode 100644 index 000000000..a21fd1731 --- /dev/null +++ b/services/minecraft/src/libs/llm/types.ts @@ -0,0 +1,14 @@ +import type { Neuri } from 'neuri' + +export interface LLMConfig { + agent: Neuri + model?: string + retryLimit?: number + delayInterval?: number + maxContextLength?: number +} + +export interface LLMResponse { + content: string + usage?: any +} diff --git a/services/minecraft/src/libs/mineflayer/base-agent.ts b/services/minecraft/src/libs/mineflayer/base-agent.ts new file mode 100644 index 000000000..fc573a537 --- /dev/null +++ b/services/minecraft/src/libs/mineflayer/base-agent.ts @@ -0,0 +1,129 @@ +import type { Action } from './action' + +import { useLogg } from '@guiiai/logg' +import EventEmitter3 from 'eventemitter3' + +export type AgentType = 'action' | 'memory' | 'planning' | 'chat' + +export interface AgentConfig { + id: string + type: AgentType +} + +export interface BaseAgent { + readonly id: string + readonly type: AgentType + init: () => Promise + destroy: () => Promise +} + +export interface ActionAgent extends BaseAgent { + type: 'action' + performAction: (name: string, params: unknown[]) => Promise + getAvailableActions: () => Action[] +} + +export interface MemoryAgent extends BaseAgent { + type: 'memory' + remember: (key: string, value: unknown) => void + recall: (key: string) => T | undefined + forget: (key: string) => void + getMemorySnapshot: () => Record +} + +export interface Plan { + goal: string + steps: Array<{ + action: string + params: unknown[] + }> + status: 'pending' | 'in_progress' | 'completed' | 'failed' + requiresAction: boolean +} + +export interface PlanningAgent extends BaseAgent { + type: 'planning' + createPlan: (goal: string) => Promise + executePlan: (plan: Plan) => Promise + adjustPlan: (plan: Plan, feedback: string) => Promise +} + +export interface ChatAgent extends BaseAgent { + type: 'chat' + processMessage: (message: string, sender: string) => Promise + startConversation: (player: string) => void + endConversation: (player: string) => void +} + +export abstract class AbstractAgent extends EventEmitter3 implements BaseAgent { + public readonly id: string + public readonly type: AgentConfig['type'] + public readonly name: string + + protected initialized: boolean + protected logger: ReturnType + // protected actionManager: ReturnType + // protected conversationStore: ReturnType + + constructor(config: AgentConfig) { + super() + this.id = config.id // TODO: use uuid, is it needed? + this.type = config.type + this.name = `${this.type}-agent` + this.initialized = false + this.logger = useLogg(this.name).useGlobalConfig() + + // Initialize managers + // this.actionManager = useActionManager(this) + // this.conversationStore = useConversationStore({ + // agent: this, + // chatBotMessages: true, + // }) + } + + public async init(): Promise { + if (this.initialized) { + return + } + + await this.initializeAgent() + this.initialized = true + } + + public async destroy(): Promise { + if (!this.initialized) { + return + } + + this.logger.log('Destroying agent') + await this.destroyAgent() + this.initialized = false + } + + // Agent interface implementation + // public isIdle(): boolean { + // return !this.actionManager.executing + // } + + public handleMessage(sender: string, message: string): void { + this.logger.withFields({ sender, message }).log('Received message') + this.emit('message', { sender, message }) + } + + public openChat(message: string): void { + this.logger.withField('message', message).log('Opening chat') + this.emit('chat', message) + } + + // public clearBotLogs(): void { + // // Implement if needed + // } + + public requestInterrupt(): void { + this.emit('interrupt') + } + + // Methods to be implemented by specific agents + protected abstract initializeAgent(): Promise + protected abstract destroyAgent(): Promise +} diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index af50a822e..6e019a20f 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -10,7 +10,7 @@ import { parseCommand } from './command' import { Components } from './components' import { Health } from './health' import { Memory } from './memory' -import { formBotChat } from './message' +import { ChatMessageHandler } from './message' import { Status } from './status' import { Ticker, type TickEvents, type TickEventsHandler } from './ticker' @@ -191,7 +191,7 @@ export class Mineflayer extends EventEmitter { } private handleCommand() { - return formBotChat(this.username, (sender, message) => { + return new ChatMessageHandler(this.username).handleChat((sender, message) => { const { isCommand, command, args } = parseCommand(sender, message) if (!isCommand) diff --git a/services/minecraft/src/libs/mineflayer/index.ts b/services/minecraft/src/libs/mineflayer/index.ts index d8989b083..1bea7708d 100644 --- a/services/minecraft/src/libs/mineflayer/index.ts +++ b/services/minecraft/src/libs/mineflayer/index.ts @@ -3,7 +3,6 @@ export * from './command' export * from './components' export * from './core' export * from './health' -export * from './interfaces' export * from './memory' export * from './message' export * from './plugin' diff --git a/services/minecraft/src/libs/mineflayer/interfaces.ts b/services/minecraft/src/libs/mineflayer/interfaces.ts deleted file mode 100644 index b55ec74d1..000000000 --- a/services/minecraft/src/libs/mineflayer/interfaces.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface OneLinerable { - toOneLiner: () => string -} diff --git a/services/minecraft/src/libs/mineflayer/message.ts b/services/minecraft/src/libs/mineflayer/message.ts index aa35fc7e4..5ab42fb7e 100644 --- a/services/minecraft/src/libs/mineflayer/message.ts +++ b/services/minecraft/src/libs/mineflayer/message.ts @@ -1,30 +1,45 @@ import type { Entity } from 'prismarine-entity' -// TODO: need to be refactored -interface ChatBotContext { - fromUsername?: string - fromEntity?: Entity - fromMessage?: string - - isBot: () => boolean - isCommand: () => boolean +// Represents the context of a chat message in the Minecraft world +interface ChatMessage { + readonly sender: { + username: string + entity: Entity | null + } + readonly content: string } -export function newChatBotContext(entity: Entity, botUsername: string, username: string, message: string): ChatBotContext { - return { - fromUsername: username, - fromEntity: entity, - fromMessage: message, - isBot: () => username === botUsername, - isCommand: () => message.startsWith('#'), +// Handles chat message validation and processing +export class ChatMessageHandler { + constructor(private readonly botUsername: string) {} + + // Creates a new chat message context with validation + createMessageContext(entity: Entity | null, username: string, content: string): ChatMessage { + return { + sender: { + username, + entity, + }, + content, + } + } + + // Checks if a message is from the bot itself + isBotMessage(username: string): boolean { + return username === this.botUsername } -} -export function formBotChat(botUsername: string, cb: (username: string, message: string) => void) { - return (username: string, message: string) => { - if (botUsername === username) - return + // Checks if a message is a command + isCommand(content: string): boolean { + return content.startsWith('#') + } - cb(username, message) + // Processes chat messages, filtering out bot's own messages + handleChat(callback: (username: string, message: string) => void): (username: string, message: string) => void { + return (username: string, message: string) => { + if (!this.isBotMessage(username)) { + callback(username, message) + } + } } } diff --git a/services/minecraft/src/libs/mineflayer/status.ts b/services/minecraft/src/libs/mineflayer/status.ts index 6b5c01ae6..d39aadcf2 100644 --- a/services/minecraft/src/libs/mineflayer/status.ts +++ b/services/minecraft/src/libs/mineflayer/status.ts @@ -1,5 +1,5 @@ import type { Mineflayer } from './core' -import type { OneLinerable } from './interfaces' +import type { OneLinerable } from './types' export class Status implements OneLinerable { public position: string diff --git a/services/minecraft/src/libs/mineflayer/types.ts b/services/minecraft/src/libs/mineflayer/types.ts index 861b5a62b..7f8a3f617 100644 --- a/services/minecraft/src/libs/mineflayer/types.ts +++ b/services/minecraft/src/libs/mineflayer/types.ts @@ -17,3 +17,7 @@ export interface EventHandlers { export type Events = keyof EventHandlers export type EventsHandler = EventHandlers[K] export type Handler = (ctx: Context) => void | Promise + +export interface OneLinerable { + toOneLiner: () => string +} diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index cca879f8c..076471b5e 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -8,11 +8,11 @@ import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder' import { plugin as MineflayerPVP } from 'mineflayer-pvp' import { plugin as MineflayerTool } from 'mineflayer-tool' -import { initAgent } from './agents/openai' import { initBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' -import { wrapPlugin } from './libs/mineflayer/plugin' -import { LLMAgent } from './mineflayer/llm-agent' +import { createNeuriAgent } from './composables/neuri' +import { wrapPlugin } from './libs/mineflayer' +import { LLMAgent } from './plugins/llm-agent' import { initLogger } from './utils/logger' const logger = useLogg('main').useGlobalConfig() @@ -35,8 +35,8 @@ async function main() { const airiClient = new Client({ name: 'minecraft-bot', url: 'ws://localhost:6121/ws' }) - // Dynamically load LLMAgent after bot is initialized - const agent = await initAgent(bot) + // Dynamically load LLMAgent after the bot is initialized + const agent = await createNeuriAgent(bot) await bot.loadPlugin(LLMAgent({ agent, airiClient })) process.on('SIGINT', () => { diff --git a/services/minecraft/src/manager/action.ts b/services/minecraft/src/manager/action.ts new file mode 100644 index 000000000..7f78c0bf5 --- /dev/null +++ b/services/minecraft/src/manager/action.ts @@ -0,0 +1,203 @@ +import type { Mineflayer } from '../libs/mineflayer/core' + +import { useLogg } from '@guiiai/logg' +import EventEmitter from 'eventemitter3' + +// Types and interfaces +type ActionFn = (...args: any[]) => void + +interface ActionResult { + success: boolean + message: string | null + timedout: boolean +} + +interface QueuedAction { + label: string + fn: ActionFn + timeout: number + resume: boolean + resolve: (result: ActionResult) => void + reject: (error: Error) => void +} + +export class ActionManager extends EventEmitter { + private state = { + executing: false, + currentActionLabel: '', + currentActionFn: undefined as ActionFn | undefined, + timedout: false, + resume: { + func: undefined as ActionFn | undefined, + name: undefined as string | undefined, + }, + } + + // Action queue to store pending actions + private actionQueue: QueuedAction[] = [] + + private logger = useLogg('ActionManager').useGlobalConfig() + private mineflayer: Mineflayer + + constructor(mineflayer: Mineflayer) { + super() + this.mineflayer = mineflayer + } + + public async resumeAction(actionLabel: string, actionFn: ActionFn, timeout: number): Promise { + return this.queueAction({ + label: actionLabel, + fn: actionFn, + timeout, + resume: true, + }) + } + + public async runAction( + actionLabel: string, + actionFn: ActionFn, + options: { timeout: number, resume: boolean } = { timeout: 10, resume: false }, + ): Promise { + return this.queueAction({ + label: actionLabel, + fn: actionFn, + timeout: options.timeout, + resume: options.resume, + }) + } + + public async stop(): Promise { + this.mineflayer.emit('interrupt') + // Clear the action queue when stopping + this.actionQueue = [] + } + + public cancelResume(): void { + this.state.resume.func = undefined + this.state.resume.name = undefined + } + + private async queueAction(action: Omit): Promise { + return new Promise((resolve, reject) => { + this.actionQueue.push({ + ...action, + resolve, + reject, + }) + + if (!this.state.executing) { + this.processQueue().catch(reject) + } + }) + } + + private async processQueue(): Promise { + while (this.actionQueue.length > 0) { + const action = this.actionQueue[0] + + try { + const result = action.resume + ? await this.executeResume(action.label, action.fn, action.timeout) + : await this.executeAction(action.label, action.fn, action.timeout) + + this.actionQueue.shift()?.resolve(result) + + if (!result.success) { + this.actionQueue.forEach(pendingAction => + pendingAction.reject(new Error('Queue cleared due to action failure')), + ) + this.actionQueue = [] + return result + } + } + catch (error) { + this.actionQueue.shift()?.reject(error as Error) + this.actionQueue.forEach(pendingAction => + pendingAction.reject(new Error('Queue cleared due to error')), + ) + this.actionQueue = [] + throw error + } + } + + return { success: true, message: 'success', timedout: false } + } + + private async executeResume(actionLabel?: string, actionFn?: ActionFn, timeout = 10): Promise { + const isNewResume = actionFn != null + + if (isNewResume) { + if (!actionLabel) { + throw new Error('actionLabel is required for new resume') + } + this.state.resume.func = actionFn + this.state.resume.name = actionLabel + } + + const canExecute = this.state.resume.func != null && isNewResume + + if (!canExecute) { + return { success: false, message: null, timedout: false } + } + + this.state.currentActionLabel = this.state.resume.name || '' + const result = await this.executeAction(this.state.resume.name || '', this.state.resume.func, timeout) + this.state.currentActionLabel = '' + return result + } + + private async executeAction(actionLabel: string, actionFn?: ActionFn, timeout = 10): Promise { + let timeoutHandle: NodeJS.Timeout | undefined + + try { + this.logger.log('executing action...\n') + + if (this.state.executing) { + this.logger.log(`action "${actionLabel}" trying to interrupt current action "${this.state.currentActionLabel}"`) + } + + await this.stop() + + // Set execution state + this.state.executing = true + this.state.currentActionLabel = actionLabel + this.state.currentActionFn = actionFn + + if (timeout > 0) { + timeoutHandle = this.startTimeout(timeout) + } + + await actionFn?.() + + // Reset state after successful execution + this.resetExecutionState(timeoutHandle) + + return { success: true, message: 'success', timedout: false } + } + catch (err) { + this.resetExecutionState(timeoutHandle) + this.cancelResume() + this.logger.withError(err).error('Code execution triggered catch') + await this.stop() + + return { success: false, message: 'failed', timedout: false } + } + } + + private resetExecutionState(timeoutHandle?: NodeJS.Timeout): void { + this.state.executing = false + this.state.currentActionLabel = '' + this.state.currentActionFn = undefined + if (timeoutHandle) + clearTimeout(timeoutHandle) + } + + private startTimeout(timeoutMins = 10): NodeJS.Timeout { + return setTimeout(async () => { + this.logger.warn(`Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`) + this.state.timedout = true + this.emit('timeout', `Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`) + await this.stop() + }, timeoutMins * 60 * 1000) + } +} diff --git a/services/minecraft/src/manager/conversation.ts b/services/minecraft/src/manager/conversation.ts new file mode 100644 index 000000000..891fb7afe --- /dev/null +++ b/services/minecraft/src/manager/conversation.ts @@ -0,0 +1,382 @@ +// import { useLogg } from '@guiiai/logg' + +// let self_prompter_paused = false + +// interface ConversationMessage { +// message: string +// start: boolean +// end: boolean +// } + +// function compileInMessages(inQueue: ConversationMessage[]) { +// let pack: ConversationMessage | undefined +// let fullMessage = '' +// while (inQueue.length > 0) { +// pack = inQueue.shift() +// if (!pack) +// continue + +// fullMessage += pack.message +// } +// if (pack) { +// pack.message = fullMessage +// } + +// return pack +// } + +// type Conversation = ReturnType + +// function useConversations(name: string, agent: Agent) { +// const active = { value: false } +// const ignoreUntilStart = { value: false } +// const blocked = { value: false } +// let inQueue: ConversationMessage[] = [] +// const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined } + +// function reset() { +// active.value = false +// ignoreUntilStart.value = false +// inQueue = [] +// } + +// function end() { +// active.value = false +// ignoreUntilStart.value = true +// const fullMessage = compileInMessages(inQueue) +// if (!fullMessage) +// return + +// if (fullMessage.message.trim().length > 0) { +// agent.history.add(name, fullMessage.message) +// } + +// if (agent.lastSender === name) { +// agent.lastSender = undefined +// } +// } + +// function queue(message: ConversationMessage) { +// inQueue.push(message) +// } + +// return { +// reset, +// end, +// queue, +// name, +// inMessageTimer, +// blocked, +// active, +// ignoreUntilStart, +// inQueue, +// } +// } + +// const WAIT_TIME_START = 30000 + +// export type ConversationStore = ReturnType + +// export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) { +// const conversations: Record = {} +// const activeConversation: { value: Conversation | undefined } = { value: undefined } +// const awaitingResponse = { value: false } +// const waitTimeLimit = { value: WAIT_TIME_START } +// const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined } +// const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined } +// const agent = options.agent +// let agentsInGame = options.agentNames || [] +// const log = useLogg('ConversationStore').useGlobalConfig() + +// const conversationStore = { +// getConvo: (name: string) => { +// if (!conversations[name]) +// conversations[name] = useConversations(name, agent) +// return conversations[name] +// }, +// startMonitor: () => { +// clearInterval(connectionMonitor.value) +// let waitTime = 0 +// let lastTime = Date.now() +// connectionMonitor.value = setInterval(() => { +// if (!activeConversation.value) { +// conversationStore.stopMonitor() +// return // will clean itself up +// } + +// const delta = Date.now() - lastTime +// lastTime = Date.now() +// const convo_partner = activeConversation.value.name + +// if (awaitingResponse.value && agent.isIdle()) { +// waitTime += delta +// if (waitTime > waitTimeLimit.value) { +// agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`) +// waitTime = 0 +// waitTimeLimit.value *= 2 +// } +// } +// else if (!awaitingResponse.value) { +// waitTimeLimit.value = WAIT_TIME_START +// waitTime = 0 +// } + +// if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) { +// connectionTimeout.value = setTimeout(() => { +// if (conversationStore.otherAgentInGame(convo_partner)) { +// conversationStore.clearMonitorTimeouts() +// return +// } +// if (!self_prompter_paused) { +// conversationStore.endConversation(convo_partner) +// agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`) +// } +// else { +// conversationStore.endConversation(convo_partner) +// } +// }, 10000) +// } +// }, 1000) +// }, +// stopMonitor: () => { +// clearInterval(connectionMonitor.value) +// connectionMonitor.value = undefined +// conversationStore.clearMonitorTimeouts() +// }, +// clearMonitorTimeouts: () => { +// awaitingResponse.value = false +// clearTimeout(connectionTimeout.value) +// connectionTimeout.value = undefined +// }, +// startConversation: (send_to: string, message: string) => { +// const convo = conversationStore.getConvo(send_to) +// convo.reset() + +// if (agent.self_prompter.on) { +// agent.self_prompter.stop() +// self_prompter_paused = true +// } +// if (convo.active.value) +// return + +// convo.active.value = true +// activeConversation.value = convo +// conversationStore.startMonitor() +// conversationStore.sendToBot(send_to, message, true, false) +// }, +// startConversationFromOtherBot: (name: string) => { +// const convo = conversationStore.getConvo(name) +// convo.active.value = true +// activeConversation.value = convo +// conversationStore.startMonitor() +// }, +// sendToBot: (send_to: string, message: string, start = false, open_chat = true) => { +// if (!conversationStore.isOtherAgent(send_to)) { +// console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`) +// return +// } +// const convo = conversationStore.getConvo(send_to) + +// if (options.chatBotMessages && open_chat) +// agent.openChat(`(To ${send_to}) ${message}`) + +// if (convo.ignoreUntilStart.value) +// return +// convo.active.value = true + +// const end = message.includes('!endConversation') +// const json = { +// message, +// start, +// end, +// } + +// awaitingResponse.value = true +// // TODO: +// // sendBotChatToServer(send_to, json) +// log.withField('json', json).log(`Sending message to ${send_to}`) +// }, +// receiveFromBot: async (sender: string, received: ConversationMessage) => { +// const convo = conversationStore.getConvo(sender) + +// if (convo.ignoreUntilStart.value && !received.start) +// return + +// // check if any convo is active besides the sender +// if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) { +// conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false) +// conversationStore.endConversation(sender) +// return +// } + +// if (received.start) { +// convo.reset() +// conversationStore.startConversationFromOtherBot(sender) +// } + +// conversationStore.clearMonitorTimeouts() +// convo.queue(received) + +// // responding to conversation takes priority over self prompting +// if (agent.self_prompter.on) { +// await agent.self_prompter.stopLoop() +// self_prompter_paused = true +// } + +// _scheduleProcessInMessage(agent, conversationStore, sender, received, convo) +// }, +// responseScheduledFor: (sender: string) => { +// if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender)) +// return false +// const convo = conversationStore.getConvo(sender) +// return !!convo.inMessageTimer +// }, +// isOtherAgent: (name: string) => { +// return !!options.agentNames?.includes(name) +// }, +// otherAgentInGame: (name: string) => { +// return agentsInGame.includes(name) +// }, +// updateAgents: (agents: Agent[]) => { +// options.agentNames = agents.map(a => a.name) +// agentsInGame = agents.filter(a => a.in_game).map(a => a.name) +// }, +// getInGameAgents: () => { +// return agentsInGame +// }, +// inConversation: (other_agent?: string) => { +// if (other_agent) +// return conversations[other_agent]?.active +// return Object.values(conversations).some(c => c.active) +// }, +// endConversation: (sender: string) => { +// if (conversations[sender]) { +// conversations[sender].end() +// if (activeConversation.value?.name === sender) { +// conversationStore.stopMonitor() +// activeConversation.value = undefined +// if (self_prompter_paused && !conversationStore.inConversation()) { +// _resumeSelfPrompter(agent, conversationStore) +// } +// } +// } +// }, +// endAllConversations: () => { +// for (const sender in conversations) { +// conversationStore.endConversation(sender) +// } +// if (self_prompter_paused) { +// _resumeSelfPrompter(agent, conversationStore) +// } +// }, +// forceEndCurrentConversation: () => { +// if (activeConversation.value) { +// const sender = activeConversation.value.name +// conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false) +// conversationStore.endConversation(sender) +// } +// }, +// scheduleSelfPrompter: () => { +// self_prompter_paused = true +// }, +// cancelSelfPrompter: () => { +// self_prompter_paused = false +// }, +// } + +// return conversationStore +// } + +// function containsCommand(message: string) { +// // TODO: mock +// return message +// } + +// /* +// This function controls conversation flow by deciding when the bot responds. +// The logic is as follows: +// - If neither bot is busy, respond quickly with a small delay. +// - If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory) +// - If I'm busy but other bot isn't, let LLM decide whether to respond +// - If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses +// - New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk +// */ +// const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions +// const fastDelay = 200 +// const longDelay = 5000 + +// async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) { +// if (convo.inMessageTimer) +// clearTimeout(convo.inMessageTimer.value) +// const otherAgentBusy = containsCommand(received.message) + +// const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay) + +// if (!agent.isIdle() && otherAgentBusy) { +// // both are busy +// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) +// if (canTalkOver) +// scheduleResponse(fastDelay) +// // otherwise don't respond +// } +// else if (otherAgentBusy) { +// // other bot is busy but I'm not +// scheduleResponse(longDelay) +// } +// else if (!agent.isIdle()) { +// // I'm busy but other bot isn't +// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) +// if (canTalkOver) { +// scheduleResponse(fastDelay) +// } +// else { +// const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message) +// useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`) +// if (shouldRespond) +// scheduleResponse(fastDelay) +// } +// } +// else { +// // neither are busy +// scheduleResponse(fastDelay) +// } +// } + +// function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) { +// const convo = conversationStore.getConvo(name) +// _handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue)) +// } + +// function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) { +// if (!received) +// return + +// useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`) + +// const convo = conversationStore.getConvo(sender) +// convo.active.value = true + +// let message = _tagMessage(received.message) +// if (received.end) { +// conversationStore.endConversation(sender) +// message = `Conversation with ${sender} ended with message: "${message}"` +// sender = 'system' // bot will respond to system instead of the other bot +// } +// else if (received.start) { +// agent.shut_up = false +// } +// convo.inMessageTimer.value = undefined +// agent.handleMessage(sender, message) +// } + +// function _tagMessage(message: string) { +// return `(FROM OTHER BOT)${message}` +// } + +// async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) { +// await new Promise(resolve => setTimeout(resolve, 5000)) +// if (self_prompter_paused && !conversationStore.inConversation()) { +// self_prompter_paused = false +// agent.self_prompter.start() +// } +// } diff --git a/services/minecraft/src/mineflayer/index.ts b/services/minecraft/src/mineflayer/index.ts deleted file mode 100644 index 9f09fed0e..000000000 --- a/services/minecraft/src/mineflayer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './echo' -export * from './llm-agent' diff --git a/services/minecraft/src/mineflayer/llm-agent.ts b/services/minecraft/src/mineflayer/llm-agent.ts deleted file mode 100644 index 5777e0b80..000000000 --- a/services/minecraft/src/mineflayer/llm-agent.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Client } from '@proj-airi/server-sdk' -import type { Neuri, NeuriContext } from 'neuri' -import type { MineflayerPlugin } from '../libs/mineflayer/plugin' - -import { useLogg } from '@guiiai/logg' -import { assistant, system, user } from 'neuri/openai' - -import { formBotChat } from '../libs/mineflayer/message' -import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent' -import { toRetriable } from '../utils/reliability' - -export function LLMAgent(options: { agent: Neuri, airiClient: Client }): MineflayerPlugin { - return { - async created(bot) { - const agent = options.agent - - const logger = useLogg('LLMAgent').useGlobalConfig() - - bot.memory.chatHistory.push(system(genActionAgentPrompt(bot))) - - // todo: get system message - const onChat = formBotChat(bot.username, async (username, message) => { - logger.withFields({ username, message }).log('Chat message received') - - // long memory - bot.memory.chatHistory.push(user(`${username}: ${message}`)) - - // short memory - const statusPrompt = await genStatusPrompt(bot) - const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => { - logger.log('thinking...') - - const handleCompletion = async (c: NeuriContext): Promise => { - logger.log('rerouting...') - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - if (!completion || 'error' in completion) { - logger.withFields({ completion }).error('Completion') - throw completion?.error || new Error('Unknown error') - } - - const content = await completion?.firstContent() - logger.withFields({ usage: completion.usage, content }).log('output') - - bot.memory.chatHistory.push(assistant(content)) - - return content - } - - const retirableHandler = toRetriable( - 3, // retryLimit - 1000, // delayInterval in ms - handleCompletion, - { onError: err => logger.withError(err).log('error occurred') }, - ) - - logger.log('handling...') - return await retirableHandler(c) - }) - - if (content) { - logger.withFields({ content }).log('responded') - bot.bot.chat(content) - } - }) - - options.airiClient.onEvent('input:text:voice', async (event) => { - logger.withFields({ user: event.data.discord?.guildMember, message: event.data.transcription }).log('Chat message received') - - // long memory - bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) - - // short memory - const statusPrompt = await genStatusPrompt(bot) - const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => { - logger.log('thinking...') - - const handleCompletion = async (c: NeuriContext): Promise => { - logger.log('rerouting...') - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) - if (!completion || 'error' in completion) { - logger.withFields({ completion }).error('Completion') - throw completion?.error || new Error('Unknown error') - } - - const content = await completion?.firstContent() - logger.withFields({ usage: completion.usage, content }).log('output') - - bot.memory.chatHistory.push(assistant(content)) - - return content - } - - const retirableHandler = toRetriable( - 3, // retryLimit - 1000, // delayInterval in ms - handleCompletion, - { onError: err => logger.withError(err).log('error occurred') }, - ) - - logger.log('handling...') - return await retirableHandler(c) - }) - - if (content) { - logger.withFields({ content }).log('responded') - bot.bot.chat(content) - } - }) - - bot.bot.on('chat', onChat) - }, - } -} diff --git a/services/minecraft/src/mineflayer/echo.ts b/services/minecraft/src/plugins/echo.ts similarity index 75% rename from services/minecraft/src/mineflayer/echo.ts rename to services/minecraft/src/plugins/echo.ts index b4be3dc5f..67b4932b3 100644 --- a/services/minecraft/src/mineflayer/echo.ts +++ b/services/minecraft/src/plugins/echo.ts @@ -2,14 +2,14 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' import { useLogg } from '@guiiai/logg' -import { formBotChat } from '../libs/mineflayer/message' +import { ChatMessageHandler } from '../libs/mineflayer/message' export function Echo(): MineflayerPlugin { const logger = useLogg('Echo').useGlobalConfig() return { spawned(mineflayer) { - const onChatHandler = formBotChat(mineflayer.username, (username, message) => { + const onChatHandler = new ChatMessageHandler(mineflayer.username).handleChat((username, message) => { logger.withFields({ username, message }).log('Chat message received') mineflayer.bot.chat(message) }) diff --git a/services/minecraft/src/mineflayer/follow.ts b/services/minecraft/src/plugins/follow.ts similarity index 100% rename from services/minecraft/src/mineflayer/follow.ts rename to services/minecraft/src/plugins/follow.ts diff --git a/services/minecraft/src/plugins/llm-agent.ts b/services/minecraft/src/plugins/llm-agent.ts new file mode 100644 index 000000000..3532d64b3 --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent.ts @@ -0,0 +1,189 @@ +import type { Client } from '@proj-airi/server-sdk' +import type { Neuri, NeuriContext } from 'neuri' +import type { ChatCompletion } from 'neuri/openai' +import type { Mineflayer } from '../libs/mineflayer' +import type { ActionAgent, ChatAgent, PlanningAgent } from '../libs/mineflayer/base-agent' +import type { MineflayerPlugin } from '../libs/mineflayer/plugin' + +import { useLogg } from '@guiiai/logg' +import { assistant, system, user } from 'neuri/openai' + +import { generateActionAgentPrompt, generateStatusPrompt } from '../agents/prompt/llm-agent.plugin' +import { createAppContainer } from '../container' +import { ChatMessageHandler } from '../libs/mineflayer/message' +import { toRetriable } from '../utils/helper' + +interface MineflayerWithAgents extends Mineflayer { + planning: PlanningAgent + action: ActionAgent + chat: ChatAgent +} + +interface LLMAgentOptions { + agent: Neuri + airiClient: Client +} + +async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType): Promise { + logger.log('rerouting...') + + const completion = await context.reroute('action', context.messages, { + model: 'openai/gpt-4o-mini', + }) as ChatCompletion | { error: { message: string } } & ChatCompletion + + if (!completion || 'error' in completion) { + logger.withFields({ completion }).error('Completion') + logger.withFields({ messages: context.messages }).log('messages') + return completion?.error?.message ?? 'Unknown error' + } + + const content = await completion.firstContent() + logger.withFields({ usage: completion.usage, content }).log('output') + + bot.memory.chatHistory.push(assistant(content)) + return content +} + +async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { + logger.withFields({ username, message }).log('Chat message received') + bot.memory.chatHistory.push(user(`${username}: ${message}`)) + + logger.log('thinking...') + + try { + // Create and execute plan + const plan = await bot.planning.createPlan(message) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // Generate response + // TODO: use chat agent and conversion manager + const statusPrompt = await generateStatusPrompt(bot) + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('handling response...') + return toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + { onError: err => logger.withError(err).log('error occurred') }, + )(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} + +async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { + logger + .withFields({ + user: event.data.discord?.guildMember, + message: event.data.transcription, + }) + .log('Chat message received') + + const statusPrompt = await generateStatusPrompt(bot) + bot.memory.chatHistory.push(system(statusPrompt)) + bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) + + try { + // 创建并执行计划 + const plan = await bot.planning.createPlan(event.data.transcription) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // 生成回复 + const retryHandler = toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + ) + + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('thinking...') + return retryHandler(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} + +export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { + return { + async created(bot) { + const logger = useLogg('LLMAgent').useGlobalConfig() + + // 创建容器并获取所需的服务 + const container = createAppContainer({ + neuri: options.agent, + model: 'openai/gpt-4o-mini', + maxHistoryLength: 50, + idleTimeout: 5 * 60 * 1000, + }) + + const actionAgent = container.resolve('actionAgent') + const planningAgent = container.resolve('planningAgent') + const chatAgent = container.resolve('chatAgent') + + // 初始化 agents + await actionAgent.init() + await planningAgent.init() + await chatAgent.init() + + // 类型转换 + const botWithAgents = bot as unknown as MineflayerWithAgents + botWithAgents.action = actionAgent + botWithAgents.planning = planningAgent + botWithAgents.chat = chatAgent + + // 初始化系统提示 + bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) + + // 设置消息处理 + const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => + handleChatMessage(username, message, botWithAgents, options.agent, logger)) + + options.airiClient.onEvent('input:text:voice', event => + handleVoiceInput(event, botWithAgents, options.agent, logger)) + + bot.bot.on('chat', onChat) + }, + + async beforeCleanup(bot) { + const botWithAgents = bot as unknown as MineflayerWithAgents + await botWithAgents.action?.destroy() + await botWithAgents.planning?.destroy() + await botWithAgents.chat?.destroy() + bot.bot.removeAllListeners('chat') + }, + } +} diff --git a/services/minecraft/src/mineflayer/pathfinder.ts b/services/minecraft/src/plugins/pathfinder.ts similarity index 100% rename from services/minecraft/src/mineflayer/pathfinder.ts rename to services/minecraft/src/plugins/pathfinder.ts diff --git a/services/minecraft/src/mineflayer/status.ts b/services/minecraft/src/plugins/status.ts similarity index 100% rename from services/minecraft/src/mineflayer/status.ts rename to services/minecraft/src/plugins/status.ts diff --git a/services/minecraft/src/prompts/agent.ts b/services/minecraft/src/prompts/agent.ts deleted file mode 100644 index 8e017dfd2..000000000 --- a/services/minecraft/src/prompts/agent.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Mineflayer } from '../libs/mineflayer' - -import { listInventory } from '../skills/actions/inventory' - -export function genSystemBasicPrompt(botName: string): string { - return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, -mine, build, and interact with the world by using commands.` -} - -export function genActionAgentPrompt(mineflayer: Mineflayer): string { - // ${ctx.prompt.selfPrompt} - - return `${genSystemBasicPrompt(mineflayer.username)} - -Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in -your responses, don't apologize constantly, don't give instructions or make lists unless -asked, and don't refuse requests. - -Do not use any emojis. Just call the function given you if needed. - -- If I command you 'stop', then call the 'stop' function. -- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. -` -} - -export async function genStatusPrompt(mineflayer: Mineflayer): Promise { - const inventory = await listInventory(mineflayer) - if (inventory.length === 0) { - return `I will give you the following information: -${mineflayer.status.toOneLiner()} - -Inventory: -[Empty] - -Item in hand: -[Empty] -` - } - const inventoryStr = inventory.map(item => `${item.name} x ${item.count}`).join(', ') - const itemInHand = `${inventory[0].name} x ${inventory[0].count}` // TODO: mock - - return `I will give you the following information: -${mineflayer.status.toOneLiner()} - -Inventory: -${inventoryStr} - -Item in hand: -${itemInHand} -` -} - -export function genQueryAgentPrompt(mineflayer: Mineflayer): string { - const prompt = `You are a helpful assistant that asks questions to help me decide the next immediate -task to do in Minecraft. My ultimate goal is to discover as many things as possible, -accomplish as many tasks as possible and become the best Minecraft player in the world. - -I will give you the following information: -${mineflayer.status.toOneLiner()} -` - - return prompt -} diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts index 333d91d02..07fe945e3 100644 --- a/services/minecraft/src/skills/actions/collect-block.ts +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -4,8 +4,8 @@ import type { Mineflayer } from '../../libs/mineflayer' import { useLogg } from '@guiiai/logg' import pathfinder from 'mineflayer-pathfinder' -import { getNearestBlocks } from '../../composables/world' import { breakBlockAt } from '../blocks' +import { getNearestBlocks } from '../world' import { ensurePickaxe } from './ensure' import { pickupNearbyItems } from './world-interactions' diff --git a/services/minecraft/src/skills/actions/gather-wood.ts b/services/minecraft/src/skills/actions/gather-wood.ts index 91479c13a..d757eb461 100644 --- a/services/minecraft/src/skills/actions/gather-wood.ts +++ b/services/minecraft/src/skills/actions/gather-wood.ts @@ -2,10 +2,10 @@ import type { Mineflayer } from '../../libs/mineflayer' import { useLogg } from '@guiiai/logg' -import { getNearestBlocks } from '../../composables/world' import { sleep } from '../../utils/helper' import { breakBlockAt } from '../blocks' import { goToPosition, moveAway } from '../movement' +import { getNearestBlocks } from '../world' import { pickupNearbyItems } from './world-interactions' const logger = useLogg('Action:GatherWood').useGlobalConfig() diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts index 0fa75ff8a..1614d9d1d 100644 --- a/services/minecraft/src/skills/actions/inventory.ts +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -3,8 +3,8 @@ import type { Mineflayer } from '../../libs/mineflayer' import { useLogg } from '@guiiai/logg' -import { getNearestBlock } from '../../composables/world' import { goToPlayer, goToPosition } from '../movement' +import { getNearestBlock } from '../world' const logger = useLogg('Action:Inventory').useGlobalConfig() diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 78e6cff87..383c49bde 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -4,10 +4,10 @@ import type { BlockFace } from './base' import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' -import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from '../composables/world' import { getBlockId, makeItem } from '../utils/mcdata' import { log } from './base' import { goToPosition } from './movement' +import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world' const { goals, Movements } = pathfinderModel diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index eae3d10c9..ff98a0be0 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -4,10 +4,10 @@ import type { Mineflayer } from '../libs/mineflayer' import pathfinderModel from 'mineflayer-pathfinder' -import { getNearbyEntities, getNearestEntityWhere } from '../composables/world' import { sleep } from '../utils/helper' import { isHostile } from '../utils/mcdata' import { log } from './base' +import { getNearbyEntities, getNearestEntityWhere } from './world' const { goals } = pathfinderModel diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 11375dfad..8ca439763 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -5,11 +5,11 @@ import type { Mineflayer } from '../libs/mineflayer' import { useLogg } from '@guiiai/logg' -import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from '../composables/world' import { getItemId, getItemName } from '../utils/mcdata' import { ensureCraftingTable } from './actions/ensure' import { collectBlock, placeBlock } from './blocks' import { goToNearestBlock, goToPosition, moveAway } from './movement' +import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './world' const logger = useLogg('Skill:Crafting').useGlobalConfig() diff --git a/services/minecraft/src/skills/inventory.ts b/services/minecraft/src/skills/inventory.ts index 91c3c9ca1..ec6c4ef34 100644 --- a/services/minecraft/src/skills/inventory.ts +++ b/services/minecraft/src/skills/inventory.ts @@ -1,8 +1,8 @@ import type { Mineflayer } from '../libs/mineflayer' -import { getNearestBlock } from '../composables/world' import { log } from './base' import { goToPlayer, goToPosition } from './movement' +import { getNearestBlock } from './world' export async function equip(mineflayer: Mineflayer, itemName: string): Promise { const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName) diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index a48ae85d9..f73a5ba68 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -6,9 +6,9 @@ import { randomInt } from 'es-toolkit' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' -import { getNearestBlock, getNearestEntityWhere } from '../composables/world' import { sleep } from '../utils/helper' import { log } from './base' +import { getNearestBlock, getNearestEntityWhere } from './world' const logger = useLogg('Skill:Movement').useGlobalConfig() const { goals, Movements } = pathfinder diff --git a/services/minecraft/src/composables/world.ts b/services/minecraft/src/skills/world.ts similarity index 100% rename from services/minecraft/src/composables/world.ts rename to services/minecraft/src/skills/world.ts diff --git a/services/minecraft/src/utils/helper.ts b/services/minecraft/src/utils/helper.ts index c1eb515da..e0600bf2b 100644 --- a/services/minecraft/src/utils/helper.ts +++ b/services/minecraft/src/utils/helper.ts @@ -1 +1,39 @@ export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Returns a retirable anonymous function with configured retryLimit and delayInterval + * + * @param retryLimit Number of retry attempts + * @param delayInterval Delay between retries in milliseconds + * @param func Function to be called + * @returns A wrapped function with the same signature as func + */ +export function toRetriable( + retryLimit: number, + delayInterval: number, + func: (...args: A[]) => Promise, + hooks?: { + onError?: (err: unknown) => void + }, +): (...args: A[]) => Promise { + let retryCount = 0 + return async function (args: A): Promise { + try { + return await func(args) + } + catch (err) { + if (hooks?.onError) { + hooks.onError(err) + } + + if (retryCount < retryLimit) { + retryCount++ + await sleep(delayInterval) + return await toRetriable(retryLimit - retryCount, delayInterval, func)(args) + } + else { + throw err + } + } + } +} diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 21a02e937..48f093fa8 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -1,5 +1,3 @@ -// src/utils/minecraftData.ts - import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' diff --git a/services/minecraft/src/utils/reliability.ts b/services/minecraft/src/utils/reliability.ts deleted file mode 100644 index 3d277f53e..000000000 --- a/services/minecraft/src/utils/reliability.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { sleep } from './helper' - -/** - * Returns a retirable anonymous function with configured retryLimit and delayInterval - * - * @param retryLimit Number of retry attempts - * @param delayInterval Delay between retries in milliseconds - * @param func Function to be called - * @returns A wrapped function with the same signature as func - */ -export function toRetriable( - retryLimit: number, - delayInterval: number, - func: (...args: A[]) => Promise, - hooks?: { - onError?: (err: unknown) => void - }, -): (...args: A[]) => Promise { - let retryCount = 0 - return async function (args: A): Promise { - try { - return await func(args) - } - catch (err) { - if (hooks?.onError) { - hooks.onError(err) - } - - if (retryCount < retryLimit) { - retryCount++ - await sleep(delayInterval) - return await toRetriable(retryLimit - retryCount, delayInterval, func)(args) - } - else { - throw err - } - } - } -} From b804f687cd8322117d8fd779179cfaa51af7db96 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 22 Jan 2025 00:47:59 +0800 Subject: [PATCH 68/77] refactor: planning agent v2 (#2) --- services/minecraft/src/agents/action/index.ts | 112 +--- .../src/agents/action/llm-handler.ts | 35 ++ .../minecraft/src/agents/planning/index.ts | 533 +++++++++--------- .../src/agents/planning/llm-handler.ts | 80 ++- .../minecraft/src/agents/prompt/planning.ts | 60 +- .../src/libs/mineflayer/base-agent.ts | 10 +- services/minecraft/src/plugins/llm-agent.ts | 14 +- 7 files changed, 419 insertions(+), 425 deletions(-) diff --git a/services/minecraft/src/agents/action/index.ts b/services/minecraft/src/agents/action/index.ts index 7815b6348..19109b000 100644 --- a/services/minecraft/src/agents/action/index.ts +++ b/services/minecraft/src/agents/action/index.ts @@ -1,10 +1,10 @@ import type { Mineflayer } from '../../libs/mineflayer' import type { Action } from '../../libs/mineflayer/action' import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent' +import type { PlanStep } from '../planning/llm-handler' import { useBot } from '../../composables/bot' import { AbstractAgent } from '../../libs/mineflayer/base-agent' -import { ActionManager } from '../../manager/action' import { actionsList } from './tools' interface ActionState { @@ -20,7 +20,6 @@ interface ActionState { export class ActionAgentImpl extends AbstractAgent implements ActionAgent { public readonly type = 'action' as const private actions: Map - private actionManager: ActionManager private mineflayer: Mineflayer private currentActionState: ActionState @@ -28,7 +27,6 @@ export class ActionAgentImpl extends AbstractAgent implements ActionAgent { super(config) this.actions = new Map() this.mineflayer = useBot().bot - this.actionManager = new ActionManager(this.mineflayer) this.currentActionState = { executing: false, label: '', @@ -41,102 +39,46 @@ export class ActionAgentImpl extends AbstractAgent implements ActionAgent { actionsList.forEach(action => this.actions.set(action.name, action)) // Set up event listeners - // todo: nothing to call here this.on('message', async ({ sender, message }) => { await this.handleAgentMessage(sender, message) }) } protected async destroyAgent(): Promise { - await this.actionManager.stop() - this.actionManager.cancelResume() this.actions.clear() this.removeAllListeners() - this.currentActionState = { - executing: false, - label: '', - startTime: 0, - } } - public async performAction( - name: string, - params: unknown[], - options: { timeout?: number, resume?: boolean } = {}, - ): Promise { + public async performAction(step: PlanStep): Promise { if (!this.initialized) { throw new Error('Action agent not initialized') } - const action = this.actions.get(name) + const action = this.actions.get(step.tool) if (!action) { - throw new Error(`Action not found: ${name}`) + throw new Error(`Unknown action: ${step.tool}`) } + this.logger.withFields({ + action: step.tool, + description: step.description, + params: step.params, + }).log('Performing action') + + // Update action state + this.updateActionState(true, step.description) + try { - this.updateActionState(true, name) - this.logger.withFields({ name, params }).log('Performing action') - - const result = await this.actionManager.runAction( - name, - async () => { - const fn = action.perform(this.mineflayer) - return await fn(...params) - }, - { - timeout: options.timeout ?? 60, - resume: options.resume ?? false, - }, - ) - - if (!result.success) { - throw new Error(result.message ?? 'Action failed') - } - + // Execute action with provided parameters + const result = await action.perform(this.mineflayer)(...Object.values(step.params)) return this.formatActionOutput({ - message: result.message, - timedout: result.timedout, + message: result, + timedout: false, interrupted: false, }) } catch (error) { - this.logger.withFields({ name, params, error }).error('Failed to perform action') - throw error - } - finally { - this.updateActionState(false) - } - } - - public async resumeAction(name: string, params: unknown[]): Promise { - const action = this.actions.get(name) - if (!action) { - throw new Error(`Action not found: ${name}`) - } - - try { - this.updateActionState(true, name) - const result = await this.actionManager.resumeAction( - name, - async () => { - const fn = action.perform(this.mineflayer) - return await fn(...params) - }, - 60, - ) - - if (!result.success) { - throw new Error(result.message ?? 'Action failed') - } - - return this.formatActionOutput({ - message: result.message, - timedout: result.timedout, - interrupted: false, - }) - } - catch (error) { - this.logger.withFields({ name, params, error }).error('Failed to resume action') + this.logger.withError(error).error('Action failed') throw error } finally { @@ -149,13 +91,10 @@ export class ActionAgentImpl extends AbstractAgent implements ActionAgent { } private async handleAgentMessage(sender: string, message: string): Promise { - if (sender === 'system') { - if (message.includes('interrupt')) { - await this.actionManager.stop() - } - } - else { - this.logger.withFields({ sender, message }).log('Processing agent message') + if (sender === 'system' && message.includes('interrupt') && this.currentActionState.executing) { + // Handle interruption + this.logger.log('Received interrupt request') + // Additional interrupt handling logic here } } @@ -163,18 +102,17 @@ export class ActionAgentImpl extends AbstractAgent implements ActionAgent { this.currentActionState = { executing, label, - startTime: executing ? Date.now() : 0, + startTime: executing ? Date.now() : this.currentActionState.startTime, } - this.emit('actionStateChanged', this.currentActionState) } private formatActionOutput(result: { message: string | null, timedout: boolean, interrupted: boolean }): string { if (result.timedout) { - return `Action timed out: ${result.message}` + return 'Action timed out' } if (result.interrupted) { return 'Action was interrupted' } - return result.message ?? '' + return result.message || 'Action completed successfully' } } diff --git a/services/minecraft/src/agents/action/llm-handler.ts b/services/minecraft/src/agents/action/llm-handler.ts index 82e3aa160..1cdf7474f 100644 --- a/services/minecraft/src/agents/action/llm-handler.ts +++ b/services/minecraft/src/agents/action/llm-handler.ts @@ -1,9 +1,11 @@ import type { Agent } from 'neuri' import type { Message } from 'neuri/openai' import type { Mineflayer } from '../../libs/mineflayer' +import type { PlanStep } from '../planning/llm-handler' import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' +import { system, user } from 'neuri/openai' import { BaseLLMHandler } from '../../libs/llm/base' import { actionsList } from './tools' @@ -31,6 +33,39 @@ export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { + const systemPrompt = this.generateActionSystemPrompt() + const userPrompt = this.generateActionUserPrompt(step) + const messages = [system(systemPrompt), user(userPrompt)] + + const result = await this.handleAction(messages) + return result + } + + private generateActionSystemPrompt(): string { + return `You are a Minecraft bot action executor. Your task is to execute a given step using available tools. +You have access to various tools that can help you accomplish tasks. +When using a tool: +1. Choose the most appropriate tool for the task +2. Determine the correct parameters based on the context +3. Handle any errors or unexpected situations + +Remember to: +- Be precise with tool parameters +- Consider the current state of the bot +- Handle failures gracefully` + } + + private generateActionUserPrompt(step: PlanStep): string { + return `Execute this step: ${step.description} + +Suggested tool: ${step.tool} +Params: ${JSON.stringify(step.params)} + +Please use the appropriate tool with the correct parameters to accomplish this step. +If the suggested tool is not appropriate, you may choose a different one.` + } + public async handleAction(messages: Message[]): Promise { const result = await this.config.agent.handleStateless(messages, async (context) => { this.logger.log('Processing action...') diff --git a/services/minecraft/src/agents/planning/index.ts b/services/minecraft/src/agents/planning/index.ts index 929a67070..61d1c7127 100644 --- a/services/minecraft/src/agents/planning/index.ts +++ b/services/minecraft/src/agents/planning/index.ts @@ -4,7 +4,7 @@ import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from import { AbstractAgent } from '../../libs/mineflayer/base-agent' import { ActionAgentImpl } from '../action' -import { PlanningLLMHandler } from './llm-handler' +import { PlanningLLMHandler, type PlanStep } from './llm-handler' interface PlanContext { goal: string @@ -13,20 +13,7 @@ interface PlanContext { lastUpdate: number retryCount: number isGenerating: boolean - pendingSteps: Array<{ - action: string - params: unknown[] - }> -} - -interface PlanTemplate { - goal: string - conditions: string[] - steps: Array<{ - action: string - params: unknown[] - }> - requiresAction: boolean + pendingSteps: PlanStep[] } export interface PlanningAgentConfig extends AgentConfig { @@ -42,15 +29,12 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { private context: PlanContext | null = null private actionAgent: ActionAgent | null = null private memoryAgent: MemoryAgent | null = null - private planTemplates: Map private llmConfig: PlanningAgentConfig['llm'] private llmHandler: PlanningLLMHandler constructor(config: PlanningAgentConfig) { super(config) - this.planTemplates = new Map() this.llmConfig = config.llm - this.initializePlanTemplates() this.llmHandler = new PlanningLLMHandler({ agent: this.llmConfig.agent, model: this.llmConfig.model, @@ -82,7 +66,6 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { this.context = null this.actionAgent = null this.memoryAgent = null - this.planTemplates.clear() this.removeAllListeners() } @@ -120,7 +103,7 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { } // Create plan steps based on available actions and goal - const steps = await this.generatePlanSteps(goal, availableActions) + const steps = await this.generatePlanSteps(goal, availableActions, 'system') // Create new plan const plan: Plan = { @@ -172,8 +155,31 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { plan.status = 'in_progress' this.currentPlan = plan - // Start generating and executing steps in parallel - await this.generateAndExecutePlanSteps(plan) + // Execute each step + for (const step of plan.steps) { + try { + this.logger.withField('step', step).log('Executing step') + await this.actionAgent.performAction(step) + } + catch (stepError) { + this.logger.withError(stepError).error('Failed to execute step') + + // Attempt to adjust plan and retry + if (this.context && this.context.retryCount < 3) { + this.context.retryCount++ + // Adjust plan and restart + const adjustedPlan = await this.adjustPlan( + plan, + stepError instanceof Error ? stepError.message : 'Unknown error', + 'system', + ) + await this.executePlan(adjustedPlan) + return + } + + throw stepError + } + } plan.status = 'completed' } @@ -186,187 +192,157 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { } } - private async generateAndExecutePlanSteps(plan: Plan): Promise { - if (!this.context || !this.actionAgent) { - return - } + // private async generateStepsStream( + // goal: string, + // availableActions: Action[], + // sender: string, + // ): Promise { + // if (!this.context) { + // return + // } - // Initialize step generation - this.context.isGenerating = true - this.context.pendingSteps = [] + // try { + // // Generate all steps at once + // const steps = await this.llmHandler.generatePlan(goal, availableActions, sender) + // if (!this.context.isGenerating) { + // return + // } - // Get available actions - const availableActions = this.actionAgent.getAvailableActions() + // // Add all steps to pending queue + // this.context.pendingSteps.push(...steps) + // this.logger.withField('steps', steps).log('Generated steps') + // } + // catch (error) { + // this.logger.withError(error).error('Failed to generate steps') + // throw error + // } + // finally { + // this.context.isGenerating = false + // } + // } - // Start step generation - const generationPromise = this.generateStepsStream(plan.goal, availableActions) + // private async executeStepsStream(): Promise { + // if (!this.context || !this.actionAgent) { + // return + // } - // Start step execution - const executionPromise = this.executeStepsStream() + // try { + // while (this.context.isGenerating || this.context.pendingSteps.length > 0) { + // // Wait for steps to be available + // if (this.context.pendingSteps.length === 0) { + // await new Promise(resolve => setTimeout(resolve, 100)) + // continue + // } - // Wait for both generation and execution to complete - await Promise.all([generationPromise, executionPromise]) - } + // // Execute next step + // const step = this.context.pendingSteps.shift() + // if (!step) { + // continue + // } - private async generateStepsStream( - goal: string, - availableActions: Action[], - ): Promise { - if (!this.context) { - return - } + // try { + // this.logger.withField('step', step).log('Executing step') + // await this.actionAgent.performAction(step) + // this.context.lastUpdate = Date.now() + // this.context.currentStep++ + // } + // catch (stepError) { + // this.logger.withError(stepError).error('Failed to execute step') - try { - // Generate steps in chunks - const generator = this.createStepGenerator(goal, availableActions) - for await (const steps of generator) { - if (!this.context.isGenerating) { - break - } + // // Attempt to adjust plan and retry + // if (this.context.retryCount < 3) { + // this.context.retryCount++ + // // Stop current generation + // this.context.isGenerating = false + // this.context.pendingSteps = [] + // // Adjust plan and restart + // const adjustedPlan = await this.adjustPlan( + // this.currentPlan!, + // stepError instanceof Error ? stepError.message : 'Unknown error', + // 'system', + // ) + // await this.executePlan(adjustedPlan) + // return + // } - // Add generated steps to pending queue - this.context.pendingSteps.push(...steps) - this.logger.withField('steps', steps).log('Generated new steps') - } - } - catch (error) { - this.logger.withError(error).error('Failed to generate steps') - throw error - } - finally { - this.context.isGenerating = false - } - } + // throw stepError + // } + // } + // } + // catch (error) { + // this.logger.withError(error).error('Failed to execute steps') + // throw error + // } + // } - private async executeStepsStream(): Promise { - if (!this.context || !this.actionAgent) { - return - } + // private async *createStepGenerator( + // goal: string, + // availableActions: Action[], + // ): AsyncGenerator { + // // Use LLM to generate plan in chunks + // this.logger.log('Generating plan using LLM') + // const chunkSize = 3 // Generate 3 steps at a time + // let currentChunk = 1 - try { - while (this.context.isGenerating || this.context.pendingSteps.length > 0) { - // Wait for steps to be available - if (this.context.pendingSteps.length === 0) { - await new Promise(resolve => setTimeout(resolve, 100)) - continue - } + // while (true) { + // const steps = await this.llmHandler.generatePlan( + // goal, + // availableActions, + // `Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`, + // ) - // Execute next step - const step = this.context.pendingSteps.shift() - if (!step) { - continue - } + // if (steps.length === 0) { + // break + // } - try { - this.logger.withField('step', step).log('Executing step') - await this.actionAgent.performAction(step.action, step.params) - this.context.lastUpdate = Date.now() - this.context.currentStep++ - } - catch (stepError) { - this.logger.withError(stepError).error('Failed to execute step') + // yield steps + // currentChunk++ - // Attempt to adjust plan and retry - if (this.context.retryCount < 3) { - this.context.retryCount++ - // Stop current generation - this.context.isGenerating = false - this.context.pendingSteps = [] - // Adjust plan and restart - const adjustedPlan = await this.adjustPlan( - this.currentPlan!, - stepError instanceof Error ? stepError.message : 'Unknown error', - ) - await this.executePlan(adjustedPlan) - return - } + // // Check if we've generated enough steps or if the goal is achieved + // if (steps.length < chunkSize || await this.isGoalAchieved(goal)) { + // break + // } + // } + // } - throw stepError - } - } - } - catch (error) { - this.logger.withError(error).error('Failed to execute steps') - throw error - } - } + // private async isGoalAchieved(goal: string): Promise { + // if (!this.context || !this.actionAgent) { + // return false + // } - private async *createStepGenerator( - goal: string, - availableActions: Action[], - ): AsyncGenerator, void, unknown> { - // First, try to find a matching template - const template = this.findMatchingTemplate(goal) - if (template) { - this.logger.log('Using plan template') - yield template.steps - return - } + // const requirements = this.parseGoalRequirements(goal) - // If no template matches, use LLM to generate plan in chunks - this.logger.log('Generating plan using LLM') - const chunkSize = 3 // Generate 3 steps at a time - let currentChunk = 1 + // // Check inventory for required items + // if (requirements.needsItems && requirements.items) { + // const inventorySteps = this.generateGatheringSteps(requirements.items) + // if (inventorySteps.length > 0) { + // this.context.pendingSteps.push(...inventorySteps) + // return false + // } + // } - while (true) { - const steps = await this.llmHandler.generatePlan( - goal, - availableActions, - `Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`, - ) + // // Check location requirements + // if (requirements.needsMovement && requirements.location) { + // const movementSteps = this.generateMovementSteps(requirements.location) + // if (movementSteps.length > 0) { + // this.context.pendingSteps.push(...movementSteps) + // return false + // } + // } - if (steps.length === 0) { - break - } + // // Check interaction requirements + // if (requirements.needsInteraction && requirements.target) { + // const interactionSteps = this.generateInteractionSteps(requirements.target) + // if (interactionSteps.length > 0) { + // this.context.pendingSteps.push(...interactionSteps) + // return false + // } + // } - yield steps - currentChunk++ + // return true + // } - // Check if we've generated enough steps or if the goal is achieved - if (steps.length < chunkSize || await this.isGoalAchieved(goal)) { - break - } - } - } - - private async isGoalAchieved(goal: string): Promise { - if (!this.context || !this.actionAgent) { - return false - } - - const requirements = this.parseGoalRequirements(goal) - - // Check inventory for required items - if (requirements.needsItems && requirements.items) { - const inventorySteps = this.generateGatheringSteps(requirements.items) - if (inventorySteps.length > 0) { - this.context.pendingSteps.push(...inventorySteps) - return false - } - } - - // Check location requirements - if (requirements.needsMovement && requirements.location) { - const movementSteps = this.generateMovementSteps(requirements.location) - if (movementSteps.length > 0) { - this.context.pendingSteps.push(...movementSteps) - return false - } - } - - // Check interaction requirements - if (requirements.needsInteraction && requirements.target) { - const interactionSteps = this.generateInteractionSteps(requirements.target) - if (interactionSteps.length > 0) { - this.context.pendingSteps.push(...interactionSteps) - return false - } - } - - return true - } - - public async adjustPlan(plan: Plan, feedback: string): Promise { + public async adjustPlan(plan: Plan, feedback: string, sender: string): Promise { if (!this.initialized) { throw new Error('Planning agent not initialized') } @@ -383,7 +359,7 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { const recoverySteps = this.generateRecoverySteps(feedback) // Generate new steps from the current point - const newSteps = await this.generatePlanSteps(plan.goal, availableActions, feedback) + const newSteps = await this.generatePlanSteps(plan.goal, availableActions, sender, feedback) // Create adjusted plan const adjustedPlan: Plan = { @@ -409,59 +385,119 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { } } - private generateGatheringSteps(items: string[]): Array<{ action: string, params: unknown[] }> { - const steps: Array<{ action: string, params: unknown[] }> = [] + // private generateGatheringSteps(items: string[]): PlanStep[] { + // const steps: PlanStep[] = [] - for (const item of items) { - steps.push( - { action: 'searchForBlock', params: [item, 64] }, - { action: 'collectBlocks', params: [item, 1] }, - ) - } + // for (const item of items) { + // steps.push( + // { + // description: `Search for ${item} in the surrounding area`, + // tool: 'searchForBlock', + // params: { + // blockType: item, + // range: 64, + // }, + // }, + // { + // description: `Collect ${item} from the found location`, + // tool: 'collectBlocks', + // params: { + // blockType: item, + // count: 1, + // }, + // }, + // ) + // } - return steps - } + // return steps + // } - private generateMovementSteps(location: { x?: number, y?: number, z?: number }): Array<{ action: string, params: unknown[] }> { - if (location.x !== undefined && location.y !== undefined && location.z !== undefined) { - return [{ - action: 'goToCoordinates', - params: [location.x, location.y, location.z, 1], - }] - } - return [] - } + // private generateMovementSteps(location: { x?: number, y?: number, z?: number }): PlanStep[] { + // if (location.x !== undefined && location.y !== undefined && location.z !== undefined) { + // return [{ + // description: `Move to coordinates (${location.x}, ${location.y}, ${location.z})`, + // tool: 'goToCoordinates', + // params: { + // x: location.x, + // y: location.y, + // z: location.z, + // }, + // }] + // } + // return [] + // } - private generateInteractionSteps(target: string): Array<{ action: string, params: unknown[] }> { - return [{ - action: 'activate', - params: [target], - }] - } + // private generateInteractionSteps(target: string): PlanStep[] { + // return [{ + // description: `Interact with ${target}`, + // tool: 'activate', + // params: { + // target, + // }, + // }] + // } - private generateRecoverySteps(feedback: string): Array<{ action: string, params: unknown[] }> { - const steps: Array<{ action: string, params: unknown[] }> = [] + private generateRecoverySteps(feedback: string): PlanStep[] { + const steps: PlanStep[] = [] if (feedback.includes('not found')) { - steps.push({ action: 'searchForBlock', params: ['any', 128] }) + steps.push({ + description: 'Search in a wider area', + tool: 'searchForBlock', + params: { + blockType: 'oak_log', + range: 64, + }, + }) } if (feedback.includes('inventory full')) { - steps.push({ action: 'discard', params: ['cobblestone', 64] }) + steps.push({ + description: 'Clear inventory space', + tool: 'discard', + params: { + blockType: 'oak_log', + count: 1, + }, + }) } if (feedback.includes('blocked') || feedback.includes('cannot reach')) { - steps.push({ action: 'moveAway', params: [5] }) + steps.push({ + description: 'Move away from obstacles', + tool: 'moveAway', + params: { + range: 64, + }, + }) } if (feedback.includes('too far')) { - steps.push({ action: 'moveAway', params: [-3] }) // Move closer + steps.push({ + description: 'Move closer to target', + tool: 'moveAway', + params: { + range: 64, + }, + }) } if (feedback.includes('need tool')) { steps.push( - { action: 'craftRecipe', params: ['wooden_pickaxe', 1] }, - { action: 'equip', params: ['wooden_pickaxe'] }, + { + description: 'Craft a wooden pickaxe', + tool: 'craftRecipe', + params: { + recipe: 'oak_pickaxe', + }, + }, + { + description: 'Equip the wooden pickaxe', + tool: 'equip', + params: { + item: 'oak_pickaxe', + }, + }, ) } @@ -491,44 +527,6 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { return true } - private initializePlanTemplates(): void { - // Add common plan templates - this.planTemplates.set('collect wood', { - goal: 'collect wood', - conditions: ['needs_axe', 'near_trees'], - steps: [ - { action: 'searchForBlock', params: ['log', 64] }, - { action: 'collectBlocks', params: ['log', 1] }, - ], - requiresAction: true, - }) - - this.planTemplates.set('find shelter', { - goal: 'find shelter', - conditions: ['is_night', 'unsafe'], - steps: [ - { action: 'searchForBlock', params: ['bed', 64] }, - { action: 'goToBed', params: [] }, - ], - requiresAction: true, - }) - - // Add templates for non-action goals - this.planTemplates.set('hello', { - goal: 'hello', - conditions: [], - steps: [], - requiresAction: false, - }) - - this.planTemplates.set('how are you', { - goal: 'how are you', - conditions: [], - steps: [], - requiresAction: false, - }) - } - private async handleAgentMessage(sender: string, message: string): Promise { if (sender === 'system') { if (message.includes('interrupt')) { @@ -541,7 +539,7 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { // If there's a current plan, try to adjust it based on the message if (this.currentPlan) { - await this.adjustPlan(this.currentPlan, message) + await this.adjustPlan(this.currentPlan, message, sender) } } } @@ -565,27 +563,12 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent { private async generatePlanSteps( goal: string, availableActions: Action[], + sender: string, feedback?: string, - ): Promise> { - // First, try to find a matching template - const template = this.findMatchingTemplate(goal) - if (template) { - this.logger.log('Using plan template') - return template.steps - } - - // If no template matches, use LLM to generate plan + ): Promise { + // Generate all steps at once this.logger.log('Generating plan using LLM') - return await this.llmHandler.generatePlan(goal, availableActions, feedback) - } - - private findMatchingTemplate(goal: string): PlanTemplate | undefined { - for (const [pattern, template] of this.planTemplates.entries()) { - if (goal.toLowerCase().includes(pattern.toLowerCase())) { - return template - } - } - return undefined + return await this.llmHandler.generatePlan(goal, availableActions, sender, feedback) } private parseGoalRequirements(goal: string): { diff --git a/services/minecraft/src/agents/planning/llm-handler.ts b/services/minecraft/src/agents/planning/llm-handler.ts index 175a60e68..81d9a4e68 100644 --- a/services/minecraft/src/agents/planning/llm-handler.ts +++ b/services/minecraft/src/agents/planning/llm-handler.ts @@ -11,14 +11,21 @@ export async function createPlanningNeuriAgent(): Promise { return agent('planning').build() } +export interface PlanStep { + description: string + tool: string + params: Record +} + export class PlanningLLMHandler extends BaseLLMHandler { public async generatePlan( goal: string, availableActions: Action[], + sender: string, feedback?: string, - ): Promise> { + ): Promise { const systemPrompt = generatePlanningAgentSystemPrompt(availableActions) - const userPrompt = generatePlanningAgentUserPrompt(goal, feedback) + const userPrompt = generatePlanningAgentUserPrompt(goal, sender, feedback) const messages = [system(systemPrompt), user(userPrompt)] const result = await this.config.agent.handleStateless(messages, async (context) => { @@ -36,26 +43,59 @@ export class PlanningLLMHandler extends BaseLLMHandler { return this.parsePlanContent(result) } - private parsePlanContent(content: string): Array<{ action: string, params: unknown[] }> { - try { - const match = content.match(/\[[\s\S]*\]/) - if (!match) { - throw new Error('No plan found in response') + private parsePlanContent(content: string): PlanStep[] { + // Split content into steps (numbered list) + const steps = content.split(/\d+\./).filter(step => step.trim().length > 0) + + return steps.map((step) => { + const lines = step.trim().split('\n') + const description = lines[0].trim() + + // Extract tool name and parameters + let tool = '' + const params: Record = {} + + for (const line of lines) { + const trimmed = line.trim() + + // Extract tool name + if (trimmed.startsWith('Tool:')) { + tool = trimmed.split(':')[1].trim() + continue + } + + // Extract parameters + if (trimmed === 'Params:') { + let i = lines.indexOf(line) + 1 + while (i < lines.length) { + const paramLine = lines[i].trim() + if (paramLine === '') + break + + const paramMatch = paramLine.match(/(\w+):\s*(.+)/) + if (paramMatch) { + const [, key, value] = paramMatch + // Try to parse numbers and booleans + if (value === 'true') + params[key] = true + else if (value === 'false') + params[key] = false + else if (/^\d+$/.test(value)) + params[key] = Number.parseInt(value) + else if (/^\d*\.\d+$/.test(value)) + params[key] = Number.parseFloat(value) + else params[key] = value.trim() + } + i++ + } + } } - const plan = JSON.parse(match[0]) - if (!Array.isArray(plan)) { - throw new TypeError('Invalid plan format') + return { + description, + tool, + params, } - - return plan.map(step => ({ - action: step.action, - params: step.params, - })) - } - catch (error) { - this.logger.withError(error).error('Failed to parse plan') - throw error - } + }) } } diff --git a/services/minecraft/src/agents/prompt/planning.ts b/services/minecraft/src/agents/prompt/planning.ts index 6c3bf21b8..87928c76e 100644 --- a/services/minecraft/src/agents/prompt/planning.ts +++ b/services/minecraft/src/agents/prompt/planning.ts @@ -2,46 +2,46 @@ import type { Action } from '../../libs/mineflayer/action' export function generatePlanningAgentSystemPrompt(availableActions: Action[]): string { const actionsList = availableActions - .map(action => `- ${action.name}: ${action.description}`) - .join('\n') + .map((action) => { + const params = Object.keys(action.schema.shape) + .map(name => ` - ${name}`) + .join('\n') + return `- ${action.name}: ${action.description}\n Parameters:\n${params}` + }) + .join('\n\n') - return `You are a Minecraft bot planner. Your task is to create a plan to achieve a given goal. -Available actions: + return `You are a Minecraft bot planner. Break down goals into simple action steps. + +Available tools: ${actionsList} -Respond with a Valid JSON array of steps, where each step has: -- action: The name of the action to perform -- params: Array of parameters for the action +Format each step as: +1. Action description (short, direct command) +2. Tool name +3. Required parameters -DO NOT contains any \`\`\` or explation, otherwise agent will be interrupted. +Example: +1. Follow player + Tool: followPlayer + Params: + player: luoling8192 + follow_dist: 3 -Example response: -[ - { - "action": "searchForBlock", - "params": ["log", 64] - }, - { - "action": "collectBlocks", - "params": ["log", 1] - } - ]` +Keep steps: +- Short and direct +- Action-focused +- Parameters precise +- Generate all steps at once` } -export function generatePlanningAgentUserPrompt(goal: string, feedback?: string): string { - let prompt = `Create a detailed plan to: ${goal} +export function generatePlanningAgentUserPrompt(goal: string, sender: string, feedback?: string): string { + let prompt = `${sender}: ${goal} -Consider the following aspects: -1. Required materials and their quantities -2. Required tools and their availability -3. Necessary crafting steps -4. Block placement requirements -5. Current inventory status - -Please generate steps that handle these requirements in the correct order.` +Generate minimal steps with exact parameters. +Use the sender's name (${sender}) for player-related parameters.` if (feedback) { - prompt += `\nPrevious attempt feedback: ${feedback}` + prompt += `\n\nPrevious attempt failed: ${feedback}` } return prompt } diff --git a/services/minecraft/src/libs/mineflayer/base-agent.ts b/services/minecraft/src/libs/mineflayer/base-agent.ts index fc573a537..132ca98f1 100644 --- a/services/minecraft/src/libs/mineflayer/base-agent.ts +++ b/services/minecraft/src/libs/mineflayer/base-agent.ts @@ -1,3 +1,4 @@ +import type { PlanStep } from '../../agents/planning/llm-handler' import type { Action } from './action' import { useLogg } from '@guiiai/logg' @@ -19,7 +20,7 @@ export interface BaseAgent { export interface ActionAgent extends BaseAgent { type: 'action' - performAction: (name: string, params: unknown[]) => Promise + performAction: (step: PlanStep) => Promise getAvailableActions: () => Action[] } @@ -33,10 +34,7 @@ export interface MemoryAgent extends BaseAgent { export interface Plan { goal: string - steps: Array<{ - action: string - params: unknown[] - }> + steps: PlanStep[] status: 'pending' | 'in_progress' | 'completed' | 'failed' requiresAction: boolean } @@ -45,7 +43,7 @@ export interface PlanningAgent extends BaseAgent { type: 'planning' createPlan: (goal: string) => Promise executePlan: (plan: Plan) => Promise - adjustPlan: (plan: Plan, feedback: string) => Promise + adjustPlan: (plan: Plan, feedback: string, sender: string) => Promise } export interface ChatAgent extends BaseAgent { diff --git a/services/minecraft/src/plugins/llm-agent.ts b/services/minecraft/src/plugins/llm-agent.ts index 3532d64b3..e05bde2f3 100644 --- a/services/minecraft/src/plugins/llm-agent.ts +++ b/services/minecraft/src/plugins/llm-agent.ts @@ -101,13 +101,13 @@ async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Ne bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) try { - // 创建并执行计划 + // Create and execute plan const plan = await bot.planning.createPlan(event.data.transcription) logger.withFields({ plan }).log('Plan created') await bot.planning.executePlan(plan) logger.log('Plan executed successfully') - // 生成回复 + // Generate response const retryHandler = toRetriable( 3, 1000, @@ -142,7 +142,7 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { async created(bot) { const logger = useLogg('LLMAgent').useGlobalConfig() - // 创建容器并获取所需的服务 + // Create container and get required services const container = createAppContainer({ neuri: options.agent, model: 'openai/gpt-4o-mini', @@ -154,21 +154,21 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { const planningAgent = container.resolve('planningAgent') const chatAgent = container.resolve('chatAgent') - // 初始化 agents + // Initialize agents await actionAgent.init() await planningAgent.init() await chatAgent.init() - // 类型转换 + // Type conversion const botWithAgents = bot as unknown as MineflayerWithAgents botWithAgents.action = actionAgent botWithAgents.planning = planningAgent botWithAgents.chat = chatAgent - // 初始化系统提示 + // Initialize system prompt bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) - // 设置消息处理 + // Set message handling const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => handleChatMessage(username, message, botWithAgents, options.agent, logger)) From 8a5795cac699721a76ef054f72ae780d8e2b7f1d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 28 Jan 2025 16:18:20 +0800 Subject: [PATCH 69/77] feat: global configurable model and reasoning model --- services/minecraft/src/agents/action/llm-handler.test.ts | 4 ++-- services/minecraft/src/agents/action/tools.test.ts | 6 +++--- services/minecraft/src/agents/chat/llm.ts | 3 ++- services/minecraft/src/composables/config.ts | 5 ++++- services/minecraft/src/plugins/llm-agent.ts | 5 +++-- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/services/minecraft/src/agents/action/llm-handler.test.ts b/services/minecraft/src/agents/action/llm-handler.test.ts index 56e9f6d28..8568e1033 100644 --- a/services/minecraft/src/agents/action/llm-handler.test.ts +++ b/services/minecraft/src/agents/action/llm-handler.test.ts @@ -2,7 +2,7 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { initBot, useBot } from '../../composables/bot' -import { botConfig, initEnv } from '../../composables/config' +import { botConfig, initEnv, openaiConfig } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' import { initLogger } from '../../utils/logger' import { generateSystemBasicPrompt } from '../prompt/llm-agent.plugin' @@ -26,7 +26,7 @@ describe('openAI agent', { timeout: 0 }, () => { user('Hello, who are you?'), ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + const completion = await c.reroute('query', c.messages, { model: openaiConfig.model }) return await completion?.firstContent() }, ) diff --git a/services/minecraft/src/agents/action/tools.test.ts b/services/minecraft/src/agents/action/tools.test.ts index a3aac8573..fe166a6e1 100644 --- a/services/minecraft/src/agents/action/tools.test.ts +++ b/services/minecraft/src/agents/action/tools.test.ts @@ -2,7 +2,7 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { initBot, useBot } from '../../composables/bot' -import { botConfig, initEnv } from '../../composables/config' +import { botConfig, initEnv, openaiConfig } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' import { sleep } from '../../utils/helper' import { initLogger } from '../../utils/logger' @@ -25,7 +25,7 @@ describe('actions agent', { timeout: 0 }, () => { system(generateActionAgentPrompt(bot)), user('What\'s your status?'), ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' }) + const completion = await c.reroute('query', c.messages, { model: openaiConfig.model }) return await completion?.firstContent() }) @@ -46,7 +46,7 @@ describe('actions agent', { timeout: 0 }, () => { system(generateActionAgentPrompt(bot)), user('goToPlayer: luoling8192'), ), async (c) => { - const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' }) + const completion = await c.reroute('action', c.messages, { model: openaiConfig.model }) return await completion?.firstContent() }) diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts index 6659db15c..0d775790c 100644 --- a/services/minecraft/src/agents/chat/llm.ts +++ b/services/minecraft/src/agents/chat/llm.ts @@ -5,6 +5,7 @@ import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' import { system, user } from 'neuri/openai' +import { openaiConfig } from '../../composables/config' import { toRetriable } from '../../utils/helper' import { genChatAgentPrompt } from '../prompt/chat' @@ -42,7 +43,7 @@ export async function generateChatResponse( const handleCompletion = async (c: any): Promise => { const completion = await c.reroute('chat', c.messages, { - model: config.model ?? 'openai/gpt-4o-mini', + model: config.model ?? openaiConfig.model, }) if (!completion || 'error' in completion) { diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 6076dc5f7..81c7ec330 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -10,6 +10,7 @@ interface OpenAIConfig { apiKey: string baseUrl: string model: string + reasoningModel: string } interface EnvConfig { @@ -22,7 +23,8 @@ const defaultConfig: EnvConfig = { openai: { apiKey: '', baseUrl: '', - model: 'openai/gpt-4o-mini', + model: '', + reasoningModel: '', }, bot: { username: '', @@ -46,6 +48,7 @@ export function initEnv(): void { apiKey: env.OPENAI_API_KEY || defaultConfig.openai.apiKey, baseUrl: env.OPENAI_API_BASEURL || defaultConfig.openai.baseUrl, model: env.OPENAI_MODEL || defaultConfig.openai.model, + reasoningModel: env.OPENAI_REASONING_MODEL || defaultConfig.openai.reasoningModel, }, bot: { username: env.BOT_USERNAME || defaultConfig.bot.username, diff --git a/services/minecraft/src/plugins/llm-agent.ts b/services/minecraft/src/plugins/llm-agent.ts index e05bde2f3..de727d52c 100644 --- a/services/minecraft/src/plugins/llm-agent.ts +++ b/services/minecraft/src/plugins/llm-agent.ts @@ -9,6 +9,7 @@ import { useLogg } from '@guiiai/logg' import { assistant, system, user } from 'neuri/openai' import { generateActionAgentPrompt, generateStatusPrompt } from '../agents/prompt/llm-agent.plugin' +import { openaiConfig } from '../composables/config' import { createAppContainer } from '../container' import { ChatMessageHandler } from '../libs/mineflayer/message' import { toRetriable } from '../utils/helper' @@ -28,7 +29,7 @@ async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAge logger.log('rerouting...') const completion = await context.reroute('action', context.messages, { - model: 'openai/gpt-4o-mini', + model: openaiConfig.model, }) as ChatCompletion | { error: { message: string } } & ChatCompletion if (!completion || 'error' in completion) { @@ -145,7 +146,7 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { // Create container and get required services const container = createAppContainer({ neuri: options.agent, - model: 'openai/gpt-4o-mini', + model: openaiConfig.model, maxHistoryLength: 50, idleTimeout: 5 * 60 * 1000, }) From 946cbb4c9dd93119d89c75515fe5736ddd6d3d16 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 28 Jan 2025 16:30:33 +0800 Subject: [PATCH 70/77] refactor: split llm-agent --- services/minecraft/src/manager/action.ts | 203 ---------- .../minecraft/src/manager/conversation.ts | 382 ------------------ services/minecraft/src/plugins/llm-agent.ts | 190 --------- .../minecraft/src/plugins/llm-agent/chat.ts | 53 +++ .../src/plugins/llm-agent/completion.ts | 27 ++ .../minecraft/src/plugins/llm-agent/index.ts | 62 +++ .../minecraft/src/plugins/llm-agent/type.ts | 15 + .../minecraft/src/plugins/llm-agent/voice.ts | 58 +++ 8 files changed, 215 insertions(+), 775 deletions(-) delete mode 100644 services/minecraft/src/manager/action.ts delete mode 100644 services/minecraft/src/manager/conversation.ts delete mode 100644 services/minecraft/src/plugins/llm-agent.ts create mode 100644 services/minecraft/src/plugins/llm-agent/chat.ts create mode 100644 services/minecraft/src/plugins/llm-agent/completion.ts create mode 100644 services/minecraft/src/plugins/llm-agent/index.ts create mode 100644 services/minecraft/src/plugins/llm-agent/type.ts create mode 100644 services/minecraft/src/plugins/llm-agent/voice.ts diff --git a/services/minecraft/src/manager/action.ts b/services/minecraft/src/manager/action.ts deleted file mode 100644 index 7f78c0bf5..000000000 --- a/services/minecraft/src/manager/action.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { Mineflayer } from '../libs/mineflayer/core' - -import { useLogg } from '@guiiai/logg' -import EventEmitter from 'eventemitter3' - -// Types and interfaces -type ActionFn = (...args: any[]) => void - -interface ActionResult { - success: boolean - message: string | null - timedout: boolean -} - -interface QueuedAction { - label: string - fn: ActionFn - timeout: number - resume: boolean - resolve: (result: ActionResult) => void - reject: (error: Error) => void -} - -export class ActionManager extends EventEmitter { - private state = { - executing: false, - currentActionLabel: '', - currentActionFn: undefined as ActionFn | undefined, - timedout: false, - resume: { - func: undefined as ActionFn | undefined, - name: undefined as string | undefined, - }, - } - - // Action queue to store pending actions - private actionQueue: QueuedAction[] = [] - - private logger = useLogg('ActionManager').useGlobalConfig() - private mineflayer: Mineflayer - - constructor(mineflayer: Mineflayer) { - super() - this.mineflayer = mineflayer - } - - public async resumeAction(actionLabel: string, actionFn: ActionFn, timeout: number): Promise { - return this.queueAction({ - label: actionLabel, - fn: actionFn, - timeout, - resume: true, - }) - } - - public async runAction( - actionLabel: string, - actionFn: ActionFn, - options: { timeout: number, resume: boolean } = { timeout: 10, resume: false }, - ): Promise { - return this.queueAction({ - label: actionLabel, - fn: actionFn, - timeout: options.timeout, - resume: options.resume, - }) - } - - public async stop(): Promise { - this.mineflayer.emit('interrupt') - // Clear the action queue when stopping - this.actionQueue = [] - } - - public cancelResume(): void { - this.state.resume.func = undefined - this.state.resume.name = undefined - } - - private async queueAction(action: Omit): Promise { - return new Promise((resolve, reject) => { - this.actionQueue.push({ - ...action, - resolve, - reject, - }) - - if (!this.state.executing) { - this.processQueue().catch(reject) - } - }) - } - - private async processQueue(): Promise { - while (this.actionQueue.length > 0) { - const action = this.actionQueue[0] - - try { - const result = action.resume - ? await this.executeResume(action.label, action.fn, action.timeout) - : await this.executeAction(action.label, action.fn, action.timeout) - - this.actionQueue.shift()?.resolve(result) - - if (!result.success) { - this.actionQueue.forEach(pendingAction => - pendingAction.reject(new Error('Queue cleared due to action failure')), - ) - this.actionQueue = [] - return result - } - } - catch (error) { - this.actionQueue.shift()?.reject(error as Error) - this.actionQueue.forEach(pendingAction => - pendingAction.reject(new Error('Queue cleared due to error')), - ) - this.actionQueue = [] - throw error - } - } - - return { success: true, message: 'success', timedout: false } - } - - private async executeResume(actionLabel?: string, actionFn?: ActionFn, timeout = 10): Promise { - const isNewResume = actionFn != null - - if (isNewResume) { - if (!actionLabel) { - throw new Error('actionLabel is required for new resume') - } - this.state.resume.func = actionFn - this.state.resume.name = actionLabel - } - - const canExecute = this.state.resume.func != null && isNewResume - - if (!canExecute) { - return { success: false, message: null, timedout: false } - } - - this.state.currentActionLabel = this.state.resume.name || '' - const result = await this.executeAction(this.state.resume.name || '', this.state.resume.func, timeout) - this.state.currentActionLabel = '' - return result - } - - private async executeAction(actionLabel: string, actionFn?: ActionFn, timeout = 10): Promise { - let timeoutHandle: NodeJS.Timeout | undefined - - try { - this.logger.log('executing action...\n') - - if (this.state.executing) { - this.logger.log(`action "${actionLabel}" trying to interrupt current action "${this.state.currentActionLabel}"`) - } - - await this.stop() - - // Set execution state - this.state.executing = true - this.state.currentActionLabel = actionLabel - this.state.currentActionFn = actionFn - - if (timeout > 0) { - timeoutHandle = this.startTimeout(timeout) - } - - await actionFn?.() - - // Reset state after successful execution - this.resetExecutionState(timeoutHandle) - - return { success: true, message: 'success', timedout: false } - } - catch (err) { - this.resetExecutionState(timeoutHandle) - this.cancelResume() - this.logger.withError(err).error('Code execution triggered catch') - await this.stop() - - return { success: false, message: 'failed', timedout: false } - } - } - - private resetExecutionState(timeoutHandle?: NodeJS.Timeout): void { - this.state.executing = false - this.state.currentActionLabel = '' - this.state.currentActionFn = undefined - if (timeoutHandle) - clearTimeout(timeoutHandle) - } - - private startTimeout(timeoutMins = 10): NodeJS.Timeout { - return setTimeout(async () => { - this.logger.warn(`Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`) - this.state.timedout = true - this.emit('timeout', `Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`) - await this.stop() - }, timeoutMins * 60 * 1000) - } -} diff --git a/services/minecraft/src/manager/conversation.ts b/services/minecraft/src/manager/conversation.ts deleted file mode 100644 index 891fb7afe..000000000 --- a/services/minecraft/src/manager/conversation.ts +++ /dev/null @@ -1,382 +0,0 @@ -// import { useLogg } from '@guiiai/logg' - -// let self_prompter_paused = false - -// interface ConversationMessage { -// message: string -// start: boolean -// end: boolean -// } - -// function compileInMessages(inQueue: ConversationMessage[]) { -// let pack: ConversationMessage | undefined -// let fullMessage = '' -// while (inQueue.length > 0) { -// pack = inQueue.shift() -// if (!pack) -// continue - -// fullMessage += pack.message -// } -// if (pack) { -// pack.message = fullMessage -// } - -// return pack -// } - -// type Conversation = ReturnType - -// function useConversations(name: string, agent: Agent) { -// const active = { value: false } -// const ignoreUntilStart = { value: false } -// const blocked = { value: false } -// let inQueue: ConversationMessage[] = [] -// const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined } - -// function reset() { -// active.value = false -// ignoreUntilStart.value = false -// inQueue = [] -// } - -// function end() { -// active.value = false -// ignoreUntilStart.value = true -// const fullMessage = compileInMessages(inQueue) -// if (!fullMessage) -// return - -// if (fullMessage.message.trim().length > 0) { -// agent.history.add(name, fullMessage.message) -// } - -// if (agent.lastSender === name) { -// agent.lastSender = undefined -// } -// } - -// function queue(message: ConversationMessage) { -// inQueue.push(message) -// } - -// return { -// reset, -// end, -// queue, -// name, -// inMessageTimer, -// blocked, -// active, -// ignoreUntilStart, -// inQueue, -// } -// } - -// const WAIT_TIME_START = 30000 - -// export type ConversationStore = ReturnType - -// export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) { -// const conversations: Record = {} -// const activeConversation: { value: Conversation | undefined } = { value: undefined } -// const awaitingResponse = { value: false } -// const waitTimeLimit = { value: WAIT_TIME_START } -// const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined } -// const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined } -// const agent = options.agent -// let agentsInGame = options.agentNames || [] -// const log = useLogg('ConversationStore').useGlobalConfig() - -// const conversationStore = { -// getConvo: (name: string) => { -// if (!conversations[name]) -// conversations[name] = useConversations(name, agent) -// return conversations[name] -// }, -// startMonitor: () => { -// clearInterval(connectionMonitor.value) -// let waitTime = 0 -// let lastTime = Date.now() -// connectionMonitor.value = setInterval(() => { -// if (!activeConversation.value) { -// conversationStore.stopMonitor() -// return // will clean itself up -// } - -// const delta = Date.now() - lastTime -// lastTime = Date.now() -// const convo_partner = activeConversation.value.name - -// if (awaitingResponse.value && agent.isIdle()) { -// waitTime += delta -// if (waitTime > waitTimeLimit.value) { -// agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`) -// waitTime = 0 -// waitTimeLimit.value *= 2 -// } -// } -// else if (!awaitingResponse.value) { -// waitTimeLimit.value = WAIT_TIME_START -// waitTime = 0 -// } - -// if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) { -// connectionTimeout.value = setTimeout(() => { -// if (conversationStore.otherAgentInGame(convo_partner)) { -// conversationStore.clearMonitorTimeouts() -// return -// } -// if (!self_prompter_paused) { -// conversationStore.endConversation(convo_partner) -// agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`) -// } -// else { -// conversationStore.endConversation(convo_partner) -// } -// }, 10000) -// } -// }, 1000) -// }, -// stopMonitor: () => { -// clearInterval(connectionMonitor.value) -// connectionMonitor.value = undefined -// conversationStore.clearMonitorTimeouts() -// }, -// clearMonitorTimeouts: () => { -// awaitingResponse.value = false -// clearTimeout(connectionTimeout.value) -// connectionTimeout.value = undefined -// }, -// startConversation: (send_to: string, message: string) => { -// const convo = conversationStore.getConvo(send_to) -// convo.reset() - -// if (agent.self_prompter.on) { -// agent.self_prompter.stop() -// self_prompter_paused = true -// } -// if (convo.active.value) -// return - -// convo.active.value = true -// activeConversation.value = convo -// conversationStore.startMonitor() -// conversationStore.sendToBot(send_to, message, true, false) -// }, -// startConversationFromOtherBot: (name: string) => { -// const convo = conversationStore.getConvo(name) -// convo.active.value = true -// activeConversation.value = convo -// conversationStore.startMonitor() -// }, -// sendToBot: (send_to: string, message: string, start = false, open_chat = true) => { -// if (!conversationStore.isOtherAgent(send_to)) { -// console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`) -// return -// } -// const convo = conversationStore.getConvo(send_to) - -// if (options.chatBotMessages && open_chat) -// agent.openChat(`(To ${send_to}) ${message}`) - -// if (convo.ignoreUntilStart.value) -// return -// convo.active.value = true - -// const end = message.includes('!endConversation') -// const json = { -// message, -// start, -// end, -// } - -// awaitingResponse.value = true -// // TODO: -// // sendBotChatToServer(send_to, json) -// log.withField('json', json).log(`Sending message to ${send_to}`) -// }, -// receiveFromBot: async (sender: string, received: ConversationMessage) => { -// const convo = conversationStore.getConvo(sender) - -// if (convo.ignoreUntilStart.value && !received.start) -// return - -// // check if any convo is active besides the sender -// if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) { -// conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false) -// conversationStore.endConversation(sender) -// return -// } - -// if (received.start) { -// convo.reset() -// conversationStore.startConversationFromOtherBot(sender) -// } - -// conversationStore.clearMonitorTimeouts() -// convo.queue(received) - -// // responding to conversation takes priority over self prompting -// if (agent.self_prompter.on) { -// await agent.self_prompter.stopLoop() -// self_prompter_paused = true -// } - -// _scheduleProcessInMessage(agent, conversationStore, sender, received, convo) -// }, -// responseScheduledFor: (sender: string) => { -// if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender)) -// return false -// const convo = conversationStore.getConvo(sender) -// return !!convo.inMessageTimer -// }, -// isOtherAgent: (name: string) => { -// return !!options.agentNames?.includes(name) -// }, -// otherAgentInGame: (name: string) => { -// return agentsInGame.includes(name) -// }, -// updateAgents: (agents: Agent[]) => { -// options.agentNames = agents.map(a => a.name) -// agentsInGame = agents.filter(a => a.in_game).map(a => a.name) -// }, -// getInGameAgents: () => { -// return agentsInGame -// }, -// inConversation: (other_agent?: string) => { -// if (other_agent) -// return conversations[other_agent]?.active -// return Object.values(conversations).some(c => c.active) -// }, -// endConversation: (sender: string) => { -// if (conversations[sender]) { -// conversations[sender].end() -// if (activeConversation.value?.name === sender) { -// conversationStore.stopMonitor() -// activeConversation.value = undefined -// if (self_prompter_paused && !conversationStore.inConversation()) { -// _resumeSelfPrompter(agent, conversationStore) -// } -// } -// } -// }, -// endAllConversations: () => { -// for (const sender in conversations) { -// conversationStore.endConversation(sender) -// } -// if (self_prompter_paused) { -// _resumeSelfPrompter(agent, conversationStore) -// } -// }, -// forceEndCurrentConversation: () => { -// if (activeConversation.value) { -// const sender = activeConversation.value.name -// conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false) -// conversationStore.endConversation(sender) -// } -// }, -// scheduleSelfPrompter: () => { -// self_prompter_paused = true -// }, -// cancelSelfPrompter: () => { -// self_prompter_paused = false -// }, -// } - -// return conversationStore -// } - -// function containsCommand(message: string) { -// // TODO: mock -// return message -// } - -// /* -// This function controls conversation flow by deciding when the bot responds. -// The logic is as follows: -// - If neither bot is busy, respond quickly with a small delay. -// - If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory) -// - If I'm busy but other bot isn't, let LLM decide whether to respond -// - If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses -// - New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk -// */ -// const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions -// const fastDelay = 200 -// const longDelay = 5000 - -// async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) { -// if (convo.inMessageTimer) -// clearTimeout(convo.inMessageTimer.value) -// const otherAgentBusy = containsCommand(received.message) - -// const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay) - -// if (!agent.isIdle() && otherAgentBusy) { -// // both are busy -// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) -// if (canTalkOver) -// scheduleResponse(fastDelay) -// // otherwise don't respond -// } -// else if (otherAgentBusy) { -// // other bot is busy but I'm not -// scheduleResponse(longDelay) -// } -// else if (!agent.isIdle()) { -// // I'm busy but other bot isn't -// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)) -// if (canTalkOver) { -// scheduleResponse(fastDelay) -// } -// else { -// const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message) -// useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`) -// if (shouldRespond) -// scheduleResponse(fastDelay) -// } -// } -// else { -// // neither are busy -// scheduleResponse(fastDelay) -// } -// } - -// function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) { -// const convo = conversationStore.getConvo(name) -// _handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue)) -// } - -// function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) { -// if (!received) -// return - -// useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`) - -// const convo = conversationStore.getConvo(sender) -// convo.active.value = true - -// let message = _tagMessage(received.message) -// if (received.end) { -// conversationStore.endConversation(sender) -// message = `Conversation with ${sender} ended with message: "${message}"` -// sender = 'system' // bot will respond to system instead of the other bot -// } -// else if (received.start) { -// agent.shut_up = false -// } -// convo.inMessageTimer.value = undefined -// agent.handleMessage(sender, message) -// } - -// function _tagMessage(message: string) { -// return `(FROM OTHER BOT)${message}` -// } - -// async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) { -// await new Promise(resolve => setTimeout(resolve, 5000)) -// if (self_prompter_paused && !conversationStore.inConversation()) { -// self_prompter_paused = false -// agent.self_prompter.start() -// } -// } diff --git a/services/minecraft/src/plugins/llm-agent.ts b/services/minecraft/src/plugins/llm-agent.ts deleted file mode 100644 index de727d52c..000000000 --- a/services/minecraft/src/plugins/llm-agent.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Client } from '@proj-airi/server-sdk' -import type { Neuri, NeuriContext } from 'neuri' -import type { ChatCompletion } from 'neuri/openai' -import type { Mineflayer } from '../libs/mineflayer' -import type { ActionAgent, ChatAgent, PlanningAgent } from '../libs/mineflayer/base-agent' -import type { MineflayerPlugin } from '../libs/mineflayer/plugin' - -import { useLogg } from '@guiiai/logg' -import { assistant, system, user } from 'neuri/openai' - -import { generateActionAgentPrompt, generateStatusPrompt } from '../agents/prompt/llm-agent.plugin' -import { openaiConfig } from '../composables/config' -import { createAppContainer } from '../container' -import { ChatMessageHandler } from '../libs/mineflayer/message' -import { toRetriable } from '../utils/helper' - -interface MineflayerWithAgents extends Mineflayer { - planning: PlanningAgent - action: ActionAgent - chat: ChatAgent -} - -interface LLMAgentOptions { - agent: Neuri - airiClient: Client -} - -async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType): Promise { - logger.log('rerouting...') - - const completion = await context.reroute('action', context.messages, { - model: openaiConfig.model, - }) as ChatCompletion | { error: { message: string } } & ChatCompletion - - if (!completion || 'error' in completion) { - logger.withFields({ completion }).error('Completion') - logger.withFields({ messages: context.messages }).log('messages') - return completion?.error?.message ?? 'Unknown error' - } - - const content = await completion.firstContent() - logger.withFields({ usage: completion.usage, content }).log('output') - - bot.memory.chatHistory.push(assistant(content)) - return content -} - -async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { - logger.withFields({ username, message }).log('Chat message received') - bot.memory.chatHistory.push(user(`${username}: ${message}`)) - - logger.log('thinking...') - - try { - // Create and execute plan - const plan = await bot.planning.createPlan(message) - logger.withFields({ plan }).log('Plan created') - await bot.planning.executePlan(plan) - logger.log('Plan executed successfully') - - // Generate response - // TODO: use chat agent and conversion manager - const statusPrompt = await generateStatusPrompt(bot) - const content = await agent.handleStateless( - [...bot.memory.chatHistory, system(statusPrompt)], - async (c: NeuriContext) => { - logger.log('handling response...') - return toRetriable( - 3, - 1000, - ctx => handleLLMCompletion(ctx, bot, logger), - { onError: err => logger.withError(err).log('error occurred') }, - )(c) - }, - ) - - if (content) { - logger.withFields({ content }).log('responded') - bot.bot.chat(content) - } - } - catch (error) { - logger.withError(error).error('Failed to process message') - bot.bot.chat( - `Sorry, I encountered an error: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ) - } -} - -async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { - logger - .withFields({ - user: event.data.discord?.guildMember, - message: event.data.transcription, - }) - .log('Chat message received') - - const statusPrompt = await generateStatusPrompt(bot) - bot.memory.chatHistory.push(system(statusPrompt)) - bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) - - try { - // Create and execute plan - const plan = await bot.planning.createPlan(event.data.transcription) - logger.withFields({ plan }).log('Plan created') - await bot.planning.executePlan(plan) - logger.log('Plan executed successfully') - - // Generate response - const retryHandler = toRetriable( - 3, - 1000, - ctx => handleLLMCompletion(ctx, bot, logger), - ) - - const content = await agent.handleStateless( - [...bot.memory.chatHistory, system(statusPrompt)], - async (c: NeuriContext) => { - logger.log('thinking...') - return retryHandler(c) - }, - ) - - if (content) { - logger.withFields({ content }).log('responded') - bot.bot.chat(content) - } - } - catch (error) { - logger.withError(error).error('Failed to process message') - bot.bot.chat( - `Sorry, I encountered an error: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ) - } -} - -export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { - return { - async created(bot) { - const logger = useLogg('LLMAgent').useGlobalConfig() - - // Create container and get required services - const container = createAppContainer({ - neuri: options.agent, - model: openaiConfig.model, - maxHistoryLength: 50, - idleTimeout: 5 * 60 * 1000, - }) - - const actionAgent = container.resolve('actionAgent') - const planningAgent = container.resolve('planningAgent') - const chatAgent = container.resolve('chatAgent') - - // Initialize agents - await actionAgent.init() - await planningAgent.init() - await chatAgent.init() - - // Type conversion - const botWithAgents = bot as unknown as MineflayerWithAgents - botWithAgents.action = actionAgent - botWithAgents.planning = planningAgent - botWithAgents.chat = chatAgent - - // Initialize system prompt - bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) - - // Set message handling - const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => - handleChatMessage(username, message, botWithAgents, options.agent, logger)) - - options.airiClient.onEvent('input:text:voice', event => - handleVoiceInput(event, botWithAgents, options.agent, logger)) - - bot.bot.on('chat', onChat) - }, - - async beforeCleanup(bot) { - const botWithAgents = bot as unknown as MineflayerWithAgents - await botWithAgents.action?.destroy() - await botWithAgents.planning?.destroy() - await botWithAgents.chat?.destroy() - bot.bot.removeAllListeners('chat') - }, - } -} diff --git a/services/minecraft/src/plugins/llm-agent/chat.ts b/services/minecraft/src/plugins/llm-agent/chat.ts new file mode 100644 index 000000000..e993e1b7c --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent/chat.ts @@ -0,0 +1,53 @@ +import type { useLogg } from '@guiiai/logg' +import type { Neuri, NeuriContext } from 'neuri' +import type { MineflayerWithAgents } from './type' + +import { system, user } from 'neuri/openai' + +import { generateStatusPrompt } from '../../agents/prompt/llm-agent.plugin' +import { toRetriable } from '../../utils/helper' +import { handleLLMCompletion } from './completion' + +export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { + logger.withFields({ username, message }).log('Chat message received') + bot.memory.chatHistory.push(user(`${username}: ${message}`)) + + logger.log('thinking...') + + try { + // Create and execute plan + const plan = await bot.planning.createPlan(message) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // Generate response + // TODO: use chat agent and conversion manager + const statusPrompt = await generateStatusPrompt(bot) + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('handling response...') + return toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + { onError: err => logger.withError(err).log('error occurred') }, + )(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} diff --git a/services/minecraft/src/plugins/llm-agent/completion.ts b/services/minecraft/src/plugins/llm-agent/completion.ts new file mode 100644 index 000000000..d7f7e1036 --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent/completion.ts @@ -0,0 +1,27 @@ +import type { useLogg } from '@guiiai/logg' +import type { NeuriContext } from 'neuri' +import type { MineflayerWithAgents } from './type' + +import { assistant, type ChatCompletion } from 'neuri/openai' + +import { openaiConfig } from '../../composables/config' + +export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType): Promise { + logger.log('rerouting...') + + const completion = await context.reroute('action', context.messages, { + model: openaiConfig.model, + }) as ChatCompletion | { error: { message: string } } & ChatCompletion + + if (!completion || 'error' in completion) { + logger.withFields({ completion }).error('Completion') + logger.withFields({ messages: context.messages }).log('messages') + return completion?.error?.message ?? 'Unknown error' + } + + const content = await completion.firstContent() + logger.withFields({ usage: completion.usage, content }).log('output') + + bot.memory.chatHistory.push(assistant(content)) + return content +} diff --git a/services/minecraft/src/plugins/llm-agent/index.ts b/services/minecraft/src/plugins/llm-agent/index.ts new file mode 100644 index 000000000..f6918b5a1 --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent/index.ts @@ -0,0 +1,62 @@ +import type { LLMAgentOptions, MineflayerWithAgents } from './type' + +import { useLogg } from '@guiiai/logg' +import { system } from 'neuri/openai' + +import { generateActionAgentPrompt } from '../../agents/prompt/llm-agent.plugin' +import { openaiConfig } from '../../composables/config' +import { createAppContainer } from '../../container' +import { ChatMessageHandler, type MineflayerPlugin } from '../../libs/mineflayer' +import { handleChatMessage } from './chat' +import { handleVoiceInput } from './voice' + +export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { + return { + async created(bot) { + const logger = useLogg('LLMAgent').useGlobalConfig() + + // Create container and get required services + const container = createAppContainer({ + neuri: options.agent, + model: openaiConfig.model, + maxHistoryLength: 50, + idleTimeout: 5 * 60 * 1000, + }) + + const actionAgent = container.resolve('actionAgent') + const planningAgent = container.resolve('planningAgent') + const chatAgent = container.resolve('chatAgent') + + // Initialize agents + await actionAgent.init() + await planningAgent.init() + await chatAgent.init() + + // Type conversion + const botWithAgents = bot as unknown as MineflayerWithAgents + botWithAgents.action = actionAgent + botWithAgents.planning = planningAgent + botWithAgents.chat = chatAgent + + // Initialize system prompt + bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot))) + + // Set message handling + const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) => + handleChatMessage(username, message, botWithAgents, options.agent, logger)) + + options.airiClient.onEvent('input:text:voice', event => + handleVoiceInput(event, botWithAgents, options.agent, logger)) + + bot.bot.on('chat', onChat) + }, + + async beforeCleanup(bot) { + const botWithAgents = bot as unknown as MineflayerWithAgents + await botWithAgents.action?.destroy() + await botWithAgents.planning?.destroy() + await botWithAgents.chat?.destroy() + bot.bot.removeAllListeners('chat') + }, + } +} diff --git a/services/minecraft/src/plugins/llm-agent/type.ts b/services/minecraft/src/plugins/llm-agent/type.ts new file mode 100644 index 000000000..d5444a130 --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent/type.ts @@ -0,0 +1,15 @@ +import type { Client } from '@proj-airi/server-sdk' +import type { Neuri } from 'neuri' +import type { Mineflayer } from '../../libs/mineflayer' +import type { ActionAgent, ChatAgent, PlanningAgent } from '../../libs/mineflayer/base-agent' + +export interface MineflayerWithAgents extends Mineflayer { + planning: PlanningAgent + action: ActionAgent + chat: ChatAgent +} + +export interface LLMAgentOptions { + agent: Neuri + airiClient: Client +} diff --git a/services/minecraft/src/plugins/llm-agent/voice.ts b/services/minecraft/src/plugins/llm-agent/voice.ts new file mode 100644 index 000000000..ff1ad5010 --- /dev/null +++ b/services/minecraft/src/plugins/llm-agent/voice.ts @@ -0,0 +1,58 @@ +import type { useLogg } from '@guiiai/logg' +import type { Neuri, NeuriContext } from 'neuri' +import type { MineflayerWithAgents } from './type' + +import { system, user } from 'neuri/openai' + +import { generateStatusPrompt } from '../../agents/prompt/llm-agent.plugin' +import { toRetriable } from '../../utils/helper' +import { handleLLMCompletion } from './completion' + +export async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { + logger + .withFields({ + user: event.data.discord?.guildMember, + message: event.data.transcription, + }) + .log('Chat message received') + + const statusPrompt = await generateStatusPrompt(bot) + bot.memory.chatHistory.push(system(statusPrompt)) + bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`)) + + try { + // Create and execute plan + const plan = await bot.planning.createPlan(event.data.transcription) + logger.withFields({ plan }).log('Plan created') + await bot.planning.executePlan(plan) + logger.log('Plan executed successfully') + + // Generate response + const retryHandler = toRetriable( + 3, + 1000, + ctx => handleLLMCompletion(ctx, bot, logger), + ) + + const content = await agent.handleStateless( + [...bot.memory.chatHistory, system(statusPrompt)], + async (c: NeuriContext) => { + logger.log('thinking...') + return retryHandler(c) + }, + ) + + if (content) { + logger.withFields({ content }).log('responded') + bot.bot.chat(content) + } + } + catch (error) { + logger.withError(error).error('Failed to process message') + bot.bot.chat( + `Sorry, I encountered an error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } +} From 0a7e1b9bf4bcfc4ee9af9182c0878d6b18ecea2f Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Mon, 3 Feb 2025 15:37:12 +0700 Subject: [PATCH 71/77] chore: bump deps --- services/minecraft/src/agents/planning/index.ts | 3 ++- services/minecraft/src/composables/bot.ts | 4 +++- services/minecraft/src/libs/mineflayer/components.ts | 3 ++- services/minecraft/src/libs/mineflayer/core.ts | 6 ++++-- services/minecraft/src/plugins/llm-agent/completion.ts | 3 ++- services/minecraft/src/plugins/llm-agent/index.ts | 3 ++- services/minecraft/src/skills/blocks.ts | 3 ++- services/minecraft/src/utils/mcdata.ts | 7 ++----- 8 files changed, 19 insertions(+), 13 deletions(-) diff --git a/services/minecraft/src/agents/planning/index.ts b/services/minecraft/src/agents/planning/index.ts index 61d1c7127..110e1ea19 100644 --- a/services/minecraft/src/agents/planning/index.ts +++ b/services/minecraft/src/agents/planning/index.ts @@ -1,10 +1,11 @@ import type { Neuri } from 'neuri' import type { Action } from '../../libs/mineflayer/action' import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from '../../libs/mineflayer/base-agent' +import type { PlanStep } from './llm-handler' import { AbstractAgent } from '../../libs/mineflayer/base-agent' import { ActionAgentImpl } from '../action' -import { PlanningLLMHandler, type PlanStep } from './llm-handler' +import { PlanningLLMHandler } from './llm-handler' interface PlanContext { goal: string diff --git a/services/minecraft/src/composables/bot.ts b/services/minecraft/src/composables/bot.ts index 5a513aae3..2e319acf0 100644 --- a/services/minecraft/src/composables/bot.ts +++ b/services/minecraft/src/composables/bot.ts @@ -1,4 +1,6 @@ -import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer' +import type { MineflayerOptions } from '../libs/mineflayer' + +import { Mineflayer } from '../libs/mineflayer' // Singleton instance of the Mineflayer bot let botInstance: Mineflayer | null = null diff --git a/services/minecraft/src/libs/mineflayer/components.ts b/services/minecraft/src/libs/mineflayer/components.ts index f04470dfd..cf6b0631b 100644 --- a/services/minecraft/src/libs/mineflayer/components.ts +++ b/services/minecraft/src/libs/mineflayer/components.ts @@ -1,6 +1,7 @@ +import type { Logg } from '@guiiai/logg' import type { Handler } from './types' -import { type Logg, useLogg } from '@guiiai/logg' +import { useLogg } from '@guiiai/logg' export class Components { private components: Map = new Map() diff --git a/services/minecraft/src/libs/mineflayer/core.ts b/services/minecraft/src/libs/mineflayer/core.ts index 6e019a20f..f0c14c21f 100644 --- a/services/minecraft/src/libs/mineflayer/core.ts +++ b/services/minecraft/src/libs/mineflayer/core.ts @@ -1,8 +1,10 @@ +import type { Logg } from '@guiiai/logg' import type { Bot, BotOptions } from 'mineflayer' import type { MineflayerPlugin } from './plugin' +import type { TickEvents, TickEventsHandler } from './ticker' import type { EventHandlers, EventsHandler } from './types' -import { type Logg, useLogg } from '@guiiai/logg' +import { useLogg } from '@guiiai/logg' import EventEmitter from 'eventemitter3' import mineflayer from 'mineflayer' @@ -12,7 +14,7 @@ import { Health } from './health' import { Memory } from './memory' import { ChatMessageHandler } from './message' import { Status } from './status' -import { Ticker, type TickEvents, type TickEventsHandler } from './ticker' +import { Ticker } from './ticker' export interface MineflayerOptions { botConfig: BotOptions diff --git a/services/minecraft/src/plugins/llm-agent/completion.ts b/services/minecraft/src/plugins/llm-agent/completion.ts index d7f7e1036..12a621dca 100644 --- a/services/minecraft/src/plugins/llm-agent/completion.ts +++ b/services/minecraft/src/plugins/llm-agent/completion.ts @@ -1,8 +1,9 @@ import type { useLogg } from '@guiiai/logg' import type { NeuriContext } from 'neuri' +import type { ChatCompletion } from 'neuri/openai' import type { MineflayerWithAgents } from './type' -import { assistant, type ChatCompletion } from 'neuri/openai' +import { assistant } from 'neuri/openai' import { openaiConfig } from '../../composables/config' diff --git a/services/minecraft/src/plugins/llm-agent/index.ts b/services/minecraft/src/plugins/llm-agent/index.ts index f6918b5a1..4aaccfd6d 100644 --- a/services/minecraft/src/plugins/llm-agent/index.ts +++ b/services/minecraft/src/plugins/llm-agent/index.ts @@ -1,3 +1,4 @@ +import type { MineflayerPlugin } from '../../libs/mineflayer' import type { LLMAgentOptions, MineflayerWithAgents } from './type' import { useLogg } from '@guiiai/logg' @@ -6,7 +7,7 @@ import { system } from 'neuri/openai' import { generateActionAgentPrompt } from '../../agents/prompt/llm-agent.plugin' import { openaiConfig } from '../../composables/config' import { createAppContainer } from '../../container' -import { ChatMessageHandler, type MineflayerPlugin } from '../../libs/mineflayer' +import { ChatMessageHandler } from '../../libs/mineflayer' import { handleChatMessage } from './chat' import { handleVoiceInput } from './voice' diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 383c49bde..35a928c64 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -1,7 +1,8 @@ +import type { SafeBlock } from 'mineflayer-pathfinder' import type { Mineflayer } from '../libs/mineflayer' import type { BlockFace } from './base' -import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder' +import pathfinderModel from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import { getBlockId, makeItem } from '../utils/mcdata' diff --git a/services/minecraft/src/utils/mcdata.ts b/services/minecraft/src/utils/mcdata.ts index 48f093fa8..8a18ba401 100644 --- a/services/minecraft/src/utils/mcdata.ts +++ b/services/minecraft/src/utils/mcdata.ts @@ -1,11 +1,8 @@ +import type { Biome, ShapedRecipe, ShapelessRecipe } from 'minecraft-data' import type { Bot } from 'mineflayer' import type { Entity } from 'prismarine-entity' -import minecraftData, { - type Biome, - type ShapedRecipe, - type ShapelessRecipe, -} from 'minecraft-data' +import minecraftData from 'minecraft-data' import prismarineItem from 'prismarine-item' const GAME_VERSION = '1.20' From 2171483b43d5ecf0e17263048618d353e2b7decf Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:05:46 +0800 Subject: [PATCH 72/77] chore: remove unused config --- services/minecraft/src/container.ts | 4 ---- services/minecraft/src/plugins/llm-agent/index.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/services/minecraft/src/container.ts b/services/minecraft/src/container.ts index bd8c6e8a6..97fae1116 100644 --- a/services/minecraft/src/container.ts +++ b/services/minecraft/src/container.ts @@ -18,8 +18,6 @@ export interface ContainerServices { export function createAppContainer(options: { neuri: Neuri model?: string - maxHistoryLength?: number - idleTimeout?: number }) { const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -62,8 +60,6 @@ export function createAppContainer(options: { agent: options.neuri, model: options.model, }, - maxHistoryLength: options.maxHistoryLength, - idleTimeout: options.idleTimeout, })), }) diff --git a/services/minecraft/src/plugins/llm-agent/index.ts b/services/minecraft/src/plugins/llm-agent/index.ts index 4aaccfd6d..ae2fb7bd6 100644 --- a/services/minecraft/src/plugins/llm-agent/index.ts +++ b/services/minecraft/src/plugins/llm-agent/index.ts @@ -20,8 +20,6 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { const container = createAppContainer({ neuri: options.agent, model: openaiConfig.model, - maxHistoryLength: 50, - idleTimeout: 5 * 60 * 1000, }) const actionAgent = container.resolve('actionAgent') From 31988e6964854284ce07400f79f5c38f5a182cbf Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:11:24 +0800 Subject: [PATCH 73/77] refactor: move llm-agent to lib folder --- services/minecraft/src/agents/action/llm-handler.test.ts | 2 +- services/minecraft/src/agents/action/llm-handler.ts | 2 +- services/minecraft/src/agents/action/tools.test.ts | 2 +- services/minecraft/src/agents/chat/llm-handler.ts | 2 +- services/minecraft/src/agents/planning/llm-handler.ts | 2 +- .../src/agents/prompt/{llm-agent.plugin.ts => llm-agent.ts} | 0 services/minecraft/src/container.ts | 2 ++ services/minecraft/src/{plugins => libs}/llm-agent/chat.ts | 2 +- .../minecraft/src/{plugins => libs}/llm-agent/completion.ts | 0 .../src/libs/{llm/base.ts => llm-agent/handler.ts} | 0 services/minecraft/src/{plugins => libs}/llm-agent/index.ts | 6 +++--- services/minecraft/src/{plugins => libs}/llm-agent/type.ts | 4 ++-- services/minecraft/src/libs/{llm => llm-agent}/types.ts | 0 services/minecraft/src/{plugins => libs}/llm-agent/voice.ts | 2 +- services/minecraft/src/main.ts | 2 +- 15 files changed, 15 insertions(+), 13 deletions(-) rename services/minecraft/src/agents/prompt/{llm-agent.plugin.ts => llm-agent.ts} (100%) rename services/minecraft/src/{plugins => libs}/llm-agent/chat.ts (99%) rename services/minecraft/src/{plugins => libs}/llm-agent/completion.ts (100%) rename services/minecraft/src/libs/{llm/base.ts => llm-agent/handler.ts} (100%) rename services/minecraft/src/{plugins => libs}/llm-agent/index.ts (93%) rename services/minecraft/src/{plugins => libs}/llm-agent/type.ts (65%) rename services/minecraft/src/libs/{llm => llm-agent}/types.ts (100%) rename services/minecraft/src/{plugins => libs}/llm-agent/voice.ts (99%) diff --git a/services/minecraft/src/agents/action/llm-handler.test.ts b/services/minecraft/src/agents/action/llm-handler.test.ts index 8568e1033..08c9cf303 100644 --- a/services/minecraft/src/agents/action/llm-handler.test.ts +++ b/services/minecraft/src/agents/action/llm-handler.test.ts @@ -5,7 +5,7 @@ import { initBot, useBot } from '../../composables/bot' import { botConfig, initEnv, openaiConfig } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' import { initLogger } from '../../utils/logger' -import { generateSystemBasicPrompt } from '../prompt/llm-agent.plugin' +import { generateSystemBasicPrompt } from '../prompt/llm-agent' describe('openAI agent', { timeout: 0 }, () => { beforeAll(() => { diff --git a/services/minecraft/src/agents/action/llm-handler.ts b/services/minecraft/src/agents/action/llm-handler.ts index 1cdf7474f..a104eb04d 100644 --- a/services/minecraft/src/agents/action/llm-handler.ts +++ b/services/minecraft/src/agents/action/llm-handler.ts @@ -7,7 +7,7 @@ import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' import { system, user } from 'neuri/openai' -import { BaseLLMHandler } from '../../libs/llm/base' +import { BaseLLMHandler } from '../../libs/llm-agent/handler' import { actionsList } from './tools' export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { diff --git a/services/minecraft/src/agents/action/tools.test.ts b/services/minecraft/src/agents/action/tools.test.ts index fe166a6e1..1f6add120 100644 --- a/services/minecraft/src/agents/action/tools.test.ts +++ b/services/minecraft/src/agents/action/tools.test.ts @@ -6,7 +6,7 @@ import { botConfig, initEnv, openaiConfig } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' import { sleep } from '../../utils/helper' import { initLogger } from '../../utils/logger' -import { generateActionAgentPrompt } from '../prompt/llm-agent.plugin' +import { generateActionAgentPrompt } from '../prompt/llm-agent' describe('actions agent', { timeout: 0 }, () => { beforeAll(() => { diff --git a/services/minecraft/src/agents/chat/llm-handler.ts b/services/minecraft/src/agents/chat/llm-handler.ts index 7743a8fed..2803c2938 100644 --- a/services/minecraft/src/agents/chat/llm-handler.ts +++ b/services/minecraft/src/agents/chat/llm-handler.ts @@ -2,7 +2,7 @@ import type { ChatHistory } from './types' import { system, user } from 'neuri/openai' -import { BaseLLMHandler } from '../../libs/llm/base' +import { BaseLLMHandler } from '../../libs/llm-agent/handler' import { genChatAgentPrompt } from '../prompt/chat' export class ChatLLMHandler extends BaseLLMHandler { diff --git a/services/minecraft/src/agents/planning/llm-handler.ts b/services/minecraft/src/agents/planning/llm-handler.ts index 81d9a4e68..01be58d54 100644 --- a/services/minecraft/src/agents/planning/llm-handler.ts +++ b/services/minecraft/src/agents/planning/llm-handler.ts @@ -4,7 +4,7 @@ import type { Action } from '../../libs/mineflayer/action' import { agent } from 'neuri' import { system, user } from 'neuri/openai' -import { BaseLLMHandler } from '../../libs/llm/base' +import { BaseLLMHandler } from '../../libs/llm-agent/handler' import { generatePlanningAgentSystemPrompt, generatePlanningAgentUserPrompt } from '../prompt/planning' export async function createPlanningNeuriAgent(): Promise { diff --git a/services/minecraft/src/agents/prompt/llm-agent.plugin.ts b/services/minecraft/src/agents/prompt/llm-agent.ts similarity index 100% rename from services/minecraft/src/agents/prompt/llm-agent.plugin.ts rename to services/minecraft/src/agents/prompt/llm-agent.ts diff --git a/services/minecraft/src/container.ts b/services/minecraft/src/container.ts index 97fae1116..44da5cdec 100644 --- a/services/minecraft/src/container.ts +++ b/services/minecraft/src/container.ts @@ -60,6 +60,8 @@ export function createAppContainer(options: { agent: options.neuri, model: options.model, }, + maxHistoryLength: 50, + idleTimeout: 5 * 60 * 1000, // 5 minutes })), }) diff --git a/services/minecraft/src/plugins/llm-agent/chat.ts b/services/minecraft/src/libs/llm-agent/chat.ts similarity index 99% rename from services/minecraft/src/plugins/llm-agent/chat.ts rename to services/minecraft/src/libs/llm-agent/chat.ts index e993e1b7c..08fc7a74a 100644 --- a/services/minecraft/src/plugins/llm-agent/chat.ts +++ b/services/minecraft/src/libs/llm-agent/chat.ts @@ -4,7 +4,7 @@ import type { MineflayerWithAgents } from './type' import { system, user } from 'neuri/openai' -import { generateStatusPrompt } from '../../agents/prompt/llm-agent.plugin' +import { generateStatusPrompt } from '../../agents/prompt/llm-agent' import { toRetriable } from '../../utils/helper' import { handleLLMCompletion } from './completion' diff --git a/services/minecraft/src/plugins/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts similarity index 100% rename from services/minecraft/src/plugins/llm-agent/completion.ts rename to services/minecraft/src/libs/llm-agent/completion.ts diff --git a/services/minecraft/src/libs/llm/base.ts b/services/minecraft/src/libs/llm-agent/handler.ts similarity index 100% rename from services/minecraft/src/libs/llm/base.ts rename to services/minecraft/src/libs/llm-agent/handler.ts diff --git a/services/minecraft/src/plugins/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts similarity index 93% rename from services/minecraft/src/plugins/llm-agent/index.ts rename to services/minecraft/src/libs/llm-agent/index.ts index ae2fb7bd6..c2cabaa8b 100644 --- a/services/minecraft/src/plugins/llm-agent/index.ts +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -1,13 +1,13 @@ -import type { MineflayerPlugin } from '../../libs/mineflayer' +import type { MineflayerPlugin } from '../mineflayer' import type { LLMAgentOptions, MineflayerWithAgents } from './type' import { useLogg } from '@guiiai/logg' import { system } from 'neuri/openai' -import { generateActionAgentPrompt } from '../../agents/prompt/llm-agent.plugin' +import { generateActionAgentPrompt } from '../../agents/prompt/llm-agent' import { openaiConfig } from '../../composables/config' import { createAppContainer } from '../../container' -import { ChatMessageHandler } from '../../libs/mineflayer' +import { ChatMessageHandler } from '../mineflayer' import { handleChatMessage } from './chat' import { handleVoiceInput } from './voice' diff --git a/services/minecraft/src/plugins/llm-agent/type.ts b/services/minecraft/src/libs/llm-agent/type.ts similarity index 65% rename from services/minecraft/src/plugins/llm-agent/type.ts rename to services/minecraft/src/libs/llm-agent/type.ts index d5444a130..a96345021 100644 --- a/services/minecraft/src/plugins/llm-agent/type.ts +++ b/services/minecraft/src/libs/llm-agent/type.ts @@ -1,7 +1,7 @@ import type { Client } from '@proj-airi/server-sdk' import type { Neuri } from 'neuri' -import type { Mineflayer } from '../../libs/mineflayer' -import type { ActionAgent, ChatAgent, PlanningAgent } from '../../libs/mineflayer/base-agent' +import type { Mineflayer } from '../mineflayer' +import type { ActionAgent, ChatAgent, PlanningAgent } from '../mineflayer/base-agent' export interface MineflayerWithAgents extends Mineflayer { planning: PlanningAgent diff --git a/services/minecraft/src/libs/llm/types.ts b/services/minecraft/src/libs/llm-agent/types.ts similarity index 100% rename from services/minecraft/src/libs/llm/types.ts rename to services/minecraft/src/libs/llm-agent/types.ts diff --git a/services/minecraft/src/plugins/llm-agent/voice.ts b/services/minecraft/src/libs/llm-agent/voice.ts similarity index 99% rename from services/minecraft/src/plugins/llm-agent/voice.ts rename to services/minecraft/src/libs/llm-agent/voice.ts index ff1ad5010..0b07cdcb8 100644 --- a/services/minecraft/src/plugins/llm-agent/voice.ts +++ b/services/minecraft/src/libs/llm-agent/voice.ts @@ -4,7 +4,7 @@ import type { MineflayerWithAgents } from './type' import { system, user } from 'neuri/openai' -import { generateStatusPrompt } from '../../agents/prompt/llm-agent.plugin' +import { generateStatusPrompt } from '../../agents/prompt/llm-agent' import { toRetriable } from '../../utils/helper' import { handleLLMCompletion } from './completion' diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 076471b5e..7afe316df 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -11,8 +11,8 @@ import { plugin as MineflayerTool } from 'mineflayer-tool' import { initBot } from './composables/bot' import { botConfig, initEnv } from './composables/config' import { createNeuriAgent } from './composables/neuri' +import { LLMAgent } from './libs/llm-agent' import { wrapPlugin } from './libs/mineflayer' -import { LLMAgent } from './plugins/llm-agent' import { initLogger } from './utils/logger' const logger = useLogg('main').useGlobalConfig() From 2631b4041b20091a402f83ee4dc3194edc0f9a32 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:22:44 +0800 Subject: [PATCH 74/77] refactor: move prompt to self folders --- .../{llm-handler.test.ts => adapter.test.ts} | 0 .../action/{llm-handler.ts => adapter.ts} | 2 +- services/minecraft/src/agents/action/index.ts | 2 +- .../chat/{llm-handler.ts => adapter.ts} | 25 ++++++++- services/minecraft/src/agents/chat/llm.ts | 4 +- .../planning/{llm-handler.ts => adapter.ts} | 51 +++++++++++++++++-- .../minecraft/src/agents/planning/index.ts | 4 +- services/minecraft/src/agents/prompt/chat.ts | 21 -------- .../minecraft/src/agents/prompt/planning.ts | 47 ----------------- services/minecraft/src/composables/neuri.ts | 4 +- services/minecraft/src/libs/llm-agent/chat.ts | 4 +- .../src/libs/llm-agent/completion.ts | 2 +- .../minecraft/src/libs/llm-agent/index.ts | 4 +- .../llm-agent.ts => libs/llm-agent/prompt.ts} | 42 +++++++-------- services/minecraft/src/libs/llm-agent/type.ts | 15 ------ .../minecraft/src/libs/llm-agent/types.ts | 14 +++++ .../minecraft/src/libs/llm-agent/voice.ts | 4 +- .../src/libs/mineflayer/base-agent.ts | 2 +- 18 files changed, 122 insertions(+), 125 deletions(-) rename services/minecraft/src/agents/action/{llm-handler.test.ts => adapter.test.ts} (100%) rename services/minecraft/src/agents/action/{llm-handler.ts => adapter.ts} (98%) rename services/minecraft/src/agents/chat/{llm-handler.ts => adapter.ts} (61%) rename services/minecraft/src/agents/planning/{llm-handler.ts => adapter.ts} (67%) delete mode 100644 services/minecraft/src/agents/prompt/chat.ts delete mode 100644 services/minecraft/src/agents/prompt/planning.ts rename services/minecraft/src/{agents/prompt/llm-agent.ts => libs/llm-agent/prompt.ts} (96%) delete mode 100644 services/minecraft/src/libs/llm-agent/type.ts diff --git a/services/minecraft/src/agents/action/llm-handler.test.ts b/services/minecraft/src/agents/action/adapter.test.ts similarity index 100% rename from services/minecraft/src/agents/action/llm-handler.test.ts rename to services/minecraft/src/agents/action/adapter.test.ts diff --git a/services/minecraft/src/agents/action/llm-handler.ts b/services/minecraft/src/agents/action/adapter.ts similarity index 98% rename from services/minecraft/src/agents/action/llm-handler.ts rename to services/minecraft/src/agents/action/adapter.ts index a104eb04d..2d1f19916 100644 --- a/services/minecraft/src/agents/action/llm-handler.ts +++ b/services/minecraft/src/agents/action/adapter.ts @@ -1,7 +1,7 @@ import type { Agent } from 'neuri' import type { Message } from 'neuri/openai' import type { Mineflayer } from '../../libs/mineflayer' -import type { PlanStep } from '../planning/llm-handler' +import type { PlanStep } from '../planning/adapter' import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' diff --git a/services/minecraft/src/agents/action/index.ts b/services/minecraft/src/agents/action/index.ts index 19109b000..6565a466d 100644 --- a/services/minecraft/src/agents/action/index.ts +++ b/services/minecraft/src/agents/action/index.ts @@ -1,7 +1,7 @@ import type { Mineflayer } from '../../libs/mineflayer' import type { Action } from '../../libs/mineflayer/action' import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent' -import type { PlanStep } from '../planning/llm-handler' +import type { PlanStep } from '../planning/adapter' import { useBot } from '../../composables/bot' import { AbstractAgent } from '../../libs/mineflayer/base-agent' diff --git a/services/minecraft/src/agents/chat/llm-handler.ts b/services/minecraft/src/agents/chat/adapter.ts similarity index 61% rename from services/minecraft/src/agents/chat/llm-handler.ts rename to services/minecraft/src/agents/chat/adapter.ts index 2803c2938..bc03b45a9 100644 --- a/services/minecraft/src/agents/chat/llm-handler.ts +++ b/services/minecraft/src/agents/chat/adapter.ts @@ -3,14 +3,35 @@ import type { ChatHistory } from './types' import { system, user } from 'neuri/openai' import { BaseLLMHandler } from '../../libs/llm-agent/handler' -import { genChatAgentPrompt } from '../prompt/chat' + +export function generateChatAgentPrompt(): string { + return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals. + +Guidelines: +1. Be friendly and helpful +2. Keep responses concise but informative +3. Use game-appropriate language +4. Acknowledge player's emotions and intentions +5. Ask for clarification when needed +6. Remember context from previous messages +7. Be proactive in suggesting helpful actions + +You can: +- Answer questions about the game +- Help with tasks and crafting +- Give directions and suggestions +- Engage in casual conversation +- Coordinate with other bots + +Remember that you're operating in a Minecraft world and should maintain that context in your responses.` +} export class ChatLLMHandler extends BaseLLMHandler { public async generateResponse( message: string, history: ChatHistory[], ): Promise { - const systemPrompt = genChatAgentPrompt() + const systemPrompt = generateChatAgentPrompt() const chatHistory = this.formatChatHistory(history, this.config.maxContextLength ?? 10) const messages = [ system(systemPrompt), diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts index 0d775790c..6b97f3b54 100644 --- a/services/minecraft/src/agents/chat/llm.ts +++ b/services/minecraft/src/agents/chat/llm.ts @@ -7,7 +7,7 @@ import { system, user } from 'neuri/openai' import { openaiConfig } from '../../composables/config' import { toRetriable } from '../../utils/helper' -import { genChatAgentPrompt } from '../prompt/chat' +import { generateChatAgentPrompt } from './adapter' const logger = useLogg('chat-llm').useGlobalConfig() @@ -28,7 +28,7 @@ export async function generateChatResponse( history: ChatHistory[], config: LLMChatConfig, ): Promise { - const systemPrompt = genChatAgentPrompt() + const systemPrompt = generateChatAgentPrompt() const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10) const userPrompt = message diff --git a/services/minecraft/src/agents/planning/llm-handler.ts b/services/minecraft/src/agents/planning/adapter.ts similarity index 67% rename from services/minecraft/src/agents/planning/llm-handler.ts rename to services/minecraft/src/agents/planning/adapter.ts index 01be58d54..fd964a649 100644 --- a/services/minecraft/src/agents/planning/llm-handler.ts +++ b/services/minecraft/src/agents/planning/adapter.ts @@ -5,7 +5,6 @@ import { agent } from 'neuri' import { system, user } from 'neuri/openai' import { BaseLLMHandler } from '../../libs/llm-agent/handler' -import { generatePlanningAgentSystemPrompt, generatePlanningAgentUserPrompt } from '../prompt/planning' export async function createPlanningNeuriAgent(): Promise { return agent('planning').build() @@ -24,8 +23,8 @@ export class PlanningLLMHandler extends BaseLLMHandler { sender: string, feedback?: string, ): Promise { - const systemPrompt = generatePlanningAgentSystemPrompt(availableActions) - const userPrompt = generatePlanningAgentUserPrompt(goal, sender, feedback) + const systemPrompt = this.generatePlanningAgentSystemPrompt(availableActions) + const userPrompt = this.generatePlanningAgentUserPrompt(goal, sender, feedback) const messages = [system(systemPrompt), user(userPrompt)] const result = await this.config.agent.handleStateless(messages, async (context) => { @@ -98,4 +97,50 @@ export class PlanningLLMHandler extends BaseLLMHandler { } }) } + + private generatePlanningAgentSystemPrompt(availableActions: Action[]): string { + const actionsList = availableActions + .map((action) => { + const params = Object.keys(action.schema.shape) + .map(name => ` - ${name}`) + .join('\n') + return `- ${action.name}: ${action.description}\n Parameters:\n${params}` + }) + .join('\n\n') + + return `You are a Minecraft bot planner. Break down goals into simple action steps. + +Available tools: +${actionsList} + +Format each step as: +1. Action description (short, direct command) +2. Tool name +3. Required parameters + +Example: +1. Follow player + Tool: followPlayer + Params: + player: luoling8192 + follow_dist: 3 + +Keep steps: +- Short and direct +- Action-focused +- Parameters precise +- Generate all steps at once` + } + + private generatePlanningAgentUserPrompt(goal: string, sender: string, feedback?: string): string { + let prompt = `${sender}: ${goal} + +Generate minimal steps with exact parameters. +Use the sender's name (${sender}) for player-related parameters.` + + if (feedback) { + prompt += `\n\nPrevious attempt failed: ${feedback}` + } + return prompt + } } diff --git a/services/minecraft/src/agents/planning/index.ts b/services/minecraft/src/agents/planning/index.ts index 110e1ea19..274fd2f45 100644 --- a/services/minecraft/src/agents/planning/index.ts +++ b/services/minecraft/src/agents/planning/index.ts @@ -1,11 +1,11 @@ import type { Neuri } from 'neuri' import type { Action } from '../../libs/mineflayer/action' import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from '../../libs/mineflayer/base-agent' -import type { PlanStep } from './llm-handler' +import type { PlanStep } from './adapter' import { AbstractAgent } from '../../libs/mineflayer/base-agent' import { ActionAgentImpl } from '../action' -import { PlanningLLMHandler } from './llm-handler' +import { PlanningLLMHandler } from './adapter' interface PlanContext { goal: string diff --git a/services/minecraft/src/agents/prompt/chat.ts b/services/minecraft/src/agents/prompt/chat.ts deleted file mode 100644 index a07f41c8b..000000000 --- a/services/minecraft/src/agents/prompt/chat.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function genChatAgentPrompt(): string { - return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals. - -Guidelines: -1. Be friendly and helpful -2. Keep responses concise but informative -3. Use game-appropriate language -4. Acknowledge player's emotions and intentions -5. Ask for clarification when needed -6. Remember context from previous messages -7. Be proactive in suggesting helpful actions - -You can: -- Answer questions about the game -- Help with tasks and crafting -- Give directions and suggestions -- Engage in casual conversation -- Coordinate with other bots - -Remember that you're operating in a Minecraft world and should maintain that context in your responses.` -} diff --git a/services/minecraft/src/agents/prompt/planning.ts b/services/minecraft/src/agents/prompt/planning.ts deleted file mode 100644 index 87928c76e..000000000 --- a/services/minecraft/src/agents/prompt/planning.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Action } from '../../libs/mineflayer/action' - -export function generatePlanningAgentSystemPrompt(availableActions: Action[]): string { - const actionsList = availableActions - .map((action) => { - const params = Object.keys(action.schema.shape) - .map(name => ` - ${name}`) - .join('\n') - return `- ${action.name}: ${action.description}\n Parameters:\n${params}` - }) - .join('\n\n') - - return `You are a Minecraft bot planner. Break down goals into simple action steps. - -Available tools: -${actionsList} - -Format each step as: -1. Action description (short, direct command) -2. Tool name -3. Required parameters - -Example: -1. Follow player - Tool: followPlayer - Params: - player: luoling8192 - follow_dist: 3 - -Keep steps: -- Short and direct -- Action-focused -- Parameters precise -- Generate all steps at once` -} - -export function generatePlanningAgentUserPrompt(goal: string, sender: string, feedback?: string): string { - let prompt = `${sender}: ${goal} - -Generate minimal steps with exact parameters. -Use the sender's name (${sender}) for player-related parameters.` - - if (feedback) { - prompt += `\n\nPrevious attempt failed: ${feedback}` - } - return prompt -} diff --git a/services/minecraft/src/composables/neuri.ts b/services/minecraft/src/composables/neuri.ts index 1c6216013..08ee32b6d 100644 --- a/services/minecraft/src/composables/neuri.ts +++ b/services/minecraft/src/composables/neuri.ts @@ -4,9 +4,9 @@ import type { Mineflayer } from '../libs/mineflayer' import { useLogg } from '@guiiai/logg' import { neuri } from 'neuri' -import { createActionNeuriAgent } from '../agents/action/llm-handler' +import { createActionNeuriAgent } from '../agents/action/adapter' import { createChatNeuriAgent } from '../agents/chat/llm' -import { createPlanningNeuriAgent } from '../agents/planning/llm-handler' +import { createPlanningNeuriAgent } from '../agents/planning/adapter' import { openaiConfig } from './config' let neuriAgent: Neuri | undefined diff --git a/services/minecraft/src/libs/llm-agent/chat.ts b/services/minecraft/src/libs/llm-agent/chat.ts index 08fc7a74a..48c638529 100644 --- a/services/minecraft/src/libs/llm-agent/chat.ts +++ b/services/minecraft/src/libs/llm-agent/chat.ts @@ -1,12 +1,12 @@ import type { useLogg } from '@guiiai/logg' import type { Neuri, NeuriContext } from 'neuri' -import type { MineflayerWithAgents } from './type' +import type { MineflayerWithAgents } from './types' import { system, user } from 'neuri/openai' -import { generateStatusPrompt } from '../../agents/prompt/llm-agent' import { toRetriable } from '../../utils/helper' import { handleLLMCompletion } from './completion' +import { generateStatusPrompt } from './prompt' export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { logger.withFields({ username, message }).log('Chat message received') diff --git a/services/minecraft/src/libs/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts index 12a621dca..084055b39 100644 --- a/services/minecraft/src/libs/llm-agent/completion.ts +++ b/services/minecraft/src/libs/llm-agent/completion.ts @@ -1,7 +1,7 @@ import type { useLogg } from '@guiiai/logg' import type { NeuriContext } from 'neuri' import type { ChatCompletion } from 'neuri/openai' -import type { MineflayerWithAgents } from './type' +import type { MineflayerWithAgents } from './types' import { assistant } from 'neuri/openai' diff --git a/services/minecraft/src/libs/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts index c2cabaa8b..150d7e7d6 100644 --- a/services/minecraft/src/libs/llm-agent/index.ts +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -1,14 +1,14 @@ import type { MineflayerPlugin } from '../mineflayer' -import type { LLMAgentOptions, MineflayerWithAgents } from './type' +import type { LLMAgentOptions, MineflayerWithAgents } from './types' import { useLogg } from '@guiiai/logg' import { system } from 'neuri/openai' -import { generateActionAgentPrompt } from '../../agents/prompt/llm-agent' import { openaiConfig } from '../../composables/config' import { createAppContainer } from '../../container' import { ChatMessageHandler } from '../mineflayer' import { handleChatMessage } from './chat' +import { generateActionAgentPrompt } from './prompt' import { handleVoiceInput } from './voice' export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { diff --git a/services/minecraft/src/agents/prompt/llm-agent.ts b/services/minecraft/src/libs/llm-agent/prompt.ts similarity index 96% rename from services/minecraft/src/agents/prompt/llm-agent.ts rename to services/minecraft/src/libs/llm-agent/prompt.ts index 273e74d6e..61591b7a4 100644 --- a/services/minecraft/src/agents/prompt/llm-agent.ts +++ b/services/minecraft/src/libs/llm-agent/prompt.ts @@ -1,27 +1,7 @@ -import type { Mineflayer } from '../../libs/mineflayer' +import type { Mineflayer } from '../mineflayer' import { listInventory } from '../../skills/actions/inventory' -export function generateSystemBasicPrompt(botName: string): string { - // ${ctx.prompt.selfPrompt} - return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move, -mine, build, and interact with the world by using commands.` -} - -export function generateActionAgentPrompt(mineflayer: Mineflayer): string { - return `${generateSystemBasicPrompt(mineflayer.username)} - -Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in -your responses, don't apologize constantly, don't give instructions or make lists unless -asked, and don't refuse requests. - -Do not use any emojis. Just call the function given you if needed. - -- If I command you 'stop', then call the 'stop' function. -- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function. -` -} - export async function generateStatusPrompt(mineflayer: Mineflayer): Promise { // Get inventory items const inventory = await listInventory(mineflayer) @@ -48,3 +28,23 @@ export async function generateStatusPrompt(mineflayer: Mineflayer): Promise): Promise { logger diff --git a/services/minecraft/src/libs/mineflayer/base-agent.ts b/services/minecraft/src/libs/mineflayer/base-agent.ts index 132ca98f1..ff5597537 100644 --- a/services/minecraft/src/libs/mineflayer/base-agent.ts +++ b/services/minecraft/src/libs/mineflayer/base-agent.ts @@ -1,4 +1,4 @@ -import type { PlanStep } from '../../agents/planning/llm-handler' +import type { PlanStep } from '../../agents/planning/adapter' import type { Action } from './action' import { useLogg } from '@guiiai/logg' From ce7f179e4f91ef8cdc35ddd7dcae76b7ca9bcdd8 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:24:26 +0800 Subject: [PATCH 75/77] chore: move container.ts to libs/llm-agent --- services/minecraft/src/{ => libs/llm-agent}/container.ts | 8 ++++---- services/minecraft/src/libs/llm-agent/index.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename services/minecraft/src/{ => libs/llm-agent}/container.ts (87%) diff --git a/services/minecraft/src/container.ts b/services/minecraft/src/libs/llm-agent/container.ts similarity index 87% rename from services/minecraft/src/container.ts rename to services/minecraft/src/libs/llm-agent/container.ts index 44da5cdec..710294728 100644 --- a/services/minecraft/src/container.ts +++ b/services/minecraft/src/libs/llm-agent/container.ts @@ -3,9 +3,9 @@ import type { Neuri } from 'neuri' import { useLogg } from '@guiiai/logg' import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' -import { ActionAgentImpl } from './agents/action' -import { ChatAgentImpl } from './agents/chat' -import { PlanningAgentImpl } from './agents/planning' +import { ActionAgentImpl } from '../../agents/action' +import { ChatAgentImpl } from '../../agents/chat' +import { PlanningAgentImpl } from '../../agents/planning' export interface ContainerServices { logger: ReturnType @@ -15,7 +15,7 @@ export interface ContainerServices { neuri: Neuri } -export function createAppContainer(options: { +export function createAgentContainer(options: { neuri: Neuri model?: string }) { diff --git a/services/minecraft/src/libs/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts index 150d7e7d6..0d178a52f 100644 --- a/services/minecraft/src/libs/llm-agent/index.ts +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -5,9 +5,9 @@ import { useLogg } from '@guiiai/logg' import { system } from 'neuri/openai' import { openaiConfig } from '../../composables/config' -import { createAppContainer } from '../../container' import { ChatMessageHandler } from '../mineflayer' import { handleChatMessage } from './chat' +import { createAgentContainer } from './container' import { generateActionAgentPrompt } from './prompt' import { handleVoiceInput } from './voice' @@ -17,7 +17,7 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { const logger = useLogg('LLMAgent').useGlobalConfig() // Create container and get required services - const container = createAppContainer({ + const container = createAgentContainer({ neuri: options.agent, model: openaiConfig.model, }) From 63643b9832bf8d534ea1700812f902a13b0eaa30 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:36:26 +0800 Subject: [PATCH 76/77] refactor: config --- .../src/agents/action/adapter.test.ts | 8 +- .../minecraft/src/agents/action/tools.test.ts | 10 +-- services/minecraft/src/agents/chat/llm.ts | 4 +- services/minecraft/src/composables/config.ts | 73 ++++++++++++------- services/minecraft/src/composables/neuri.ts | 6 +- .../src/libs/llm-agent/completion.ts | 4 +- .../minecraft/src/libs/llm-agent/handler.ts | 4 +- .../minecraft/src/libs/llm-agent/index.ts | 4 +- services/minecraft/src/main.ts | 10 ++- 9 files changed, 73 insertions(+), 50 deletions(-) diff --git a/services/minecraft/src/agents/action/adapter.test.ts b/services/minecraft/src/agents/action/adapter.test.ts index 08c9cf303..828a0c54c 100644 --- a/services/minecraft/src/agents/action/adapter.test.ts +++ b/services/minecraft/src/agents/action/adapter.test.ts @@ -2,16 +2,16 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { initBot, useBot } from '../../composables/bot' -import { botConfig, initEnv, openaiConfig } from '../../composables/config' +import { config, initEnv } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' +import { generateSystemBasicPrompt } from '../../libs/llm-agent/prompt' import { initLogger } from '../../utils/logger' -import { generateSystemBasicPrompt } from '../prompt/llm-agent' describe('openAI agent', { timeout: 0 }, () => { beforeAll(() => { initLogger() initEnv() - initBot({ botConfig }) + initBot({ botConfig: config.bot }) }) it('should initialize the agent', async () => { @@ -26,7 +26,7 @@ describe('openAI agent', { timeout: 0 }, () => { user('Hello, who are you?'), ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: openaiConfig.model }) + const completion = await c.reroute('query', c.messages, { model: config.openai.model }) return await completion?.firstContent() }, ) diff --git a/services/minecraft/src/agents/action/tools.test.ts b/services/minecraft/src/agents/action/tools.test.ts index 1f6add120..14af19f8e 100644 --- a/services/minecraft/src/agents/action/tools.test.ts +++ b/services/minecraft/src/agents/action/tools.test.ts @@ -2,17 +2,17 @@ import { messages, system, user } from 'neuri/openai' import { beforeAll, describe, expect, it } from 'vitest' import { initBot, useBot } from '../../composables/bot' -import { botConfig, initEnv, openaiConfig } from '../../composables/config' +import { config, initEnv } from '../../composables/config' import { createNeuriAgent } from '../../composables/neuri' +import { generateActionAgentPrompt } from '../../libs/llm-agent/prompt' import { sleep } from '../../utils/helper' import { initLogger } from '../../utils/logger' -import { generateActionAgentPrompt } from '../prompt/llm-agent' describe('actions agent', { timeout: 0 }, () => { beforeAll(() => { initLogger() initEnv() - initBot({ botConfig }) + initBot({ botConfig: config.bot }) }) it('should choose right query command', async () => { @@ -25,7 +25,7 @@ describe('actions agent', { timeout: 0 }, () => { system(generateActionAgentPrompt(bot)), user('What\'s your status?'), ), async (c) => { - const completion = await c.reroute('query', c.messages, { model: openaiConfig.model }) + const completion = await c.reroute('query', c.messages, { model: config.openai.model }) return await completion?.firstContent() }) @@ -46,7 +46,7 @@ describe('actions agent', { timeout: 0 }, () => { system(generateActionAgentPrompt(bot)), user('goToPlayer: luoling8192'), ), async (c) => { - const completion = await c.reroute('action', c.messages, { model: openaiConfig.model }) + const completion = await c.reroute('action', c.messages, { model: config.openai.model }) return await completion?.firstContent() }) diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts index 6b97f3b54..ae4672e0c 100644 --- a/services/minecraft/src/agents/chat/llm.ts +++ b/services/minecraft/src/agents/chat/llm.ts @@ -5,7 +5,7 @@ import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' import { system, user } from 'neuri/openai' -import { openaiConfig } from '../../composables/config' +import { config as appConfig } from '../../composables/config' import { toRetriable } from '../../utils/helper' import { generateChatAgentPrompt } from './adapter' @@ -43,7 +43,7 @@ export async function generateChatResponse( const handleCompletion = async (c: any): Promise => { const completion = await c.reroute('chat', c.messages, { - model: config.model ?? openaiConfig.model, + model: config.model ?? appConfig.openai.model, }) if (!completion || 'error' in completion) { diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 81c7ec330..e53f25bf4 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -13,13 +13,28 @@ interface OpenAIConfig { reasoningModel: string } -interface EnvConfig { +interface AiriConfig { + wsBaseUrl: string + clientName: string +} + +interface Config { openai: OpenAIConfig bot: BotOptions + airi: AiriConfig +} + +// Helper functions for type-safe environment variable parsing +function getEnvVar(key: string, defaultValue: string): string { + return env[key] || defaultValue +} + +function getEnvNumber(key: string, defaultValue: number): number { + return Number.parseInt(env[key] || String(defaultValue)) } // Default configurations -const defaultConfig: EnvConfig = { +const defaultConfig: Config = { openai: { apiKey: '', baseUrl: '', @@ -27,41 +42,45 @@ const defaultConfig: EnvConfig = { reasoningModel: '', }, bot: { - username: '', - host: '', - port: 0, + username: 'airi-bot', + host: 'localhost', + port: 25565, password: '', version: '1.20', }, + airi: { + wsBaseUrl: 'ws://localhost:6121/ws', + clientName: 'minecraft-bot', + }, } -// Exported configurations -export const botConfig: BotOptions = { ...defaultConfig.bot } -export const openaiConfig: OpenAIConfig = { ...defaultConfig.openai } +// Create a singleton config instance +export const config: Config = { ...defaultConfig } -// Load environment variables into config +// Initialize environment configuration export function initEnv(): void { logger.log('Initializing environment variables') - const config: EnvConfig = { - openai: { - apiKey: env.OPENAI_API_KEY || defaultConfig.openai.apiKey, - baseUrl: env.OPENAI_API_BASEURL || defaultConfig.openai.baseUrl, - model: env.OPENAI_MODEL || defaultConfig.openai.model, - reasoningModel: env.OPENAI_REASONING_MODEL || defaultConfig.openai.reasoningModel, - }, - bot: { - username: env.BOT_USERNAME || defaultConfig.bot.username, - host: env.BOT_HOSTNAME || defaultConfig.bot.host, - port: Number.parseInt(env.BOT_PORT || '49415'), - password: env.BOT_PASSWORD || defaultConfig.bot.password, - version: env.BOT_VERSION || defaultConfig.bot.version, - }, + // Update config with environment variables + config.openai = { + apiKey: getEnvVar('OPENAI_API_KEY', defaultConfig.openai.apiKey), + baseUrl: getEnvVar('OPENAI_API_BASEURL', defaultConfig.openai.baseUrl), + model: getEnvVar('OPENAI_MODEL', defaultConfig.openai.model), + reasoningModel: getEnvVar('OPENAI_REASONING_MODEL', defaultConfig.openai.reasoningModel), } - // Update exported configs - Object.assign(openaiConfig, config.openai) - Object.assign(botConfig, config.bot) + config.bot = { + username: getEnvVar('BOT_USERNAME', defaultConfig.bot.username as string), + host: getEnvVar('BOT_HOSTNAME', defaultConfig.bot.host as string), + port: getEnvNumber('BOT_PORT', defaultConfig.bot.port as number), + password: getEnvVar('BOT_PASSWORD', defaultConfig.bot.password as string), + version: getEnvVar('BOT_VERSION', defaultConfig.bot.version as string), + } - logger.withFields({ openaiConfig }).log('Environment variables initialized') + config.airi = { + wsBaseUrl: getEnvVar('AIRI_WS_BASEURL', defaultConfig.airi.wsBaseUrl), + clientName: getEnvVar('AIRI_CLIENT_NAME', defaultConfig.airi.clientName), + } + + logger.withFields({ config }).log('Environment variables initialized') } diff --git a/services/minecraft/src/composables/neuri.ts b/services/minecraft/src/composables/neuri.ts index 08ee32b6d..1a00f7098 100644 --- a/services/minecraft/src/composables/neuri.ts +++ b/services/minecraft/src/composables/neuri.ts @@ -7,7 +7,7 @@ import { neuri } from 'neuri' import { createActionNeuriAgent } from '../agents/action/adapter' import { createChatNeuriAgent } from '../agents/chat/llm' import { createPlanningNeuriAgent } from '../agents/planning/adapter' -import { openaiConfig } from './config' +import { config } from './config' let neuriAgent: Neuri | undefined const agents = new Set>() @@ -26,8 +26,8 @@ export async function createNeuriAgent(mineflayer: Mineflayer): Promise { neuriAgent = await n.build({ provider: { - apiKey: openaiConfig.apiKey, - baseURL: openaiConfig.baseUrl, + apiKey: config.openai.apiKey, + baseURL: config.openai.baseUrl, }, }) diff --git a/services/minecraft/src/libs/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts index 084055b39..dbb5a2c32 100644 --- a/services/minecraft/src/libs/llm-agent/completion.ts +++ b/services/minecraft/src/libs/llm-agent/completion.ts @@ -5,13 +5,13 @@ import type { MineflayerWithAgents } from './types' import { assistant } from 'neuri/openai' -import { openaiConfig } from '../../composables/config' +import { config } from '../../composables/config' export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType): Promise { logger.log('rerouting...') const completion = await context.reroute('action', context.messages, { - model: openaiConfig.model, + model: config.openai.model, }) as ChatCompletion | { error: { message: string } } & ChatCompletion if (!completion || 'error' in completion) { diff --git a/services/minecraft/src/libs/llm-agent/handler.ts b/services/minecraft/src/libs/llm-agent/handler.ts index 7e8df465b..5c57cd121 100644 --- a/services/minecraft/src/libs/llm-agent/handler.ts +++ b/services/minecraft/src/libs/llm-agent/handler.ts @@ -4,7 +4,7 @@ import type { LLMConfig, LLMResponse } from './types' import { useLogg } from '@guiiai/logg' -import { openaiConfig } from '../../composables/config' +import { config } from '../../composables/config' import { toRetriable } from '../../utils/helper' export abstract class BaseLLMHandler { @@ -18,7 +18,7 @@ export abstract class BaseLLMHandler { messages: Message[], ): Promise { const completion = await context.reroute(route, messages, { - model: this.config.model ?? openaiConfig.model, + model: this.config.model ?? config.openai.model, }) as ChatCompletion | ChatCompletion & { error: { message: string } } if (!completion || 'error' in completion) { diff --git a/services/minecraft/src/libs/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts index 0d178a52f..a8b690568 100644 --- a/services/minecraft/src/libs/llm-agent/index.ts +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -4,7 +4,7 @@ import type { LLMAgentOptions, MineflayerWithAgents } from './types' import { useLogg } from '@guiiai/logg' import { system } from 'neuri/openai' -import { openaiConfig } from '../../composables/config' +import { config } from '../../composables/config' import { ChatMessageHandler } from '../mineflayer' import { handleChatMessage } from './chat' import { createAgentContainer } from './container' @@ -19,7 +19,7 @@ export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { // Create container and get required services const container = createAgentContainer({ neuri: options.agent, - model: openaiConfig.model, + model: config.openai.model, }) const actionAgent = container.resolve('actionAgent') diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 7afe316df..a912d13ea 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -9,7 +9,7 @@ import { plugin as MineflayerPVP } from 'mineflayer-pvp' import { plugin as MineflayerTool } from 'mineflayer-tool' import { initBot } from './composables/bot' -import { botConfig, initEnv } from './composables/config' +import { config, initEnv } from './composables/config' import { createNeuriAgent } from './composables/neuri' import { LLMAgent } from './libs/llm-agent' import { wrapPlugin } from './libs/mineflayer' @@ -22,7 +22,7 @@ async function main() { initEnv() const { bot } = await initBot({ - botConfig, + botConfig: config.bot, plugins: [ wrapPlugin(MineflayerArmorManager), wrapPlugin(MineflayerAutoEat), @@ -33,7 +33,11 @@ async function main() { ], }) - const airiClient = new Client({ name: 'minecraft-bot', url: 'ws://localhost:6121/ws' }) + // Connect airi server + const airiClient = new Client({ + name: config.airi.clientName, + url: config.airi.wsBaseUrl, + }) // Dynamically load LLMAgent after the bot is initialized const agent = await createNeuriAgent(bot) From fdb1bd1b002e5bfdd8cc7ba6ad75adcbf1cf306c Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 7 Feb 2025 01:55:41 +0800 Subject: [PATCH 77/77] chore: useLogger --- .../minecraft/src/agents/action/adapter.ts | 4 ++-- services/minecraft/src/agents/action/tools.ts | 5 ++--- services/minecraft/src/agents/chat/llm.ts | 5 ++--- services/minecraft/src/agents/memory/index.ts | 19 +++++++++---------- services/minecraft/src/composables/config.ts | 5 +++-- services/minecraft/src/composables/neuri.ts | 6 ++---- services/minecraft/src/libs/llm-agent/chat.ts | 4 ++-- .../src/libs/llm-agent/completion.ts | 4 ++-- .../minecraft/src/libs/llm-agent/container.ts | 5 +++-- .../minecraft/src/libs/llm-agent/handler.ts | 9 +++++---- .../minecraft/src/libs/llm-agent/index.ts | 4 ++-- .../minecraft/src/libs/llm-agent/voice.ts | 4 ++-- .../src/libs/mineflayer/base-agent.ts | 3 ++- .../src/libs/mineflayer/components.ts | 4 ++-- services/minecraft/src/main.ts | 7 ++----- services/minecraft/src/plugins/echo.ts | 5 ++--- services/minecraft/src/plugins/follow.ts | 5 +++-- services/minecraft/src/plugins/pathfinder.ts | 5 +++-- services/minecraft/src/plugins/status.ts | 4 ++-- .../src/skills/actions/collect-block.ts | 4 ++-- .../minecraft/src/skills/actions/ensure.ts | 6 ++---- .../src/skills/actions/gather-wood.ts | 5 ++--- .../minecraft/src/skills/actions/inventory.ts | 5 ++--- .../src/skills/actions/world-interactions.ts | 4 ++-- services/minecraft/src/skills/base.ts | 4 ++-- services/minecraft/src/skills/crafting.ts | 5 ++--- services/minecraft/src/skills/movement.ts | 4 ++-- services/minecraft/src/utils/logger.ts | 18 ++++++++++++++++++ 28 files changed, 86 insertions(+), 76 deletions(-) diff --git a/services/minecraft/src/agents/action/adapter.ts b/services/minecraft/src/agents/action/adapter.ts index 2d1f19916..5156d6b6a 100644 --- a/services/minecraft/src/agents/action/adapter.ts +++ b/services/minecraft/src/agents/action/adapter.ts @@ -3,15 +3,15 @@ import type { Message } from 'neuri/openai' import type { Mineflayer } from '../../libs/mineflayer' import type { PlanStep } from '../planning/adapter' -import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' import { system, user } from 'neuri/openai' import { BaseLLMHandler } from '../../libs/llm-agent/handler' +import { useLogger } from '../../utils/logger' import { actionsList } from './tools' export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise { - const logger = useLogg('action-neuri').useGlobalConfig() + const logger = useLogger() logger.log('Initializing action agent') let actionAgent = agent('action') diff --git a/services/minecraft/src/agents/action/tools.ts b/services/minecraft/src/agents/action/tools.ts index 61f89dbb4..b9960c119 100644 --- a/services/minecraft/src/agents/action/tools.ts +++ b/services/minecraft/src/agents/action/tools.ts @@ -1,6 +1,5 @@ import type { Action } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' import { z } from 'zod' import * as skills from '../../skills' @@ -8,10 +7,10 @@ import { collectBlock } from '../../skills/actions/collect-block' import { discard, equip, putInChest, takeFromChest, viewChest } from '../../skills/actions/inventory' import { activateNearestBlock, placeBlock } from '../../skills/actions/world-interactions' import * as world from '../../skills/world' +import { useLogger } from '../../utils/logger' // Utils const pad = (str: string): string => `\n${str}\n` -const logger = useLogg('actions').useGlobalConfig() function formatInventoryItem(item: string, count: number): string { return count > 0 ? `\n- ${item}: ${count}` : '' @@ -59,7 +58,7 @@ export const actionsList: Action[] = [ schema: z.object({}), perform: mineflayer => (): string => { const blocks = world.getNearbyBlockTypes(mineflayer) - logger.withFields({ blocks }).log('nearbyBlocks') + useLogger().withFields({ blocks }).log('nearbyBlocks') return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`) }, }, diff --git a/services/minecraft/src/agents/chat/llm.ts b/services/minecraft/src/agents/chat/llm.ts index ae4672e0c..924fd089a 100644 --- a/services/minecraft/src/agents/chat/llm.ts +++ b/services/minecraft/src/agents/chat/llm.ts @@ -1,16 +1,14 @@ import type { Agent, Neuri } from 'neuri' import type { ChatHistory } from './types' -import { useLogg } from '@guiiai/logg' import { agent } from 'neuri' import { system, user } from 'neuri/openai' import { config as appConfig } from '../../composables/config' import { toRetriable } from '../../utils/helper' +import { useLogger } from '../../utils/logger' import { generateChatAgentPrompt } from './adapter' -const logger = useLogg('chat-llm').useGlobalConfig() - interface LLMChatConfig { agent: Neuri model?: string @@ -31,6 +29,7 @@ export async function generateChatResponse( const systemPrompt = generateChatAgentPrompt() const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10) const userPrompt = message + const logger = useLogger() const messages = [ system(systemPrompt), diff --git a/services/minecraft/src/agents/memory/index.ts b/services/minecraft/src/agents/memory/index.ts index 8cf7c41e9..c284ea2a8 100644 --- a/services/minecraft/src/agents/memory/index.ts +++ b/services/minecraft/src/agents/memory/index.ts @@ -2,11 +2,8 @@ import type { Message } from 'neuri/openai' import type { Action } from '../../libs/mineflayer' import type { AgentConfig, MemoryAgent } from '../../libs/mineflayer/base-agent' -import { useLogg } from '@guiiai/logg' - import { Memory } from '../../libs/mineflayer/memory' - -const logger = useLogg('memory-agent').useGlobalConfig() +import { type Logger, useLogger } from '../../utils/logger' export class MemoryAgentImpl implements MemoryAgent { public readonly type = 'memory' as const @@ -14,12 +11,14 @@ export class MemoryAgentImpl implements MemoryAgent { private memory: Map private initialized: boolean private memoryInstance: Memory + private logger: Logger constructor(config: AgentConfig) { this.id = config.id this.memory = new Map() this.initialized = false this.memoryInstance = new Memory() + this.logger = useLogger() } async init(): Promise { @@ -27,7 +26,7 @@ export class MemoryAgentImpl implements MemoryAgent { return } - logger.log('Initializing memory agent') + this.logger.log('Initializing memory agent') this.initialized = true } @@ -41,7 +40,7 @@ export class MemoryAgentImpl implements MemoryAgent { throw new Error('Memory agent not initialized') } - logger.withFields({ key, value }).log('Storing memory') + this.logger.withFields({ key, value }).log('Storing memory') this.memory.set(key, value) } @@ -51,7 +50,7 @@ export class MemoryAgentImpl implements MemoryAgent { } const value = this.memory.get(key) as T | undefined - logger.withFields({ key, value }).log('Recalling memory') + this.logger.withFields({ key, value }).log('Recalling memory') return value } @@ -60,7 +59,7 @@ export class MemoryAgentImpl implements MemoryAgent { throw new Error('Memory agent not initialized') } - logger.withFields({ key }).log('Forgetting memory') + this.logger.withFields({ key }).log('Forgetting memory') this.memory.delete(key) } @@ -78,7 +77,7 @@ export class MemoryAgentImpl implements MemoryAgent { } this.memoryInstance.chatHistory.push(message) - logger.withFields({ message }).log('Adding chat message to memory') + this.logger.withFields({ message }).log('Adding chat message to memory') } addAction(action: Action): void { @@ -87,7 +86,7 @@ export class MemoryAgentImpl implements MemoryAgent { } this.memoryInstance.actions.push(action) - logger.withFields({ action }).log('Adding action to memory') + this.logger.withFields({ action }).log('Adding action to memory') } getChatHistory(): Message[] { diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index e53f25bf4..e391eaf1f 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -1,9 +1,10 @@ import type { BotOptions } from 'mineflayer' import { env } from 'node:process' -import { useLogg } from '@guiiai/logg' -const logger = useLogg('config').useGlobalConfig() +import { useLogger } from '../utils/logger' + +const logger = useLogger() // Configuration interfaces interface OpenAIConfig { diff --git a/services/minecraft/src/composables/neuri.ts b/services/minecraft/src/composables/neuri.ts index 1a00f7098..bc52e435a 100644 --- a/services/minecraft/src/composables/neuri.ts +++ b/services/minecraft/src/composables/neuri.ts @@ -1,21 +1,19 @@ import type { Agent, Neuri } from 'neuri' import type { Mineflayer } from '../libs/mineflayer' -import { useLogg } from '@guiiai/logg' import { neuri } from 'neuri' import { createActionNeuriAgent } from '../agents/action/adapter' import { createChatNeuriAgent } from '../agents/chat/llm' import { createPlanningNeuriAgent } from '../agents/planning/adapter' +import { useLogger } from '../utils/logger' import { config } from './config' let neuriAgent: Neuri | undefined const agents = new Set>() -const logger = useLogg('neuri').useGlobalConfig() - export async function createNeuriAgent(mineflayer: Mineflayer): Promise { - logger.log('Initializing neuri agent') + useLogger().log('Initializing neuri agent') let n = neuri() agents.add(createPlanningNeuriAgent()) diff --git a/services/minecraft/src/libs/llm-agent/chat.ts b/services/minecraft/src/libs/llm-agent/chat.ts index 48c638529..117a23158 100644 --- a/services/minecraft/src/libs/llm-agent/chat.ts +++ b/services/minecraft/src/libs/llm-agent/chat.ts @@ -1,5 +1,5 @@ -import type { useLogg } from '@guiiai/logg' import type { Neuri, NeuriContext } from 'neuri' +import type { Logger } from '../../utils/logger' import type { MineflayerWithAgents } from './types' import { system, user } from 'neuri/openai' @@ -8,7 +8,7 @@ import { toRetriable } from '../../utils/helper' import { handleLLMCompletion } from './completion' import { generateStatusPrompt } from './prompt' -export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { +export async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { logger.withFields({ username, message }).log('Chat message received') bot.memory.chatHistory.push(user(`${username}: ${message}`)) diff --git a/services/minecraft/src/libs/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts index dbb5a2c32..433adaaac 100644 --- a/services/minecraft/src/libs/llm-agent/completion.ts +++ b/services/minecraft/src/libs/llm-agent/completion.ts @@ -1,13 +1,13 @@ -import type { useLogg } from '@guiiai/logg' import type { NeuriContext } from 'neuri' import type { ChatCompletion } from 'neuri/openai' +import type { Logger } from '../../utils/logger' import type { MineflayerWithAgents } from './types' import { assistant } from 'neuri/openai' import { config } from '../../composables/config' -export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType): Promise { +export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise { logger.log('rerouting...') const completion = await context.reroute('action', context.messages, { diff --git a/services/minecraft/src/libs/llm-agent/container.ts b/services/minecraft/src/libs/llm-agent/container.ts index 710294728..da49b0b0a 100644 --- a/services/minecraft/src/libs/llm-agent/container.ts +++ b/services/minecraft/src/libs/llm-agent/container.ts @@ -1,4 +1,5 @@ import type { Neuri } from 'neuri' +import type { Logger } from '../../utils/logger' import { useLogg } from '@guiiai/logg' import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' @@ -8,7 +9,7 @@ import { ChatAgentImpl } from '../../agents/chat' import { PlanningAgentImpl } from '../../agents/planning' export interface ContainerServices { - logger: ReturnType + logger: Logger actionAgent: ActionAgentImpl planningAgent: PlanningAgentImpl chatAgent: ChatAgentImpl @@ -27,7 +28,7 @@ export function createAgentContainer(options: { // Register services container.register({ // Create independent logger for each agent - logger: asFunction(() => useLogg('app').useGlobalConfig()).singleton(), + logger: asFunction(() => useLogg('agent').useGlobalConfig()).singleton(), // Register neuri client neuri: asFunction(() => options.neuri).singleton(), diff --git a/services/minecraft/src/libs/llm-agent/handler.ts b/services/minecraft/src/libs/llm-agent/handler.ts index 5c57cd121..d6e48e24d 100644 --- a/services/minecraft/src/libs/llm-agent/handler.ts +++ b/services/minecraft/src/libs/llm-agent/handler.ts @@ -2,15 +2,16 @@ import type { NeuriContext } from 'neuri' import type { ChatCompletion, Message } from 'neuri/openai' import type { LLMConfig, LLMResponse } from './types' -import { useLogg } from '@guiiai/logg' - import { config } from '../../composables/config' import { toRetriable } from '../../utils/helper' +import { type Logger, useLogger } from '../../utils/logger' export abstract class BaseLLMHandler { - protected logger = useLogg('llm-handler').useGlobalConfig() + protected logger: Logger - constructor(protected config: LLMConfig) {} + constructor(protected config: LLMConfig) { + this.logger = useLogger() + } protected async handleCompletion( context: NeuriContext, diff --git a/services/minecraft/src/libs/llm-agent/index.ts b/services/minecraft/src/libs/llm-agent/index.ts index a8b690568..fc2ef25de 100644 --- a/services/minecraft/src/libs/llm-agent/index.ts +++ b/services/minecraft/src/libs/llm-agent/index.ts @@ -1,10 +1,10 @@ import type { MineflayerPlugin } from '../mineflayer' import type { LLMAgentOptions, MineflayerWithAgents } from './types' -import { useLogg } from '@guiiai/logg' import { system } from 'neuri/openai' import { config } from '../../composables/config' +import { useLogger } from '../../utils/logger' import { ChatMessageHandler } from '../mineflayer' import { handleChatMessage } from './chat' import { createAgentContainer } from './container' @@ -14,7 +14,7 @@ import { handleVoiceInput } from './voice' export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin { return { async created(bot) { - const logger = useLogg('LLMAgent').useGlobalConfig() + const logger = useLogger() // Create container and get required services const container = createAgentContainer({ diff --git a/services/minecraft/src/libs/llm-agent/voice.ts b/services/minecraft/src/libs/llm-agent/voice.ts index 5af47b6d0..d7a52bca5 100644 --- a/services/minecraft/src/libs/llm-agent/voice.ts +++ b/services/minecraft/src/libs/llm-agent/voice.ts @@ -1,5 +1,5 @@ -import type { useLogg } from '@guiiai/logg' import type { Neuri, NeuriContext } from 'neuri' +import type { Logger } from '../../utils/logger' import type { MineflayerWithAgents } from './types' import { system, user } from 'neuri/openai' @@ -8,7 +8,7 @@ import { toRetriable } from '../../utils/helper' import { handleLLMCompletion } from './completion' import { generateStatusPrompt } from './prompt' -export async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType): Promise { +export async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: Logger): Promise { logger .withFields({ user: event.data.discord?.guildMember, diff --git a/services/minecraft/src/libs/mineflayer/base-agent.ts b/services/minecraft/src/libs/mineflayer/base-agent.ts index ff5597537..d56130b6e 100644 --- a/services/minecraft/src/libs/mineflayer/base-agent.ts +++ b/services/minecraft/src/libs/mineflayer/base-agent.ts @@ -1,4 +1,5 @@ import type { PlanStep } from '../../agents/planning/adapter' +import type { Logger } from '../../utils/logger' import type { Action } from './action' import { useLogg } from '@guiiai/logg' @@ -59,7 +60,7 @@ export abstract class AbstractAgent extends EventEmitter3 implements BaseAgent { public readonly name: string protected initialized: boolean - protected logger: ReturnType + protected logger: Logger // protected actionManager: ReturnType // protected conversationStore: ReturnType diff --git a/services/minecraft/src/libs/mineflayer/components.ts b/services/minecraft/src/libs/mineflayer/components.ts index cf6b0631b..67fa57890 100644 --- a/services/minecraft/src/libs/mineflayer/components.ts +++ b/services/minecraft/src/libs/mineflayer/components.ts @@ -1,14 +1,14 @@ import type { Logg } from '@guiiai/logg' import type { Handler } from './types' -import { useLogg } from '@guiiai/logg' +import { useLogger } from '../../utils/logger' export class Components { private components: Map = new Map() private logger: Logg constructor() { - this.logger = useLogg('Components').useGlobalConfig() + this.logger = useLogger() } register(componentName: string, component: Handler) { diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index a912d13ea..89457b4f8 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -1,5 +1,4 @@ import process, { exit } from 'node:process' -import { useLogg } from '@guiiai/logg' import { Client } from '@proj-airi/server-sdk' import MineflayerArmorManager from 'mineflayer-armor-manager' import { loader as MineflayerAutoEat } from 'mineflayer-auto-eat' @@ -13,9 +12,7 @@ import { config, initEnv } from './composables/config' import { createNeuriAgent } from './composables/neuri' import { LLMAgent } from './libs/llm-agent' import { wrapPlugin } from './libs/mineflayer' -import { initLogger } from './utils/logger' - -const logger = useLogg('main').useGlobalConfig() +import { initLogger, useLogger } from './utils/logger' async function main() { initLogger() // todo: save logs to file @@ -50,6 +47,6 @@ async function main() { } main().catch((err: Error) => { - logger.errorWithError('Fatal error', err) + useLogger().errorWithError('Fatal error', err) exit(1) }) diff --git a/services/minecraft/src/plugins/echo.ts b/services/minecraft/src/plugins/echo.ts index 67b4932b3..76b8b4029 100644 --- a/services/minecraft/src/plugins/echo.ts +++ b/services/minecraft/src/plugins/echo.ts @@ -1,11 +1,10 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' -import { useLogg } from '@guiiai/logg' - import { ChatMessageHandler } from '../libs/mineflayer/message' +import { useLogger } from '../utils/logger' export function Echo(): MineflayerPlugin { - const logger = useLogg('Echo').useGlobalConfig() + const logger = useLogger() return { spawned(mineflayer) { diff --git a/services/minecraft/src/plugins/follow.ts b/services/minecraft/src/plugins/follow.ts index 851b9bfd7..8420a7dd0 100644 --- a/services/minecraft/src/plugins/follow.ts +++ b/services/minecraft/src/plugins/follow.ts @@ -1,10 +1,11 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' -import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' +import { useLogger } from '../utils/logger' + export function FollowCommand(options?: { rangeGoal: number }): MineflayerPlugin { - const logger = useLogg('follow').useGlobalConfig() + const logger = useLogger() const { goals, Movements } = pathfinderModel return { diff --git a/services/minecraft/src/plugins/pathfinder.ts b/services/minecraft/src/plugins/pathfinder.ts index cb2ae6ae7..725d32c6c 100644 --- a/services/minecraft/src/plugins/pathfinder.ts +++ b/services/minecraft/src/plugins/pathfinder.ts @@ -1,15 +1,16 @@ import type { Context } from '../libs/mineflayer' import type { MineflayerPlugin } from '../libs/mineflayer/plugin' -import { useLogg } from '@guiiai/logg' import pathfinderModel from 'mineflayer-pathfinder' +import { useLogger } from '../utils/logger' + const { goals, Movements } = pathfinderModel export function PathFinder(options?: { rangeGoal: number }): MineflayerPlugin { return { created(bot) { - const logger = useLogg('pathfinder').useGlobalConfig() + const logger = useLogger() let defaultMove: any diff --git a/services/minecraft/src/plugins/status.ts b/services/minecraft/src/plugins/status.ts index b1401f649..4b9cfc995 100644 --- a/services/minecraft/src/plugins/status.ts +++ b/services/minecraft/src/plugins/status.ts @@ -1,11 +1,11 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin' -import { useLogg } from '@guiiai/logg' +import { useLogger } from '../utils/logger' export function Status(): MineflayerPlugin { return { created(bot) { - const logger = useLogg('status').useGlobalConfig() + const logger = useLogger() logger.log('Loading status component') bot.onCommand('status', () => { diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts index 07fe945e3..c7ff182eb 100644 --- a/services/minecraft/src/skills/actions/collect-block.ts +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -1,15 +1,15 @@ import type { Block } from 'prismarine-block' import type { Mineflayer } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' import pathfinder from 'mineflayer-pathfinder' +import { useLogger } from '../../utils/logger' import { breakBlockAt } from '../blocks' import { getNearestBlocks } from '../world' import { ensurePickaxe } from './ensure' import { pickupNearbyItems } from './world-interactions' -const logger = useLogg('Action:CollectBlock').useGlobalConfig() +const logger = useLogger() function isMessagable(err: unknown): err is { message: string } { return (err instanceof Error || (typeof err === 'object' && !!err && 'message' in err && typeof err.message === 'string')) diff --git a/services/minecraft/src/skills/actions/ensure.ts b/services/minecraft/src/skills/actions/ensure.ts index cf22b9dfc..826372218 100644 --- a/services/minecraft/src/skills/actions/ensure.ts +++ b/services/minecraft/src/skills/actions/ensure.ts @@ -1,7 +1,6 @@ import type { Mineflayer } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' - +import { useLogger } from '../../utils/logger' import { getItemId } from '../../utils/mcdata' import { craftRecipe } from '../crafting' import { moveAway } from '../movement' @@ -12,8 +11,7 @@ import { getItemCount } from './inventory' // Constants for crafting and gathering const PLANKS_PER_LOG = 4 const STICKS_PER_PLANK = 2 - -const logger = useLogg('Action:Ensure').useGlobalConfig() +const logger = useLogger() // Helper function to ensure a crafting table export async function ensureCraftingTable(mineflayer: Mineflayer): Promise { diff --git a/services/minecraft/src/skills/actions/gather-wood.ts b/services/minecraft/src/skills/actions/gather-wood.ts index d757eb461..a163ab15e 100644 --- a/services/minecraft/src/skills/actions/gather-wood.ts +++ b/services/minecraft/src/skills/actions/gather-wood.ts @@ -1,14 +1,13 @@ import type { Mineflayer } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' - import { sleep } from '../../utils/helper' +import { useLogger } from '../../utils/logger' import { breakBlockAt } from '../blocks' import { goToPosition, moveAway } from '../movement' import { getNearestBlocks } from '../world' import { pickupNearbyItems } from './world-interactions' -const logger = useLogg('Action:GatherWood').useGlobalConfig() +const logger = useLogger() /** * Gather wood blocks nearby to collect logs. diff --git a/services/minecraft/src/skills/actions/inventory.ts b/services/minecraft/src/skills/actions/inventory.ts index 1614d9d1d..f2076037c 100644 --- a/services/minecraft/src/skills/actions/inventory.ts +++ b/services/minecraft/src/skills/actions/inventory.ts @@ -1,12 +1,11 @@ import type { Item } from 'prismarine-item' import type { Mineflayer } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' - +import { useLogger } from '../../utils/logger' import { goToPlayer, goToPosition } from '../movement' import { getNearestBlock } from '../world' -const logger = useLogg('Action:Inventory').useGlobalConfig() +const logger = useLogger() /** * Equip an item from the bot's inventory. diff --git a/services/minecraft/src/skills/actions/world-interactions.ts b/services/minecraft/src/skills/actions/world-interactions.ts index fbfb5e163..0e5946353 100644 --- a/services/minecraft/src/skills/actions/world-interactions.ts +++ b/services/minecraft/src/skills/actions/world-interactions.ts @@ -2,15 +2,15 @@ import type { Bot } from 'mineflayer' import type { Block } from 'prismarine-block' import type { Mineflayer } from '../../libs/mineflayer' -import { useLogg } from '@guiiai/logg' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import { sleep } from '../../utils/helper' +import { useLogger } from '../../utils/logger' import { getNearestBlock, makeItem } from '../../utils/mcdata' import { goToPosition } from '../movement' -const logger = useLogg('Action:WorldInteractions').useGlobalConfig() +const logger = useLogger() export async function placeBlock( mineflayer: Mineflayer, diff --git a/services/minecraft/src/skills/base.ts b/services/minecraft/src/skills/base.ts index a2721f1e0..cda7ad75c 100644 --- a/services/minecraft/src/skills/base.ts +++ b/services/minecraft/src/skills/base.ts @@ -1,8 +1,8 @@ import type { Mineflayer } from '../libs/mineflayer' -import { useLogg } from '@guiiai/logg' +import { useLogger } from '../utils/logger' -const logger = useLogg('skills').useGlobalConfig() +const logger = useLogger() /** * Log a message to the context's output buffer diff --git a/services/minecraft/src/skills/crafting.ts b/services/minecraft/src/skills/crafting.ts index 8ca439763..2896c0807 100644 --- a/services/minecraft/src/skills/crafting.ts +++ b/services/minecraft/src/skills/crafting.ts @@ -3,15 +3,14 @@ import type { Item } from 'prismarine-item' import type { Recipe } from 'prismarine-recipe' import type { Mineflayer } from '../libs/mineflayer' -import { useLogg } from '@guiiai/logg' - +import { useLogger } from '../utils/logger' import { getItemId, getItemName } from '../utils/mcdata' import { ensureCraftingTable } from './actions/ensure' import { collectBlock, placeBlock } from './blocks' import { goToNearestBlock, goToPosition, moveAway } from './movement' import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './world' -const logger = useLogg('Skill:Crafting').useGlobalConfig() +const logger = useLogger() /* Possible Scenarios: diff --git a/services/minecraft/src/skills/movement.ts b/services/minecraft/src/skills/movement.ts index f73a5ba68..2225e1946 100644 --- a/services/minecraft/src/skills/movement.ts +++ b/services/minecraft/src/skills/movement.ts @@ -1,16 +1,16 @@ import type { Entity } from 'prismarine-entity' import type { Mineflayer } from '../libs/mineflayer' -import { useLogg } from '@guiiai/logg' import { randomInt } from 'es-toolkit' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' import { sleep } from '../utils/helper' +import { useLogger } from '../utils/logger' import { log } from './base' import { getNearestBlock, getNearestEntityWhere } from './world' -const logger = useLogg('Skill:Movement').useGlobalConfig() +const logger = useLogger() const { goals, Movements } = pathfinder export async function goToPosition( diff --git a/services/minecraft/src/utils/logger.ts b/services/minecraft/src/utils/logger.ts index 3a689bb7b..8155023a8 100644 --- a/services/minecraft/src/utils/logger.ts +++ b/services/minecraft/src/utils/logger.ts @@ -1,5 +1,7 @@ import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +export type Logger = ReturnType + export function initLogger() { setGlobalLogLevel(LogLevel.Debug) setGlobalFormat(Format.Pretty) @@ -7,3 +9,19 @@ export function initLogger() { const logger = useLogg('logger').useGlobalConfig() logger.log('Logger initialized') } + +/** + * Get logger instance with directory name and filename + * @returns logger instance configured with "directoryName/filename" + */ +export function useLogger() { + const stack = new Error('logger').stack + const caller = stack?.split('\n')[2] + + // Match the parent directory and filename without extension + const match = caller?.match(/\/([^/]+)\/([^/]+?)\.[jt]s/) + const dirName = match?.[1] || 'unknown' + const fileName = match?.[2] || 'unknown' + + return useLogg(`${dirName}/${fileName}`).useGlobalConfig() +}