refactor: life cycle

This commit is contained in:
RainbowBird
2025-01-05 19:04:57 +08:00
parent d4e8a00132
commit 469abd58e9
6 changed files with 343 additions and 407 deletions
+35 -37
View File
@@ -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<string, ComponentContext>()
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<string, ComponentLifecycle>()
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,
}
}
+210 -364
View File
@@ -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<typeof useLogg>) {
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<Bot['findBlock']> => {
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<typeof useLogg>, utils: ReturnType<typeof createItemUtils>) {
const { itemToString, findItemByName, listItems } = utils
const handleChestCommands = async (window: Window, command: string[]): Promise<void> => {
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<ContainerContext | null> => {
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<typeof useLogg>, containerManager: ReturnType<typeof createContainerManager>) {
const { findNearbyBlock } = containerManager
const openFurnace = async (): Promise<void> => {
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<void> => {
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')
},
}
}
+2 -2
View File
@@ -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
@@ -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)
},
}
}
@@ -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()
+7 -2
View File
@@ -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()