This commit is contained in:
2026-07-12 17:26:59 -04:00
parent 60663b8d4a
commit ba1bef8ff9
25 changed files with 158880 additions and 1529 deletions
+227 -45
View File
@@ -71,7 +71,11 @@ class CJbot{
this._taskQueue = [];
this._connecting = false;
this._idleTimeout = args.idleTimeout || 30000;
this._goToLock = false;
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
@@ -245,8 +249,24 @@ class CJbot{
this.on('end', async (...args)=>{
console.error('CJbot.__autoReConnect on end', args)
await sleep(30000)
this.connect()
// 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;
}
});
@@ -371,6 +391,13 @@ class CJbot{
_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);
}
@@ -492,7 +519,7 @@ class CJbot{
const cmdDef = this.commands[cmd];
if(this.commandLock && !cmdDef.ignoreLock){
this.whisper(from, `cool down, try again in ${this.commandCollDownTime/1000} seconds...`);
this.whisper(from, `I'm busy with another command right now, please try again shortly...`);
return ;
}
@@ -547,7 +574,7 @@ class CJbot{
findChestBySign(text){
return this.bot.findBlock({
point: this.findBlockBySign(text).position,
// maxDistance: 1,
maxDistance: 4,
useExtraInfo: true,
matching: block => block.name === 'chest'
});
@@ -560,7 +587,7 @@ class CJbot{
return distance <= range+.9;
}
playerWithinBlock(player, block, range){
playerWithinBlock(player, block, range){
let playerData = this.bot.players[player];
if(!playerData || !playerData.entity) return; // Skip if no entity info
@@ -575,43 +602,158 @@ playerWithinBlock(player, block, range){
return distance < range;
}
// Interrupt current movement so trade/storage commands can run
// 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;
try {
let range = options.range || 2;
let block = this.__blockOrVec(options.where);
console.log('[goTo] moving to', block.position, 'range', range);
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);
while(!this.isWithinRange(block.position, range)){
try{
console.log('[goTo] loop: isMoving=', this.bot.pathfinder.isMoving(), 'inRange=', this.isWithinRange(block.position, range));
if(this.bot.pathfinder.isMoving()){
await sleep(500);
continue;
}
await this.bot.pathfinder.goto(
new GoalNear(...block.position.toArray(), range)
);
}catch(error){
console.log('CJbot.goTo while loop error:', error)
await sleep(500);
}
}
const startTime = Date.now();
let lastPos = this.bot.entity.position.clone();
let stuckTime = 0;
let recoveryCount = 0;
return true;
} finally {
this._goToLock = false;
}
}
// 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
@@ -643,26 +785,66 @@ playerWithinBlock(player, block, range){
}
async openContainer(block){
let count = 0;
block = this.__blockOrVec(block);
let window;
while(!this.bot.currentWindow){
// 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{
window = await this.bot.openContainer(block);
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;
}
if(this.bot.currentWindow?.title){
break;
}
this.bot.removeAllListeners('windowOpen');
// 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);
if(count++ == 3) throw 'Block wont open';
}
return this.bot.currentWindow;
}
async openCraftingTable(block){