'use strict'; const mineflayer = require('mineflayer'); const minecraftData = require('minecraft-data'); const { pathfinder, Movements, goals: { GoalNear } } = require('mineflayer-pathfinder'); const Vec3 = require('vec3'); const {sleep} = require('../utils'); // The server sends entity_velocity packets with undefined fields (e.g. // packet.velocity.x is undefined). This causes fromNotchVelocity to return // Vec3(NaN,NaN,NaN), which corrupts the bot's velocity, which the physics tick // uses to update position → NaN position → physics.js deadlocks permanently. const _conv = require('mineflayer/lib/conversions'); const _fromNotchVelocity = _conv.fromNotchVelocity; _conv.fromNotchVelocity = function(vel) { if (!Number.isFinite(vel.x) || !Number.isFinite(vel.y) || !Number.isFinite(vel.z)) return new Vec3(0, 0, 0); return _fromNotchVelocity(vel); }; class CJbot{ isReady = false; listeners = {}; // Holds the minimum cool down time for chat messages nextChatTime = 1500; // Prevents executing commands while one is running commandLock = false; commandCollDownTime = 1500; // Holds the list of commands this bot can execute commands = {}; combatTag = false; doLogOut = false; // Map of all the bots currently in use static bots = {}; // Forces the code to wait while until the bot is loaded before moving on to // the next static __addLock = false; // Holds a list of bots that still needs to be connect static __toConnect = []; constructor(args){ // Friendly name to access the bot in `CJbot.bots` this.name = args.name || args.username; this.username = args.username; this.password = args.password; this.host = args.host; this.auth = args.auth || 'microsoft'; this.version = args.version || '1.20.1'; this.hasAi = args.hasAi; // this.pluginsWanted = args.plugins || {}; // States if the bot should connect when its loaded this.autoReConnect = 'autoConnect' in args ? args.autoReConnect : true; this.autoConnect = 'autoConnect' in args ? args.autoConnect : true; // On-demand mode: bot stays offline until ensureConnected() is called this.onDemand = args.onDemand || false; this._idleTimer = null; this._taskQueue = []; this._connecting = false; this._idleTimeout = args.idleTimeout || 30000; this._goToLock = false; // Bumped by interruptTask; in-flight goTo calls compare against the // value they captured at start and bail out when it changes. this._interruptGen = 0; // If we want the be always connected, kick off the function to auto // reconnect if(this.autoReConnect && !this.onDemand) this.__autoReConnect() } connect(){ console.log('CJbot.connect'); return new Promise((resolve, reject) =>{ try{ this.bot = mineflayer.createBot({ host: this.host, username: this.username, password: this.password, version: this.version, auth: this.auth, }); // If an error happens before the login event, toss an error back to // the caller of the function let onError = this.bot.on('error', (m)=>{ console.log('ERROR CJbot.connect on error:', this.name, m.toString()); reject(m); }) // If the connection ends before the login event, toss an error back // to the caller of the function this.bot.on('end', (reason, ...args)=>{ console.log(this.name, 'Connection ended:', reason, ...args); this.pluginUnloadAll(this.onDemand); // keepDb=true for on-demand this.isReady = false; reject(reason); }); // When the bot is ready, return to the caller success this.bot.on('spawn', async()=>{ console.log('CJbot.connect on spawn') await sleep(2000); this.__onReady(); resolve(); this._pluginsReady = this.pluginLoadAll(); }); }catch(error){ console.log('CJbot.connect Error', error); reject(error); } }); } // Wrap the once method so it works correctly with await once(event){ return new Promise((resolve, reject)=> this.bot.once(event, resolve)); } // Internal method to kick off the functionality after its loaded in the // server async __onReady(){try{ this.bot.loadPlugin(pathfinder); this.mcData = minecraftData(this.bot.version); this.defaultMove = new Movements(this.bot, this.mcData); this.defaultMove.canDig = false; this.defaultMove.scafoldingBlocks = []; /*// Make pathfinder avoid routing through chests/shulkers if (!this.defaultMove.blocksCost) this.defaultMove.blocksCost = {}; const avoidBlocks = ['chest', 'trapped_chest', 'ender_chest', 'shulker_box', 'white_shulker_box', 'orange_shulker_box', 'magenta_shulker_box', 'light_blue_shulker_box', 'yellow_shulker_box', 'lime_shulker_box', 'pink_shulker_box', 'gray_shulker_box', 'light_gray_shulker_box', 'cyan_shulker_box', 'purple_shulker_box', 'blue_shulker_box', 'brown_shulker_box', 'green_shulker_box', 'red_shulker_box', 'black_shulker_box']; for (const name of avoidBlocks) { const block = this.mcData.blocksByName[name]; if (block) this.defaultMove.blocksCost[block.id] = 100; }*/ // 1. Disable sprinting globally. This is the #1 cause of clipping // on high-TPS servers because the bot moves too fast for its own // rotation speed. this.defaultMove.allowSprinting = false; this.defaultMove.entityCost = 100; this.defaultMove.allow1by1towers = false; // 3. Entity Intersections // Set this to true to make the bot more aware of collision boxes this.defaultMove.allowEntityDetection = true; this.bot.pathfinder.setMovements(this.defaultMove); // Add the listeners to the bot. We do this so if the bot loses // connection, the mineflayer instance will also be lost. this.isReady = true; this.__error(); this.__startListeners(); // Call the internal listeners when the bot is ready for(let callback of this.listeners.onReady || []){ callback.call(this); } console.log('Bot is ready', this.bot.entity.username, this.username); // Start chat listeners this.__listen(); this.bot.on('title', (...args)=>console.log('on title', args)) }catch(error){ console.error('minecraft.js | __onReady | ', this.name, ' ', error); }} __startListeners(){ for(let event in this.listeners){ console.log('__adding listeners', event) for(let callback of this.listeners[event]){ this.bot.on(event, callback); } } } // Wrap the .on method so we can hold the listeners internally on(event, callback){ if(!this.listeners[event]) this.listeners[event] = []; this.listeners[event].push(callback); // If the bot is already loaded, add the passed listener to the // mineflayer instance if(this.isReady){ if(event === 'onReady') callback(this); else this.bot.on(event, callback); } return ()=> this.off(event, callback); } // Remove listener for events off(event, callback) { console.log('off', event, callback) if (!this.listeners[event]) return false; const index = this.listeners[event].indexOf(callback); if (index === -1) return false; this.listeners[event].splice(index, 1); // If bot is ready, also remove from the Mineflayer bot if (this.isReady) { this.bot.off(event, callback); } return true; } // Listen for ending events and call connect again __autoReConnect(){ try{ console.log('auto re-connect function') this.on('kicked', (...args)=>console.log('CJbot.__autoReConnect on kick', args)) this.on('end', async (...args)=>{ console.error('CJbot.__autoReConnect on end', args) // connect() also rejects on 'end', which used to surface as an // unhandled rejection (fatal on modern Node). Guard against // overlapping loops and retry until we get back in. if(this._reconnecting) return; this._reconnecting = true; try{ while(true){ await sleep(30000); try{ await this.connect(); break; }catch(error){ console.error('CJbot.__autoReConnect retry failed:', this.name, error?.message || error); } } }finally{ this._reconnecting = false; } }); this.on('error', (error)=>{ console.error('MC on error', error); // this.connect(); }); }catch(error){ console.error('error in __autoReConnect', error); } } __error(message){ this.bot.on('error', (error)=>{ console.error(`ERROR!!! MC bot ${this.username} on ${this.host} had an error:\n`, error) }); } quit(force){ if(!this.combatTag || force){ this.bot.quit(); }else{ console.log('Logout prevented due to combatTag'); this.doLogOut = true; } } /* Plugins */ static plungins = {}; static pluginAdd(cls){ this.plungins[cls.name] = cls; // Queue for web registration if plugin has web support if (typeof cls.createRouter === 'function' || cls.webUI) { const webServer = require('../controller/web-server'); webServer.queuePlugin(cls); } } plunginsLoaded = {}; async pluginLoadAll(){ for(let pluginName in this.pluginsWanted){ await this.pluginLoad(pluginName, this.pluginsWanted[pluginName]); } } async pluginLoad(pluginName, opts){ console.log('CJbot.pluginLoad', pluginName) let plugin = new this.constructor.plungins[pluginName]({...opts, bot:this}) await plugin.init(); this.plunginsLoaded[pluginName] = plugin; } async pluginUnload(name){ console.log('CJbot.pluginUnload', name) if(this.plunginsLoaded[name]){ this.plunginsLoaded[name].unload(); delete this.plunginsLoaded[name]; console.log('CJbot.pluginUnload', name, 'done'); return true; } } async pluginUnloadAll(keepDb = false){ console.log('CJbot.pluginUnloadAll', keepDb ? '(keepDb)' : ''); for(let pluginName in this.plunginsLoaded){ console.log('CJbot.pluginUnloadAll loop', pluginName) try{ await this.plunginsLoaded[pluginName].unload(keepDb); delete this.plunginsLoaded[pluginName]; }catch(error){ console.log('CJbot.pluginUnload loop error:', error) } } } /* On-demand lifecycle */ async ensureConnected(taskFn) { this._resetIdleTimer(); if (this.isReady) return await taskFn(); return new Promise((resolve, reject) => { this._taskQueue.push({ fn: taskFn, resolve, reject }); if (this._connecting) return; this._connecting = true; this.connect().then(async () => { this._connecting = false; // Wait for plugins to finish loading (pluginLoadAll runs after spawn) await this._pluginsReady; await this._drainTaskQueue(); }).catch((error) => { this._connecting = false; const queue = this._taskQueue.splice(0); for (const task of queue) task.reject(error); }); }); } async _drainTaskQueue() { while (this._taskQueue.length > 0) { const task = this._taskQueue.shift(); try { task.resolve(await task.fn()); } catch (error) { task.reject(error); } this._resetIdleTimer(); } } _resetIdleTimer() { if (!this.onDemand) return; if (this._idleTimer) clearTimeout(this._idleTimer); this._idleTimer = setTimeout(() => this._idleDisconnect(), this._idleTimeout); } _idleDisconnect() { if (!this.isReady) return; // A registered task (scan, organize, withdraw, trade) is still running — // disconnecting now would abandon shulkers and leak locks if (this._currentTask) { console.log(`${this.name}: Idle timer fired mid-task (${this._currentTask.task}), rescheduling`); this._resetIdleTimer(); return; } console.log(`${this.name}: Idle timeout, disconnecting on-demand bot`); this.quit(true); } /* Chat and messaging*/ __listen(){ this.bot.on('chat', (from, message)=>{ // Ignore messages from this bot if(from === this.bot.entity.username) return; // Filter messages to this bot if(message.startsWith(this.bot.entity.username)){ this.__doCommand( from, message.replace(`${this.bot.entity.username} ` , ' ').trim() ) } }); this.bot.on('whisper', (from, message)=>{ this.__doCommand( from, message.replace(`${this.bot.entity.username} ` , ' ').trim() ) }); this.bot.on('message', (message, type)=>{ if(message.toString().includes(' invited you to teleport to him.')){ // teleport invite console.log('found teleport', message.toString().split(' ')[0]) this.__doCommand(message.toString().split(' ')[0], '.invite'); } if(message.toString().includes(' wants to trade with you!')){ // teleport invite console.log('found Trade', message.toString().split(' ')[0]) this.__doCommand(message.toString().split(' ')[0], '.trade'); } if(message.toString().includes('You are combat tagged by')){ try{ this.combatTag = true; console.log('was attacked by') let attacker = message.toString().split('. ')[0].replace('You are combat tagged by ', '') console.log('was attacked by', attacker) // teleport invite this.whisper(attacker, 'Please do not attack me, I am a bot.') }catch(error){ console.log('error!!!!!!', error) } } if(message.toString().includes('You are no longer in combat. You may now logout.')){ this.combatTag = false if(this.doLogOut){ this.quit() } } }); } __chatCoolDown(){ return Math.floor(Math.random() * (3000 - 2000) + 2000); } async say(...messages){ for(let message of messages){ (async (message)=>{ if(this.nextChatTime > Date.now()){ await sleep(this.nextChatTime-Date.now()+1) } this.bot.chat(message); })(message); this.nextChatTime = Date.now() + this.__chatCoolDown(); } } async sayAiSafe(...messages){ for(let message of messages){ if(message.startsWith('/') && !(message.startsWith('/msg') || message.startsWith('/help'))){ console.log('bot tried to execute bad command', message); message = '.'+message; } await this.say(message); } } async whisper(to, ...messages){ await this.say(...messages.map(message=>`/msg ${to} ${message}`)); } /* Commands */ async __unLockCommand(time){ await sleep(this.commandCollDownTime); this.commandLock = false; } __reduceCommands(from){ return Object.keys(this.commands).filter(command =>{ if (this.commands[command].allowed && !this.commands[command].allowed.includes(from)) return false; return true; }); } async __doCommand(from, command){try{ let [cmd, ...parts] = command.split(/\s+/); if(!this.__reduceCommands(from).includes(cmd)) return; const cmdDef = this.commands[cmd]; if(this.commandLock && !cmdDef.ignoreLock){ this.whisper(from, `I'm busy with another command right now, please try again shortly...`); return ; } if(!cmdDef.ignoreLock) this.commandLock = true; try{ await cmdDef.function.call(this, from, ...parts); }catch(error){ this.whisper(from, `The command encountered an error.`); this.whisper(from, `ERROR: ${error}`); console.error(`Chat command error on ${cmd} from ${from}\n`, error); } if(!cmdDef.ignoreLock) this.__unLockCommand(); }catch(error){ console.error('minecraft.js | __doCommand |', this.name, ' ', error) }} addCommand(name, obj){ if(this.commands[name]) return false; this.commands[name] = obj; } getPlayers(){ for (let [username, value] of Object.entries(this.bot.players)){ value.lvl = Number(value.displayName.extra[0].text) } return this.bot.players; } /* Actions */ __blockOrVec(thing){ if(thing instanceof Vec3.Vec3) return this.bot.blockAt(thing); if(thing.constructor && thing.constructor.name === 'Block') return thing; throw new Error('Not supported block identifier'); } findBlockBySign(text){ return this.bot.findBlock({ useExtraInfo: true, maxDistance: 64, matching: (block)=> { if(block.name.includes('sign') && block.signText.includes(text)){ return true; } } }); } findChestBySign(text){ return this.bot.findBlock({ point: this.findBlockBySign(text).position, maxDistance: 4, useExtraInfo: true, matching: block => block.name === 'chest' }); } isWithinRange(target, range=2){ const botPos = this.bot.entity.position; const distance = botPos.distanceTo(target); return distance <= range+.9; } playerWithinBlock(player, block, range){ let playerData = this.bot.players[player]; if(!playerData || !playerData.entity) return; // Skip if no entity info // Calculate the distance between the player and the block let distance = playerData.entity.position.distanceTo(block.position); console.log('CJbot.playerWithinBlock', distance, range, distance < range) if(!range){ return distance; } return distance < range; } // Interrupt current movement so trade/storage commands can run async interruptTask(from) { this._interrupted = true; this._interruptGen++; this._currentTask = null; try { this.bot.pathfinder.stop(); } catch (e) { /* pathfinder may not be moving */ } this.bot.clearControlStates(); } registerTask(source, task, fn) { this._interrupted = false; this._currentTask = { source, task, fn }; } clearTask() { this._currentTask = null; } wasInterrupted() { return this._interrupted; } async goTo(options) { while (this._goToLock) await new Promise(r => setTimeout(r, 50)); this._goToLock = true; // Captured after acquiring the lock: an interrupt kills goTo calls that // were already running, not the interrupting command's own movement. const gen = this._interruptGen; try { let range = options.range || 2; let block = this.__blockOrVec(options.where); let timeout = options.timeout || 60000; const goal = block.position; console.log('[goTo] target:', goal.toArray(), 'range:', range); const startTime = Date.now(); let lastPos = this.bot.entity.position.clone(); let stuckTime = 0; let recoveryCount = 0; // Fire-and-forget: starts pathfinder, never awaited. // PathStopped/GoalChanged/NoPath are expected during recovery. const startPathfinder = () => { this.bot.pathfinder.goto( new GoalNear(...goal.toArray(), range) ).catch(e => { if (e.name !== 'PathStopped' && e.name !== 'GoalChanged' && e.name !== 'NoPath') console.log('[goTo] goto rejected:', e.name, e.message); }); }; startPathfinder(); while (!this.isWithinRange(goal, range)) { if (this._interruptGen !== gen) { this.bot.pathfinder.stop(); console.log('[goTo] interrupted'); return false; } const elapsed = Date.now() - startTime; if (elapsed > timeout) { this.bot.pathfinder.stop(); console.log('[goTo] timed out after', timeout, 'ms'); return false; } const currentPos = this.bot.entity.position; const moved = currentPos.distanceTo(lastPos); if (moved > 0.2) { // Making progress stuckTime = 0; recoveryCount = Math.max(0, recoveryCount - 1); lastPos = currentPos.clone(); } else { // Not moving stuckTime += 400; } // Pathfinder legitimately pauses during jumps, replans, and corner // turns — only treat 2s+ of zero movement as actually stuck. if (stuckTime >= 2000) { console.log('[goTo] stuck, recovery=', recoveryCount); await this._recoverFromStuck(recoveryCount); stuckTime = 0; recoveryCount++; lastPos = this.bot.entity.position.clone(); if (recoveryCount > 8) { console.log('[goTo] giving up after', recoveryCount, 'recoveries'); return false; } startPathfinder(); } if (!this.bot.pathfinder.isMoving()) { startPathfinder(); } await sleep(400); } this.bot.pathfinder.stop(); console.log('[goTo] arrived'); return true; } finally { this._goToLock = false; } } // Like goTo, but failure to arrive aborts the caller instead of letting it // operate on a container it never reached. async goToMust(options) { const arrived = await this.goTo(options); if (!arrived) { const pos = options.where?.position || options.where; throw new Error(`Could not reach ${pos} (interrupted, stuck, or timed out)`); } return true; } // Simple unstuck: back up N blocks, strafe N blocks (alternating left/right), // then let the main loop retry pathfinding to the goal. async _recoverFromStuck(failureCount) { this.bot.pathfinder.stop(); this.bot.clearControlStates(); await sleep(50); // Cap at 3 blocks — backing way up just walks the bot away from the // goal and into other obstacles. No jump: jumping while reversing // climbs the bot onto chests and wedges it into corners. const n = Math.min(failureCount + 1, 3); const strafeDir = failureCount % 2 === 0 ? 'left' : 'right'; const blockTime = 250; // ms per block (walking speed ~4.3 blocks/sec) console.log('[goTo] recover: back', n, 'strafe', n, strafeDir); // Back up N blocks this.bot.setControlState('back', true); await sleep(n * blockTime); this.bot.setControlState('back', false); await sleep(50); // Strafe N blocks (alternating direction) this.bot.setControlState(strafeDir, true); await sleep(n * blockTime); this.bot.setControlState(strafeDir, false); this.bot.clearControlStates(); await sleep(100); } async goToReturn(options){ let here = this.bot.entity.position; let hereYaw = this.bot.entity.yaw await this.goTo(options); return async () =>{ await this.goTo({where: here, range: 0}, true); await sleep(500); await this.bot.look(Math.floor(hereYaw), 0); } } async __nextContainerSlot(window, item) { let firstEmptySlot = false; await window.containerItems(); for(let slot in window.slots.slice(0, window.inventoryStart)){ if(window.slots[slot] === null ){ if(!Number.isInteger(firstEmptySlot)) firstEmptySlot = Number(slot); continue; } if(item.type === window.slots[slot].type && window.slots[slot].count < window.slots[slot].stackSize){ return slot; } } return firstEmptySlot; } async openContainer(block){ block = this.__blockOrVec(block); // A window left open by a failed earlier operation would otherwise be // returned as-is below — for the wrong container, with wrong slot // indexes. Close it and start clean. if(this.bot.currentWindow){ console.log('CJbot.openContainer: closing stale window', this.bot.currentWindow.title); try{ this.bot.closeWindow(this.bot.currentWindow); }catch(error){ console.log('CJbot.openContainer: stale window close failed:', error.message); } await sleep(500); } // A chest with a solid block on top cannot open. If the obstruction is // a shulker box it's one of ours, abandoned by a failed cycle — break // it and try to collect it. Anything else is a hard, described failure // instead of four pointless retries. if(block.name.includes('chest')){ const above = this.bot.blockAt(block.position.offset(0, 1, 0)); if(above && above.boundingBox === 'block'){ if(above.name.includes('shulker_box')){ console.log(`CJbot.openContainer: chest at ${block.position} blocked by ${above.name} on top — breaking it`); try{ await this.bot.lookAt(above.position.offset(0.5, 0.5, 0.5), true); await this.bot.dig(above, 'raycast'); await sleep(300); // Try to catch the drop so the box isn't lost to despawn await this.goTo({ where: above.position, range: 0, timeout: 10000 }); await sleep(500); }catch(error){ throw new Error(`Chest at ${block.position} blocked by ${above.name} on top (recovery failed: ${error.message})`); } }else{ throw new Error(`Chest at ${block.position} won't open: blocked by ${above.name} on top`); } } } let count = 0; while(true){ // Face the container — some anti-cheat setups reject interactions // the player isn't looking at try{ await this.bot.lookAt(block.position.offset(0.5, 0.5, 0.5), true); }catch(error){ /* best effort */ } try{ const window = await this.bot.openContainer(block); if(window) return window; }catch(error){ if(!error.message.includes('Event windowOpen did not fire within timeout')) throw error; } // The open packet may have landed even though the event timed out if(this.bot.currentWindow?.title) return this.bot.currentWindow; if(++count > 3) throw new Error(`Block wont open (${block.name} at ${block.position})`); await sleep(1500); } } async openCraftingTable(block){ let count = 0; block = this.__blockOrVec(block); this.bot.activateBlock(block); let window = await this.once('windowOpen'); return window; } async checkItemsFromContainer(containerBlock, itemName, count){ let currentSlot = 0; let foundCount = 0; let window = await this.openContainer(containerBlock); for(let slot of window.slots){ if(currentSlot++ === window.inventoryStart) break;currentSlot if(!slot) continue; if(slot.name === itemName) foundCount += slot.count; } await this.bot.closeWindow(window); if(foundCount >= count) return true; } async getItemsFromChest(containerBlock, itemName, count){ let window = await this.openContainer(containerBlock); await sleep(500); // console.log('item id', this.mcData.itemsByName[itemName], this.mcData) await window.withdraw(this.mcData.itemsByName[itemName].id, null, count); await this.bot.closeWindow(window); } async getFullShulkersFromChest(chestBlock, item) { const fullShulkers = []; let window = await this.openContainer(chestBlock); let itemCount = 0 let currentSlot = 0; for(let slot of window.slots){ if(currentSlot++ === window.inventoryStart) break; if(!slot || slot.name !== 'shulker_box') continue; // console.log('slot:', slot) if(slot.nbt){ // console.log('nbt', slot.nbt, slot.nbt.value.BlockEntityTag) // console.log('BlockEntityTag:', slot.nbt.value.BlockEntityTag.value.Items.value.value) for(let shulkerSlot of slot.nbt.value.BlockEntityTag.value.Items.value.value){ console.log('shulkerSlot', shulkerSlot) if(shulkerSlot.id?.value !== `minecraft:${item}`) continue; itemCount += shulkerSlot.Count.value } if(this.bot.registry.itemsByName[item].stackSize * 27 === itemCount){ console.log('found full shulker'); this.bot.moveSlotItem(currentSlot, window.inventoryStart); break; } } } await window.close(); } async dumpToChest(block, blockName, amount) { let window = await this.openContainer(block); let items = window.slots.slice(window.inventoryStart).filter(function(item){ if(!item) return false; if(blockName && blockName !== item.name) return false; return true; }); for(let item of items){ await sleep(500); let currentSlot = Number(item.slot); if(!window.slots[currentSlot]) continue; try{ await this.bot.transfer({ window, itemType: this.mcData.itemsByName[item.name].id, sourceStart: currentSlot, sourceEnd: currentSlot+1, destStart: 0, destEnd: window.inventoryStart-1, count: amount || item.count, }) if(amount) amount -= item.count }catch(error){ console.log('error?', item.count, error.message, error); } } await sleep(1000); await this.bot.closeWindow(window); return amount ? amount : true; } } module.exports = {CJbot};