'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'); 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; // 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; /*// 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); this._setupAntiStuck(); // 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) await sleep(30000) this.connect() }); 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; 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, `cool down, try again in ${this.commandCollDownTime/1000} seconds...`); 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: 1, 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; } // Global anti-stuck system: monitors every physics tick while pathfinder is // moving. If the bot hasn't moved for ~600ms (12 ticks), it stops the // pathfinder and nudges the bot in an alternating direction (back/left/right) // to free it from corners. This works for ALL pathfinder movement globally. _antiStuckNudging = false; _antiStuckNudging = false; _setupAntiStuck() { let lastPos = null; let stuckTicks = 0; this.bot.on('physicsTick', () => { if (!this.bot.pathfinder.isMoving() || this._antiStuckNudging) return; const pos = this.bot.entity.position; if (!lastPos) { lastPos = pos.clone(); return; } const dist = lastPos.distanceTo(pos); // On 20 TPS / LAN, any distance under 0.01 is a hard collision if (dist < 0.01) { stuckTicks++; if (stuckTicks >= 15) { // 750ms is plenty of time on a 20 TPS server console.log(`[AntiStuck] LAN-Precision Reset at ${pos.x.toFixed(2)}, ${pos.z.toFixed(2)}`); // KILL THE VIBRATION: // If we don't clear control states, the bot keeps 'pushing' into the corner this.bot.clearControlStates(); this.bot.pathfinder.stop(); this.bot.entity.velocity.set(0, 0, 0); // SNAP TO ABSOLUTE CENTER const newX = Math.floor(pos.x) + 0.5; const newZ = Math.floor(pos.z) + 0.5; // Use a 'hard' teleport to break the physics loop this.bot.entity.position.x = newX; this.bot.entity.position.z = newZ; this._antiStuckNudging = true; setTimeout(() => { this._antiStuckNudging = false; }, 300); stuckTicks = 0; lastPos = null; } } else { stuckTicks = 0; lastPos = pos.clone(); } }); } async goTo(options) { let range = options.range || 2; let block = this.__blockOrVec(options.where); let retries = 0; let lastPos = this.bot.entity.position.clone(); console.log(`[goTo] Starting path to ${block.position} with range ${range}`); // Listen for path updates to detect partial paths const pathUpdateHandler = (results) => { console.log(`[Pathfinder] New path found. Length: ${results.path.length} | Status: ${results.status}`); }; this.bot.on('path_update', pathUpdateHandler); try { while (!this.isWithinRange(block.position, range)) { try { await this.bot.pathfinder.goto(new GoalNear(...block.position.toArray(), range)); console.log(`[goTo] Successfully reached goal.`); break; } catch (error) { retries++; const errorMsg = error.message || error; console.log(`%c[goTo] Error on Attempt ${retries}: ${errorMsg}`, "color: red;"); this.bot.pathfinder.setGoal(null); const botPos = this.bot.entity.position; const dist = botPos.distanceTo(block.position); console.log(`[Debug] Target Block: ${this.bot.blockAt(block.position)?.name} | Bot Pos: ${botPos} | Distance: ${dist.toFixed(2)}`); // If we're within extended range on a partial path, call it good enough if (dist <= range + 4) { console.log(`[goTo] Close enough (${dist.toFixed(2)} <= ${range + 4}), accepting partial path`); break; } // Detect stuck-ness: compare position to last attempt const moved = botPos.distanceTo(lastPos); console.log(`[goTo] Moved ${moved.toFixed(2)} blocks this attempt`); lastPos = botPos.clone(); // Visual refresh — look around to reveal more blocks console.log(`[goTo] Performing 360 refresh...`); for (let i = 0; i < 4; i++) { await this.bot.look(this.bot.entity.yaw + Math.PI / 2, 0, true); await sleep(150); } // Every 3 retries, try unblocking via perpendicular movement if (retries % 3 === 0) { console.log(`[goTo] Attempting to unstick via perpendicular waypoint`); this.bot.pathfinder.setGoal(null); const yaw = Math.atan2( block.position.z - botPos.z, block.position.x - botPos.x ); const perpAngles = [yaw + Math.PI / 2, yaw - Math.PI / 2, yaw + Math.PI]; for (const perpYaw of perpAngles) { const dx = Math.cos(perpYaw) * 3; const dz = Math.sin(perpYaw) * 3; const waypoint = botPos.offset(dx, 0, dz); try { this.bot.pathfinder.setGoal(new GoalNear(waypoint.x, waypoint.y, waypoint.z, 1), true); await sleep(1500); this.bot.pathfinder.setGoal(null); console.log(`[goTo] Perpendicular waypoint reached`); break; } catch (e) { // Try next direction } } // Small backward nudge this.bot.setControlState('back', true); await sleep(300); this.bot.clearControlStates(); await sleep(200); } await sleep(200); if (retries >= 20) { console.log(`[goTo] Failed after ${retries} attempts — giving up`); return false; } } } } finally { this.bot.removeListener('path_update', pathUpdateHandler); } return true; } 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){ let count = 0; block = this.__blockOrVec(block); let window; while(!this.bot.currentWindow){ try{ window = await this.bot.openContainer(block); }catch(error){ if(!error.message.includes('Event windowOpen did not fire within timeout')) throw error; } if(this.bot.currentWindow?.title){ break; } this.bot.removeAllListeners('windowOpen'); await sleep(1500); if(count++ == 3) throw 'Block wont open'; } return this.bot.currentWindow; } 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};