embark-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1415 @@
1
+ // Load .env from project root before anything else (Featherless API key, model)
2
+ require('./env').loadEnv()
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+
7
+ const mineflayer = require('mineflayer')
8
+ const { pathfinder, Movements, goals } = require('mineflayer-pathfinder')
9
+ const { plugin: pvp } = require('mineflayer-pvp')
10
+
11
+ const { loadMemory, rememberLocation, rememberEvent, rememberKnowledge, recallLocation } = require('./memory')
12
+ const { buildGroundedState, chatSummary, HOSTILE_MOB_NAMES } = require('./state')
13
+ const { classifyIntent, evaluateSurvival, validateLLMOutput, safeDefault, selectAutonomousGoal, detectInsult } = require('./engine')
14
+ const { queryLLM, checkOllama, getModelName } = require('./llm')
15
+ const log = require('./logger')
16
+ const { safeDig, safeCraft, safeEquip, safeConsume, safePlaceBlock, safeAttack } = require('./safeMineflayer')
17
+ const createEntityLivenessMonitor = require('./entityLiveness')
18
+ const createHealthIntegrityWatchdog = require('./healthIntegrityWatchdog')
19
+ const createFatalDesyncRecovery = require('./fatalDesyncRecovery')
20
+ const createMovementController = require('./movementController')
21
+ const createGoalRegistry = require('./goalRegistry')
22
+ const createRecoveryEngine = require('./recoveryEngine')
23
+ const createEnvironmentPerception = require('./environmentPerception')
24
+ const createLocomotionRecovery = require('./locomotionRecovery')
25
+ const createDamagePipeline = require('./damagePipeline')
26
+ const createTasks = require('./tasks')
27
+ const createPositionGuard = require('./positionGuard')
28
+
29
+ // ── Configuration via env ─────────────────────────────────────────────────────
30
+ const BOT_NAME = process.env.BOT_NAME || 'Ember'
31
+ const SERVER_HOST = process.env.SERVER_HOST || 'localhost'
32
+ const SERVER_PORT = parseInt(process.env.SERVER_PORT || '25565', 10)
33
+ const MC_VERSION = process.env.MC_VERSION || '1.21.4'
34
+
35
+ const bot = mineflayer.createBot({
36
+ host: SERVER_HOST,
37
+ port: SERVER_PORT,
38
+ username: BOT_NAME,
39
+ version: MC_VERSION,
40
+ })
41
+
42
+ bot.loadPlugin(pathfinder)
43
+ bot.loadPlugin(pvp)
44
+
45
+ const liveness = createEntityLivenessMonitor(bot, log)
46
+ const movement = createMovementController(bot, goals, makeMovements, log)
47
+
48
+ // ── Internal State ─────────────────────────────────────────────────────────────
49
+ const state = {
50
+ energy: 100,
51
+ hunger: 100,
52
+ goal: 'idle',
53
+ idleTicks: 0,
54
+ followTarget: null, // username being followed (specific, not "nearest")
55
+ lastActivityAt: Date.now(), // for global activity watchdog
56
+ }
57
+
58
+ const memory = loadMemory()
59
+ const anger = new Map() // username → { level, count, lastAt }
60
+ let llmEnabled = false
61
+ let llmBusy = false
62
+ let taskBusy = false
63
+
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+ // TASK OWNERSHIP — token-discipline lifecycle
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ //
68
+ // Each runTask() invocation mints a Symbol — its ownership token — stored in
69
+ // `currentTaskToken`. Per-task state (watchdog handle, goal name, start time)
70
+ // lives in `currentTaskContext`. Every deferred callback (catch, finally,
71
+ // watchdog, retry timer) verifies via `isOwner(myToken)` that it is still the
72
+ // active task before mutating any global state, pathfinder, pvp, or controls.
73
+ //
74
+ // When a task is replaced via `replaceTask()`, the old token is superseded
75
+ // atomically (single synchronous block — JS is single-threaded, no race).
76
+ // The old task's pending Promise will resolve later, but its callbacks see
77
+ // they are no longer the owner and become observational-only — they cannot
78
+ // corrupt the new task's globals, watchdog, or movement state.
79
+ //
80
+ // This is the architectural fix for the canonical livelock where an old
81
+ // task's deferred cleanup would clear the new task's watchdog and taskBusy.
82
+ //
83
+ // Invariant: if currentTaskToken === T, then currentTaskContext.token === T
84
+ // and taskBusy === true. If currentTaskToken === null, taskBusy
85
+ // should be false. Both are restored in cancelCurrentTask().
86
+ //
87
+ let currentTaskToken = null
88
+ let currentTaskContext = null // { token, goalName, startedAt, silent, watchdog }
89
+
90
+ const { setGoal } = createGoalRegistry(
91
+ state,
92
+ () => ({ taskBusy, currentTaskToken }),
93
+ log
94
+ )
95
+
96
+ const isOwner = (token) => currentTaskToken === token
97
+
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+ // VECTOR SAFETY LAYER
100
+ // Every coordinate that flows into the pathfinder must pass through these.
101
+ // The pathfinder rejects NaN, but only AFTER it has been called — by that
102
+ // point the task has already started, the watchdog is running, and the
103
+ // failure mode is a 15-second timeout. These helpers make NaN impossible
104
+ // to construct in the first place.
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+
107
+ const VEC_EPSILON = 1e-6
108
+
109
+ const isFiniteNum = (n) => typeof n === 'number' && Number.isFinite(n)
110
+
111
+ // True iff v has finite x, y, z. Works on Vec3, plain objects, or null/undefined.
112
+ function isValidVec(v) {
113
+ return v != null && isFiniteNum(v.x) && isFiniteNum(v.y) && isFiniteNum(v.z)
114
+ }
115
+
116
+ // Returns { x, y, z } with floored coords, or null if any coord is invalid.
117
+ function safeFloor(v) {
118
+ if (!isValidVec(v)) return null
119
+ return { x: Math.floor(v.x), y: Math.floor(v.y), z: Math.floor(v.z) }
120
+ }
121
+
122
+ // Normalise a 2D direction. Returns { dx, dz } with magnitude 1.
123
+ // Falls back to a random unit direction if input is invalid or zero-length.
124
+ function safeNormalize2D(dx, dz) {
125
+ if (!isFiniteNum(dx) || !isFiniteNum(dz)) {
126
+ const a = Math.random() * Math.PI * 2
127
+ return { dx: Math.sin(a), dz: Math.cos(a), fallback: 'invalid_input' }
128
+ }
129
+ const len = Math.sqrt(dx * dx + dz * dz)
130
+ if (len < VEC_EPSILON) {
131
+ const a = Math.random() * Math.PI * 2
132
+ return { dx: Math.sin(a), dz: Math.cos(a), fallback: 'zero_length' }
133
+ }
134
+ return { dx: dx / len, dz: dz / len, fallback: null }
135
+ }
136
+
137
+ // Wait until bot.entity.position has finite x,y,z. Resolves with the position,
138
+ // or rejects on timeout. Used by every task that reads the bot's current position.
139
+ async function awaitValidPosition(timeoutMs = 2000) {
140
+ const start = Date.now()
141
+ // Fast path: position is already valid
142
+ if (isValidVec(bot.entity?.position)) return bot.entity.position
143
+ while (Date.now() - start < timeoutMs) {
144
+ await new Promise(r => setTimeout(r, 80))
145
+ if (isValidVec(bot.entity?.position)) return bot.entity.position
146
+ }
147
+ throw new Error(`bot position invalid for ${timeoutMs}ms (post-teleport / pre-spawn race)`)
148
+ }
149
+
150
+ // ── Utility: rate-limited chat queue (Spigot kicks on >4 msgs in 0.5s) ────────
151
+ const chatQueue = []
152
+ let chatBusy = false
153
+
154
+ function safeChat(msg) {
155
+ const str = String(msg).slice(0, 200)
156
+ if (chatQueue.length >= 5) {
157
+ console.log(`[${BOT_NAME}] [chat-dropped] ${str}`)
158
+ return
159
+ }
160
+ if (chatQueue.length > 0 && chatQueue[chatQueue.length - 1] === str) return // dedup
161
+ chatQueue.push(str)
162
+ pumpChat()
163
+ }
164
+
165
+ function pumpChat() {
166
+ if (chatBusy || chatQueue.length === 0) return
167
+ chatBusy = true
168
+ const msg = chatQueue.shift()
169
+ try { bot.chat(msg) } catch (e) { console.error('[chat error]', e.message) }
170
+ setTimeout(() => { chatBusy = false; pumpChat() }, 1500) // 1 msg per 1.5s
171
+ }
172
+
173
+ // ── Boot ───────────────────────────────────────────────────────────────────────
174
+ bot.once('spawn', async () => {
175
+ log.info('spawned', {
176
+ pos: { x: Math.floor(bot.entity.position.x), y: Math.floor(bot.entity.position.y), z: Math.floor(bot.entity.position.z) },
177
+ ...(RECOVERY_CHAIN_ID && { chainId: RECOVERY_CHAIN_ID }),
178
+ })
179
+ if (RECOVERY_CHAIN_ID) {
180
+ log.info('recovery_chain_active', { chainId: RECOVERY_CHAIN_ID })
181
+ }
182
+
183
+ // Bring up the long-lived components before the (slow) LLM connectivity check so the
184
+ // damage pipeline's entityHurt/health listeners + classifier are live from spawn.
185
+ recoveryEngine = createRecoveryEngine({
186
+ bot, movement, state, log,
187
+ getTaskContext: () => ({ taskBusy, currentTaskToken }),
188
+ cancelTask: cancelCurrentTask,
189
+ replaceTask,
190
+ runTask,
191
+ taskBlindSurvival: tasks.taskBlindSurvival,
192
+ taskEscape: tasks.taskEscape,
193
+ taskExplore: tasks.taskExplore,
194
+ writeExitReason,
195
+ })
196
+
197
+ envPerception = createEnvironmentPerception(bot, log)
198
+ locomotionRecovery = createLocomotionRecovery(bot, log)
199
+
200
+ damagePipeline = createDamagePipeline({
201
+ bot, log, state, liveness,
202
+ getEnvPerception: () => envPerception,
203
+ getLastEnvScan: () => lastEnvScan,
204
+ isTaskBusy: () => taskBusy,
205
+ replaceTask,
206
+ safeChat, safeFloor, isValidVec,
207
+ getPlayer,
208
+ bumpAnger, ANGER_HIT, ANGER_ATTACK_LEVEL,
209
+ HAZARD_BLOCKS, HOSTILE_MOB_NAMES, BOT_NAME,
210
+ tasks, // damagePipeline destructures the 4 it needs (attack player/mobs, flee, evade)
211
+ })
212
+
213
+ llmEnabled = await checkOllama()
214
+ log.info('llm_check', { model: getModelName(), connected: llmEnabled })
215
+
216
+ safeChat(llmEnabled ? `${BOT_NAME} online (${getModelName()}).` : 'LLM offline — using fallback commands.')
217
+ rememberLocation(memory, 'spawn', bot.entity.position)
218
+ rememberEvent(memory, 'spawned', { model: getModelName() })
219
+
220
+ startStateLoop()
221
+ startAgentLoop()
222
+ startThreatLoop()
223
+ startAngerDecay()
224
+ startActivityWatchdog()
225
+ startReconciliationWatchdog()
226
+ startFreezeForensics()
227
+ startHealthBeacon()
228
+ startPerceptionLoop()
229
+ createFatalDesyncRecovery(bot, liveness, log, (ctx) => recoveryEngine.report('DESYNC', ctx))
230
+ createPositionGuard(bot, liveness, log, recoveryEngine)
231
+
232
+ // Post-spawn integrity check: confirm entity liveness reaches LIVE_VALID within 30s.
233
+ // A failure here means position/entity data never arrived — likely a login-phase desync.
234
+ const integrityStart = Date.now()
235
+ const integrityCheck = setInterval(() => {
236
+ if (liveness.getState() === 'LIVE_VALID') {
237
+ clearInterval(integrityCheck)
238
+ log.info('spawn_integrity_ok', {
239
+ checkMs: Date.now() - integrityStart,
240
+ ...(RECOVERY_CHAIN_ID && { chainId: RECOVERY_CHAIN_ID }),
241
+ })
242
+ } else if (Date.now() - integrityStart > 30_000) {
243
+ clearInterval(integrityCheck)
244
+ log.warn('spawn_integrity_fail', {
245
+ livenessState: liveness.getState(),
246
+ checkMs: 30_000,
247
+ ...(RECOVERY_CHAIN_ID && { chainId: RECOVERY_CHAIN_ID }),
248
+ })
249
+ }
250
+ }, 1_000)
251
+ })
252
+
253
+ // ── Reconciliation Watchdog ──────────────────────────────────────────────────
254
+ // Every 5 seconds, verify the bot's actual state matches its declared state.
255
+ // Forcibly re-syncs if any inconsistency is found:
256
+ // - goal === 'following' but pathfinder has no active goal
257
+ // - goal === 'attacking' but pvp is not engaged
258
+ // - state has a followTarget but goal is idle (handled by agent loop's auto-resume too)
259
+ // - taskBusy stuck true with no watchdog
260
+ let recoveryAttempts = 0
261
+ let recoveryEngine = null // initialized in spawn handler after task functions are available
262
+ let envPerception = null // initialized in spawn handler
263
+ let locomotionRecovery = null // initialized in spawn handler
264
+ let damagePipeline = null // initialized in spawn handler
265
+
266
+ // Last full environment scan — refreshed every PERCEPTION_INTERVAL_MS
267
+ let lastEnvScan = null
268
+ const PERCEPTION_INTERVAL_MS = 3000 // scan every 3s; scanning is cheap (local blockAt calls)
269
+
270
+ function startReconciliationWatchdog() {
271
+ setInterval(() => {
272
+ const findings = []
273
+
274
+ // Check 1: following goal but no active path
275
+ if (state.goal === 'following' && state.followTarget) {
276
+ if (!movement.isActive()) {
277
+ findings.push('following_no_path')
278
+ const target = getPlayer(state.followTarget)
279
+ if (target) {
280
+ if (!movement.follow(target.entity, 2, movement.PRIORITY.LOW, 'reconcile')) {
281
+ log.error('reconcile_follow_blocked', { blockedBy: movement.getOwner()?.source })
282
+ } else {
283
+ recoveryAttempts++
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ // Check 2: taskBusy true but no current task context → orphaned
290
+ // (Should be impossible under token discipline; defensive paranoia.)
291
+ if (taskBusy && !currentTaskContext) {
292
+ findings.push('orphaned_taskBusy')
293
+ taskBusy = false
294
+ setGoal(state.followTarget ? 'following' : 'idle', { source: 'reconciliation', reason: 'orphan_reset' })
295
+ recoveryAttempts++
296
+ }
297
+
298
+ // Check 3: idle with no follow target after a while → reset emotion
299
+ // (No-op for now; future: re-run autonomous planner)
300
+
301
+ if (findings.length > 0) {
302
+ log.warn('reconcile_corrected', { findings, recoveryAttempts })
303
+ }
304
+ }, 5000)
305
+ }
306
+
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ // FREEZE FORENSICS
309
+ // Periodic stuck-detection + freeze snapshot dumper.
310
+ // Fires `freeze_snapshot` log entry with full runtime state when:
311
+ // - a task has been running > FREEZE_SNAPSHOT_TASK_AGE_MS, OR
312
+ // - position has been invalid for > FREEZE_SNAPSHOT_POS_INVALID_MS
313
+ // Desync→reconnect is NOT handled here: fatalDesyncRecovery.js owns that decision
314
+ // (it reconnects as soon as liveness.isDesynced() flips true). This loop is purely
315
+ // observational.
316
+ // ─────────────────────────────────────────────────────────────────────────────
317
+
318
+ const FREEZE_SNAPSHOT_TASK_AGE_MS = 30000 // task running >30s with no completion
319
+ const FREEZE_SNAPSHOT_POS_INVALID_MS = 3000 // position invalid >3s
320
+
321
+ // Freeze taxonomy — classify what kind of failure a freeze snapshot represents.
322
+ // Returns a string tag used to route postmortem analysis.
323
+ function classifyFreeze(s) {
324
+ if (s.livenessState === 'LIVE_FATAL' || s.livenessState === 'LIVE_RECOVERING') return 'entity_desync'
325
+ if (s.taskBusy && !s.currentTaskGoal) return 'runtime_corruption'
326
+ if (s.activeGotoCount > 0 && s.oldestGotoAgeMs > 15000 && !s.isMoving) return 'movement_deadlock'
327
+ if (s.activeGotoCount > 0 && s.oldestGotoAgeMs > 15000) return 'async_timeout'
328
+ if (s.posInvalidMs > 3000) return 'validator_dead_end'
329
+ if (s.taskBusy && s.taskAgeMs > FREEZE_SNAPSHOT_TASK_AGE_MS) return 'planner_stall'
330
+ return 'unknown'
331
+ }
332
+
333
+ let lastFreezeSnapshotAt = 0
334
+ const FREEZE_SNAPSHOT_INTERVAL_MS = 5000 // don't spam
335
+
336
+ function buildFreezeSnapshot(reason) {
337
+ const now = Date.now()
338
+ const ctx = currentTaskContext
339
+ const livePos = bot.entity?.position
340
+ const oldestGoto = activeGotos.size > 0
341
+ ? Math.min(...[...activeGotos.values()].map(g => g.startedAt))
342
+ : null
343
+
344
+ const snapshot = {
345
+ reason,
346
+ // Task state
347
+ currentTaskGoal: ctx?.goalName || null,
348
+ taskAgeMs: ctx ? now - ctx.startedAt : null,
349
+ taskTokenStr: ctx?.token ? String(ctx.token).slice(0, 80) : null,
350
+ taskBusy,
351
+ watchdogActive: !!ctx?.watchdog,
352
+
353
+ // Goal state
354
+ goal: state.goal,
355
+ followTarget: state.followTarget,
356
+
357
+ // Damage state
358
+ damageState: damagePipeline?.getDamageState() ?? 'safe',
359
+ damageWindowSize: damagePipeline?.getDamageWindowSize() ?? 0,
360
+ msSinceLastReaction: damagePipeline ? now - damagePipeline.getLastReactionAt() : null,
361
+
362
+ // Pathfinder
363
+ pathActive: !!bot.pathfinder?.goal,
364
+ isMoving: !!bot.pathfinder?.isMoving?.(),
365
+ activeGotoCount: activeGotos.size,
366
+ oldestGotoAgeMs: oldestGoto ? now - oldestGoto : null,
367
+
368
+ // Position liveness
369
+ livePosValid: isValidVec(livePos),
370
+ livePosRaw: livePos ? { x: livePos.x, y: livePos.y, z: livePos.z } : null,
371
+ cachedPos: liveness.getCachedPos(),
372
+ posInvalidMs: liveness.getInvalidMs() || null,
373
+ livenessState: liveness.getState(),
374
+
375
+ // Physics liveness
376
+ onGround: !!bot.entity?.onGround,
377
+ inWater: !!bot.entity?.isInWater,
378
+ inLava: !!bot.entity?.isInLava,
379
+ velocity: isValidVec(bot.entity?.velocity)
380
+ ? { x: bot.entity.velocity.x, y: bot.entity.velocity.y, z: bot.entity.velocity.z }
381
+ : null,
382
+
383
+ // World liveness
384
+ dimension: bot.game?.dimension || null,
385
+ hp: bot.health,
386
+ food: bot.food,
387
+
388
+ // Recovery state
389
+ recoveryState: recoveryEngine?.getState() || null,
390
+ movementOwner: movement.getOwner(),
391
+
392
+ // Environmental perception
393
+ envRisk: lastEnvScan?.locomotionRisk ?? null,
394
+ envStuckClass: lastEnvScan?.stuckClass ?? null,
395
+ envHazards: lastEnvScan?.hazardSummary ?? null,
396
+ envIsEnclosed: lastEnvScan?.isEnclosed ?? null,
397
+
398
+ // Tracing
399
+ cid: ctx?.correlationId || null,
400
+ }
401
+ snapshot.freezeClass = classifyFreeze(snapshot)
402
+ return snapshot
403
+ }
404
+
405
+ function startFreezeForensics() {
406
+ setInterval(() => {
407
+ const now = Date.now()
408
+ const ctx = currentTaskContext
409
+
410
+ // Condition 1: task running too long
411
+ const taskTooOld = ctx && (now - ctx.startedAt) > FREEZE_SNAPSHOT_TASK_AGE_MS
412
+
413
+ // Condition 2: position invalid too long — use liveness monitor as source of truth
414
+ const posInvalidMs = liveness.getInvalidMs()
415
+ const posInvalidLong = posInvalidMs > FREEZE_SNAPSHOT_POS_INVALID_MS
416
+
417
+ // Snapshot (rate-limited)
418
+ if ((taskTooOld || posInvalidLong) && (now - lastFreezeSnapshotAt) > FREEZE_SNAPSHOT_INTERVAL_MS) {
419
+ const reason = taskTooOld && posInvalidLong ? 'task_old_and_pos_invalid'
420
+ : taskTooOld ? 'task_old'
421
+ : 'pos_invalid_long'
422
+ log.warn('freeze_snapshot', buildFreezeSnapshot(reason))
423
+ lastFreezeSnapshotAt = now
424
+ }
425
+ }, 1000)
426
+ }
427
+
428
+ // ── Environmental Hazards ────────────────────────────────────────────────────
429
+ // Blocks that damage the bot just by being adjacent / inside / on top of them.
430
+ // Used by Movements.blocksToAvoid (makeMovements) and passed to the damage pipeline
431
+ // (findNearbyHazard / unknown-damage branch live in damagePipeline.js).
432
+ const HAZARD_BLOCKS = new Set([
433
+ 'cactus', 'fire', 'soul_fire', 'lava', 'magma_block',
434
+ 'sweet_berry_bush', 'wither_rose', 'powder_snow',
435
+ 'campfire', 'soul_campfire',
436
+ ])
437
+
438
+ // ── Anger / Defense System ────────────────────────────────────────────────────
439
+
440
+ // Tuned for "forgive accidents, escalate on intent":
441
+ // 1 accidental hit → anger 2.5 (no attack, decays in ~5s)
442
+ // 2 hits in 5 sec → anger 5 (attack threshold)
443
+ // 1 insult + 1 hit → anger 3.5 (still no attack)
444
+ const ANGER_INSULT = 1
445
+ const ANGER_HIT = 2.5
446
+ const ANGER_THRESHOLD = 3
447
+ const ANGER_ATTACK_LEVEL = 5
448
+ const ANGER_DECAY_PER_S = 0.5 // 10× faster — accidents are forgotten in seconds
449
+
450
+ function bumpAnger(username, amount, reason) {
451
+ const rec = anger.get(username) || { level: 0, count: 0, lastAt: 0 }
452
+ rec.level += amount
453
+ rec.count += 1
454
+ rec.lastAt = Date.now()
455
+ rec.reason = reason
456
+ anger.set(username, rec)
457
+ console.log(`[${BOT_NAME}] anger ${username} → ${rec.level.toFixed(1)} (${reason})`)
458
+ return rec
459
+ }
460
+
461
+ function startAngerDecay() {
462
+ setInterval(() => {
463
+ for (const [name, rec] of anger.entries()) {
464
+ rec.level = Math.max(0, rec.level - ANGER_DECAY_PER_S)
465
+ if (rec.level <= 0.1) anger.delete(name)
466
+ }
467
+ }, 1000)
468
+ }
469
+
470
+ function maybeAttackForAnger(username) {
471
+ const rec = anger.get(username)
472
+ if (!rec || rec.level < ANGER_ATTACK_LEVEL) return false
473
+ if (state.energy < 20) return false
474
+ if (state.goal === 'attacking') return false
475
+
476
+ const player = bot.players[username]
477
+ if (!player?.entity) return false
478
+
479
+ safeChat(`That's it, ${username}. I warned you.`)
480
+ replaceTask('attacking', () => tasks.taskAttackPlayer(player.entity, username))
481
+ return true
482
+ }
483
+
484
+ // ─────────────────────────────────────────────────────────────────────────────
485
+ // DAMAGE PIPELINE lives in damagePipeline.js — created in the spawn handler as
486
+ // `damagePipeline`. It owns the entityHurt/health listeners and the 500ms
487
+ // classifier. bot.js keeps death/respawn/forcedMove (mixed concerns) below and
488
+ // calls damagePipeline.flushDamageState() from respawn/forcedMove.
489
+ // ─────────────────────────────────────────────────────────────────────────────
490
+
491
+ bot.on('death', () => {
492
+ log.warn('died', { lastGoal: state.goal })
493
+ safeChat('I died. Respawning...')
494
+ cancelCurrentTask() // releases ownership, stops movement, sets goal=idle
495
+ state.followTarget = null
496
+ // health/food will re-sync from server on next tick after respawn
497
+ })
498
+
499
+ bot.on('respawn', () => {
500
+ log.info('respawned', { pos: safeFloor(bot.entity?.position) })
501
+ // Discard any damage events from the pre-respawn life — they refer to a stale world.
502
+ damagePipeline?.flushDamageState()
503
+ safeChat("I'm back. That hurt.")
504
+ })
505
+
506
+ // Server teleported the bot (e.g. /tp, /spawn, plugin teleport). Position is briefly
507
+ // invalid (NaN x/z) until the new position packet arrives. Flush stale state so
508
+ // damage events from the pre-teleport location don't leak into post-teleport reactions.
509
+ bot.on('forcedMove', () => {
510
+ log.info('teleported', {
511
+ newPos: safeFloor(bot.entity?.position),
512
+ valid: isValidVec(bot.entity?.position),
513
+ })
514
+ damagePipeline?.flushDamageState()
515
+ // The current task's path was for the OLD location — invalidate it atomically.
516
+ // cancelCurrentTask supersedes ownership; the task's microtask-deferred catch
517
+ // will see it lost ownership and skip cleanup, leaving us in clean idle state.
518
+ cancelCurrentTask()
519
+ })
520
+
521
+ // ─────────────────────────────────────────────────────────────────────────────
522
+ // Task Runner — token-discipline lifecycle
523
+ // ─────────────────────────────────────────────────────────────────────────────
524
+
525
+ // Stops the current task atomically and stops all movement.
526
+ // After this returns: currentTaskToken=null, taskBusy=false, state.goal='idle'
527
+ // (if it matched the cancelled task's goal). Pending callbacks of the cancelled
528
+ // task will see they are no longer the owner and skip all global mutations.
529
+ //
530
+ // Use this to stop a task without starting a replacement. To swap tasks, use
531
+ // `replaceTask` instead — it bundles cancel + start atomically.
532
+ function cancelCurrentTask() {
533
+ const oldCtx = currentTaskContext
534
+ // Supersede ownership BEFORE stopping movement, so that the old task's
535
+ // microtask-deferred catch handler (which the .stop() will trigger) sees
536
+ // it is no longer the owner.
537
+ currentTaskToken = null
538
+ currentTaskContext = null
539
+ if (oldCtx?.watchdog) clearTimeout(oldCtx.watchdog)
540
+ if (oldCtx && state.goal === oldCtx.goalName) setGoal('idle', { source: 'cancel_task', token: oldCtx.token })
541
+ // Stop movement before clearing taskBusy so no runTask() call can slip through the
542
+ // `if (taskBusy) return false` guard while the old task's pathfinder/pvp is still
543
+ // unwinding. The old task's deferred catch handlers will see ownership lost and no-op.
544
+ movement.forceStop('cancel')
545
+ try { bot.pvp.stop() } catch {}
546
+ bot.clearControlStates()
547
+ taskBusy = false // cleared last: gate stays closed until all cleanup is done
548
+ }
549
+
550
+ // Atomic task replacement: cancel current task and start a new one.
551
+ // The cancel + start runs in a single synchronous block, so the new task is
552
+ // fully installed before any of the old task's Promise callbacks can fire.
553
+ function replaceTask(goalName, fn, opts) {
554
+ cancelCurrentTask()
555
+ return runTask(goalName, fn, opts)
556
+ }
557
+
558
+ // Track recent path failures by goal — repeated explore timeouts mean we're stuck.
559
+ const taskFailureCounts = new Map() // goalName -> consecutive failure count
560
+ const EXPLORE_FAIL_THRESHOLD = 3
561
+
562
+ // silent=true: log errors to console only, never to game chat (for autonomous tasks)
563
+ function runTask(goalName, fn, { silent = false } = {}) {
564
+ if (taskBusy) return false
565
+
566
+ // Per-task context — captured in closure so deferred callbacks reference
567
+ // their OWN watchdog/goal/start time, not whatever globals look like later.
568
+ const myToken = Symbol(goalName)
569
+ const myCtx = {
570
+ token: myToken,
571
+ goalName,
572
+ startedAt: Date.now(),
573
+ silent,
574
+ watchdog: null,
575
+ correlationId: ++correlationCounter,
576
+ }
577
+
578
+ taskBusy = true
579
+ setGoal(goalName, { source: 'task_start', token: myToken })
580
+ state.idleTicks = 0
581
+ state.lastActivityAt = Date.now()
582
+ currentTaskToken = myToken
583
+ currentTaskContext = myCtx
584
+
585
+ log.info('task_start', { goal: goalName, silent, cid: myCtx.correlationId })
586
+
587
+ myCtx.watchdog = setTimeout(() => {
588
+ if (!isOwner(myToken)) return // superseded; observational-only
589
+ log.warn('task_watchdog_kill', { goal: goalName, durationMs: Date.now() - myCtx.startedAt, cid: myCtx.correlationId })
590
+ if (!silent) safeChat('Timed out. Resetting.')
591
+ recoveryEngine.report('TASK_HUNG', { source: 'watchdog', goalName, cid: myCtx.correlationId })
592
+ }, 90000)
593
+
594
+ fn().then(() => {
595
+ if (!isOwner(myToken)) {
596
+ log.debug('task_complete_stale', { goal: goalName })
597
+ return
598
+ }
599
+ log.info('task_complete', { goal: goalName, durationMs: Date.now() - myCtx.startedAt, cid: myCtx.correlationId })
600
+ taskFailureCounts.set(goalName, 0)
601
+ recoveryEngine.reset('TASK')
602
+ }).catch(err => {
603
+ if (!isOwner(myToken)) {
604
+ log.debug('task_error_stale', { goal: goalName, message: err.message })
605
+ return // we were replaced; the new task owns globals now
606
+ }
607
+ log.error('task_error', { goal: goalName, message: err.message, durationMs: Date.now() - myCtx.startedAt, cid: myCtx.correlationId })
608
+ if (!silent) safeChat(`Error in ${goalName}: ${err.message.slice(0, 80)}`)
609
+
610
+ // Stop movement — we're still owner, this is our cleanup
611
+ movement.forceStop('task_error')
612
+ try { bot.pvp.stop() } catch {}
613
+ bot.clearControlStates()
614
+
615
+ // Track timeout streaks for stuck detection
616
+ const isTimeout = /timeout|Took to long|stopped before/i.test(err.message || '')
617
+ if (isTimeout) {
618
+ const n = (taskFailureCounts.get(goalName) || 0) + 1
619
+ taskFailureCounts.set(goalName, n)
620
+ if (['exploring', 'mining', 'gathering'].includes(goalName) && n >= EXPLORE_FAIL_THRESHOLD) {
621
+ log.warn('stuck_detected_scheduling_escape', { goal: goalName, consecutive: n })
622
+ taskFailureCounts.set(goalName, 0)
623
+ recoveryEngine.report('MOVEMENT_TIMEOUT_STREAK', { source: 'timeout_streak', goalName, consecutive: n, cid: myCtx.correlationId })
624
+ }
625
+ } else {
626
+ taskFailureCounts.set(goalName, 0)
627
+ }
628
+ }).finally(() => {
629
+ if (!isOwner(myToken)) {
630
+ log.debug('task_finally_stale', { goal: goalName })
631
+ return // we were replaced; the new task owns globals now
632
+ }
633
+ // We're still owner — release ownership and clean up our state.
634
+ if (myCtx.watchdog) { clearTimeout(myCtx.watchdog); myCtx.watchdog = null }
635
+ currentTaskToken = null
636
+ currentTaskContext = null
637
+ taskBusy = false
638
+ state.lastActivityAt = Date.now()
639
+ bot.clearControlStates()
640
+ if (state.goal === goalName) setGoal('idle', { source: 'task_complete', token: myToken })
641
+ })
642
+
643
+ return true
644
+ }
645
+
646
+ // ── Helpers ───────────────────────────────────────────────────────────────────
647
+
648
+ // (LOG_NAMES / PLANK_NAMES / logIds / plankIds / countInInv moved to tasks.js)
649
+
650
+ // Build a fully-configured Movements instance. Allows digging through
651
+ // obstacles and placing blocks for scaffolding, while protecting our own infra
652
+ // (crafting tables, chests, doors). Used by every navigation call.
653
+ function makeMovements() {
654
+ const m = new Movements(bot)
655
+ m.canDig = true // break blocks if path requires it
656
+ m.canOpenDoors = true // navigate through doors instead of around
657
+ m.allowParkour = false // don't risk gap-jumps that frequently fail
658
+ m.allowSprinting = true
659
+ m.allow1by1towers = true // pillar up when needed
660
+ m.maxDropDown = 4 // tolerate small falls
661
+ m.infiniteLiquidDropdownDistance = false // don't drop into deep water
662
+
663
+ // Don't break our own infrastructure while pathing through it
664
+ const protect = ['crafting_table', 'chest', 'furnace', 'bed',
665
+ 'oak_door', 'spruce_door', 'birch_door', 'jungle_door', 'acacia_door', 'dark_oak_door',
666
+ 'oak_trapdoor', 'spruce_trapdoor']
667
+ for (const name of protect) {
668
+ const id = bot.registry.blocksByName[name]?.id
669
+ if (id != null) m.blocksCantBreak.add(id)
670
+ }
671
+
672
+ // Route AROUND damage-dealing blocks AND don't dig them.
673
+ // (Pathfinder's blocksToAvoid: A* refuses to step into these.
674
+ // blocksCantBreak: A* won't propose breaking these to clear a path.)
675
+ for (const name of HAZARD_BLOCKS) {
676
+ const id = bot.registry.blocksByName[name]?.id
677
+ if (id == null) continue
678
+ if (m.blocksToAvoid) m.blocksToAvoid.add(id)
679
+ if (m.blocksCantBreak) m.blocksCantBreak.add(id)
680
+ }
681
+
682
+ // Scaffolding blocks the bot can place to traverse gaps / climb out of holes.
683
+ // Use any plank, dirt, cobblestone, or sand we have.
684
+ const scaffoldNames = [
685
+ 'dirt', 'cobblestone', 'cobbled_deepslate', 'sand', 'gravel', 'netherrack',
686
+ 'oak_planks', 'spruce_planks', 'birch_planks', 'jungle_planks', 'acacia_planks',
687
+ 'dark_oak_planks', 'mangrove_planks', 'cherry_planks', 'crimson_planks', 'warped_planks',
688
+ ]
689
+ const scaffoldIds = scaffoldNames
690
+ .map(n => bot.registry.itemsByName[n]?.id)
691
+ .filter(id => id != null)
692
+ // mineflayer-pathfinder field has a typo — set both names to be safe
693
+ if (scaffoldIds.length > 0) {
694
+ m.scafoldingBlocks = scaffoldIds
695
+ m.scaffoldingBlocks = scaffoldIds
696
+ }
697
+
698
+ return m
699
+ }
700
+
701
+ // Goto lifecycle tracking — assigns each goto() call a unique id and logs
702
+ // start / resolve / reject / stale-resolve. Useful for forensic analysis
703
+ // of pathfinder hangs and stuck-after-cancellation behaviours.
704
+ let nextGotoId = 0
705
+ let correlationCounter = 0 // monotonic; shared across tasks, goto, recovery, damage
706
+ const activeGotos = new Map() // id -> { id, startedAt, token, taskGoal, target }
707
+
708
+ async function navNear(x, y, z, range = 3, priority = movement.PRIORITY.NORMAL) {
709
+ if (isNaN(x) || isNaN(y) || isNaN(z)) throw new Error(`invalid position (${x},${y},${z}) — bot not ready`)
710
+
711
+ const gotoId = ++nextGotoId
712
+ const startedAt = Date.now()
713
+ const ownerToken = currentTaskToken
714
+ const ownerGoal = currentTaskContext?.goalName || null
715
+ const cid = currentTaskContext?.correlationId || null
716
+ const target = { x, y, z, range }
717
+ activeGotos.set(gotoId, { id: gotoId, startedAt, token: ownerToken, taskGoal: ownerGoal, target })
718
+ log.debug('goto_start', { id: gotoId, taskGoal: ownerGoal, target, ...(cid != null && { cid }) })
719
+
720
+ try {
721
+ await movement.navigate(x, y, z, range, priority, `goto_${gotoId}`)
722
+ log.debug('goto_resolve', {
723
+ id: gotoId,
724
+ durationMs: Date.now() - startedAt,
725
+ stale: ownerToken !== currentTaskToken,
726
+ ...(cid != null && { cid }),
727
+ })
728
+ } catch (e) {
729
+ log.debug('goto_reject', {
730
+ id: gotoId,
731
+ durationMs: Date.now() - startedAt,
732
+ stale: ownerToken !== currentTaskToken,
733
+ message: e.message,
734
+ ...(cid != null && { cid }),
735
+ })
736
+ throw e
737
+ } finally {
738
+ activeGotos.delete(gotoId)
739
+ }
740
+ }
741
+
742
+ // All taskXxx bodies + their item/craft/build helpers live in tasks.js (Fix 9 Step 2).
743
+ // createTasks(...) binds them to deps; bot.js calls them via tasks.taskXxx. The task
744
+ // runner (runTask/replaceTask/cancelCurrentTask), navNear, makeMovements and goto
745
+ // tracking stay here — tasks receive navNear as a dependency.
746
+ const tasks = createTasks({
747
+ bot, log, state, memory, movement, liveness,
748
+ safeChat,
749
+ safeDig, safeCraft, safeEquip, safeConsume, safePlaceBlock, safeAttack,
750
+ navNear, awaitValidPosition,
751
+ isValidVec, isFiniteNum, safeFloor, safeNormalize2D,
752
+ rememberEvent, rememberLocation, recallLocation,
753
+ anger,
754
+ BOT_NAME,
755
+ getLastEnvScan: () => lastEnvScan,
756
+ getEnvPerception: () => envPerception,
757
+ getLocomotionRecovery: () => locomotionRecovery,
758
+ })
759
+
760
+ // ── Autonomous Idle Behavior ───────────────────────────────────────────────────
761
+
762
+ const IDLE_THRESHOLD = 18
763
+
764
+ function tryAutonomous() {
765
+ if (taskBusy || state.goal !== 'idle' || state.energy < 35) return
766
+
767
+ state.idleTicks++
768
+ if (state.idleTicks < IDLE_THRESHOLD) return
769
+ state.idleTicks = 0
770
+
771
+ // Escape pre-empts everything else: if we're way underground and not doing anything
772
+ // intentional, get back to the surface before anything else.
773
+ const myY = bot.entity?.position?.y
774
+ if (myY != null && !isNaN(myY) && myY < 55) {
775
+ log.info('autonomous_escape', { y: Math.floor(myY) })
776
+ runTask('escaping', tasks.taskEscape)
777
+ return
778
+ }
779
+
780
+ const gs = buildGroundedState(bot, state, memory, anger, envPerception)
781
+ const choice = selectAutonomousGoal(gs)
782
+
783
+ if (choice) {
784
+ log.debug('autonomous_choice', { action: choice.action, target: choice.target })
785
+ // Narrate only 1-in-4 times to avoid chat spam — actions speak louder than words
786
+ if (Math.random() < 0.25) safeChat(choice.say)
787
+ if (choice.action === 'explore') runTask('exploring', tasks.taskExplore, { silent: true })
788
+ else if (choice.action === 'gather_wood') runTask('gathering', tasks.taskGatherWood, { silent: true })
789
+ else if (choice.action === 'craft_planks') runTask('crafting', tasks.taskCraftPlanks, { silent: true })
790
+ else if (choice.action === 'craft') runTask('crafting', () => tasks.taskCraftItem(choice.target || 'crafting_table'), { silent: true })
791
+ else if (choice.action === 'attack_mobs') runTask('attacking', tasks.taskAttackMobs)
792
+ else if (choice.action === 'collect_items') runTask('collecting', tasks.taskCollectNearby, { silent: true })
793
+ }
794
+ // No ambient narration — silent observation avoids chat spam
795
+ }
796
+
797
+ // ── Activity Watchdog ────────────────────────────────────────────────────────
798
+ // Detects when the bot has been silently inactive for too long and force-resets
799
+ // state so autonomous behavior can resume. Different from the per-task watchdog:
800
+ // this catches "everything completed normally but bot is now stuck doing nothing".
801
+
802
+ // ── Health Beacon (10s) ───────────────────────────────────────────────────────
803
+ // Continuous runtime health timeline for postmortem reconstruction.
804
+ // Promotes the edge-only freeze_snapshot into an always-on pulse.
805
+
806
+ function startHealthBeacon() {
807
+ setInterval(() => {
808
+ const ctx = currentTaskContext
809
+ const now = Date.now()
810
+ log.info('runtime_health_snapshot', {
811
+ livenessState: liveness.getState(),
812
+ livenessInvalidMs: liveness.getInvalidMs() || 0,
813
+ movementOwner: movement.getOwner(),
814
+ taskGoal: ctx?.goalName || null,
815
+ taskAgeMs: ctx ? now - ctx.startedAt : null,
816
+ taskBusy,
817
+ watchdogActive: !!ctx?.watchdog,
818
+ recoveryLevels: recoveryEngine ? Object.fromEntries(
819
+ Object.entries(recoveryEngine.getState()).map(([k, v]) => [k, v.level])
820
+ ) : null,
821
+ hp: bot.health,
822
+ food: bot.food,
823
+ goal: state.goal,
824
+ envRisk: lastEnvScan?.locomotionRisk ?? null,
825
+ envStuck: lastEnvScan?.stuckClass ?? null,
826
+ envHazards: lastEnvScan?.hazardSummary ?? null,
827
+ cid: ctx?.correlationId || null,
828
+ })
829
+ }, 10_000)
830
+ }
831
+
832
+ // ── Perception Loop ───────────────────────────────────────────────────────────
833
+ // Continuously refreshes lastEnvScan so it is available to tasks, the LLM,
834
+ // freeze forensics, and recovery without blocking event loop with repeated scans.
835
+ // Also fires automatic hazard escape when in critical danger and not already busy.
836
+
837
+ const LAVA_ESCAPE_COOLDOWN_MS = 8000
838
+ let lastLavaEscapeAt = 0
839
+
840
+ function startPerceptionLoop() {
841
+ setInterval(() => {
842
+ if (!envPerception) return
843
+ try {
844
+ lastEnvScan = envPerception.scan()
845
+ } catch (e) {
846
+ log.debug('perception_scan_error', { message: e.message })
847
+ return
848
+ }
849
+
850
+ // Don't act on an invalid scan — position is NaN/null, all block reads were junk.
851
+ if (!lastEnvScan.valid) return
852
+
853
+ // Auto-trigger hazard escape when standing in lava and not already reacting
854
+ if (lastEnvScan.feetBlock === 'lava' || lastEnvScan.headBlock === 'lava') {
855
+ const now = Date.now()
856
+ if (!taskBusy && now - lastLavaEscapeAt > LAVA_ESCAPE_COOLDOWN_MS) {
857
+ lastLavaEscapeAt = now
858
+ log.warn('hazard_detected', { type: 'lava', feetBlock: lastEnvScan.feetBlock })
859
+ locomotionRecovery.runHazardEscape('lava_immobilization', lastEnvScan, 'auto_lava').catch(() => {})
860
+ }
861
+ }
862
+
863
+ // Log high-risk situations for postmortem visibility
864
+ if (lastEnvScan.locomotionRisk >= 8) {
865
+ log.warn('hazard_detected', {
866
+ risk: lastEnvScan.locomotionRisk,
867
+ stuckClass: lastEnvScan.stuckClass,
868
+ hazardSummary: lastEnvScan.hazardSummary,
869
+ isEnclosed: lastEnvScan.isEnclosed,
870
+ })
871
+ }
872
+ }, PERCEPTION_INTERVAL_MS)
873
+ }
874
+
875
+ const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
876
+ const ACTIVITY_CHECK_INTERVAL_MS = 30 * 1000
877
+
878
+ function startActivityWatchdog() {
879
+ setInterval(() => {
880
+ const elapsed = Date.now() - state.lastActivityAt
881
+ if (elapsed < ACTIVITY_TIMEOUT_MS) return
882
+ if (taskBusy || state.goal === 'following' || state.goal === 'resting') {
883
+ // Active in some form — not actually idle
884
+ state.lastActivityAt = Date.now()
885
+ return
886
+ }
887
+ log.warn('activity_watchdog_reset', { idleMs: elapsed, goal: state.goal })
888
+ recoveryEngine.report('IDLE', { source: 'activity_watchdog', idleMs: elapsed })
889
+ state.lastActivityAt = Date.now()
890
+ }, ACTIVITY_CHECK_INTERVAL_MS)
891
+ }
892
+
893
+
894
+ // ── Threat Reaction Loop (auto-attack hostile mobs in range) ─────────────────
895
+
896
+ function startThreatLoop() {
897
+ setInterval(() => {
898
+ if (taskBusy || state.energy < 25) return
899
+ if (!['idle','exploring'].includes(state.goal)) return
900
+
901
+ const mob = bot.nearestEntity(e =>
902
+ e.name && HOSTILE_MOB_NAMES.has(e.name) &&
903
+ e.position.distanceTo(bot.entity.position) < 10
904
+ )
905
+ if (mob) {
906
+ safeChat(`${mob.name} is close. Engaging.`)
907
+ runTask('attacking', () => tasks.taskAttackMobs())
908
+ }
909
+ }, 2500)
910
+ }
911
+
912
+ // ── State Loop (1s) ───────────────────────────────────────────────────────────
913
+
914
+ function startStateLoop() {
915
+ const hpWatchdog = createHealthIntegrityWatchdog(
916
+ bot, log,
917
+ () => {
918
+ // Silent HP drain — the normal damage pipeline couldn't react. Report it; the
919
+ // arbiter routes: structurally-invalid position ⇒ DESYNC (reconnect, no maneuver
920
+ // can help); valid position ⇒ CRITICAL_HP (blind-survival sprint away).
921
+ if (!isValidVec(bot.entity?.position)) {
922
+ recoveryEngine.report('DESYNC', { source: 'silent_damage', hp: bot.health })
923
+ } else {
924
+ recoveryEngine.report('CRITICAL_HP', { source: 'silent_damage', hp: bot.health })
925
+ }
926
+ },
927
+ () => damagePipeline?.getLastReactionAt() ?? 0
928
+ )
929
+
930
+ setInterval(() => {
931
+ // HP-loss watchdog: detects silent damage when damage pipeline is blinded by NaN position
932
+ hpWatchdog.tick()
933
+
934
+ // Sync from real Minecraft values — bot is a full player with actual HP and hunger
935
+ state.energy = Math.round((bot.health / 20) * 100) // 0-100 from 0-20 HP
936
+ state.hunger = Math.round((bot.food / 20) * 100) // 0-100 from 0-20 food
937
+
938
+ const active = ['following','exploring','gathering','going_to','attacking','collecting','crafting','building'].includes(state.goal)
939
+
940
+ // Low HP while doing something — cancel task and rest (health regen when idle)
941
+ if (state.energy <= 15 && active) {
942
+ cancelCurrentTask() // atomically supersedes ownership + stops movement
943
+ setGoal('resting', { source: 'state_loop', reason: 'low_hp' })
944
+ safeChat('Low health. Pulling back.')
945
+ rememberEvent(memory, 'low_health', { hp: bot.health })
946
+ }
947
+
948
+ // Recovered to 80% HP — resume normal operation (and follow if we were)
949
+ if (state.goal === 'resting' && state.energy >= 80) {
950
+ setGoal(state.followTarget ? 'following' : 'idle', { source: 'state_loop', reason: 'rest_recovery' })
951
+ safeChat('Health up. Ready.')
952
+ log.info('rest_recovery', { resumedGoal: state.goal, followTarget: state.followTarget })
953
+ }
954
+
955
+ // Auto-eat when hungry and food is available — fires once per state tick
956
+ if (bot.food <= 14 && !taskBusy && tasks.FOOD_PRIORITY.some(f => bot.inventory.items().some(i => i.name === f))) {
957
+ runTask('eating', tasks.taskEatFood, { silent: true })
958
+ }
959
+
960
+ tryAutonomous()
961
+
962
+ // Periodic structured state snapshot for TUI / postmortem.
963
+ // This is the canonical record of "what was the bot doing at time T?"
964
+ if (!startStateLoop._tickCounter) startStateLoop._tickCounter = 0
965
+ startStateLoop._tickCounter++
966
+ if (startStateLoop._tickCounter % 2 === 0) { // every 2s
967
+ const pos = bot.entity?.position
968
+ const posValid = pos && !isNaN(pos.x) && !isNaN(pos.y) && !isNaN(pos.z)
969
+ if (posValid) {
970
+ startStateLoop._lastValidPos = { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) }
971
+ }
972
+
973
+ // Compute emotion summary from anger map
974
+ let topAnger = null
975
+ let totalAnger = 0
976
+ for (const [name, rec] of anger.entries()) {
977
+ totalAnger += rec.level
978
+ if (!topAnger || rec.level > topAnger.level) topAnger = { name, level: rec.level }
979
+ }
980
+ const emotion = topAnger
981
+ ? (topAnger.level >= ANGER_ATTACK_LEVEL ? 'hostile' : topAnger.level >= ANGER_THRESHOLD ? 'irritated' : 'wary')
982
+ : 'calm'
983
+
984
+ log.trace('state', {
985
+ // Basic
986
+ goal: state.goal,
987
+ hp: Number(bot.health.toFixed(1)),
988
+ food: bot.food,
989
+ pos: startStateLoop._lastValidPos || null,
990
+ inventory: bot.inventory.items().slice(0, 16).map(i => `${i.name}x${i.count}`),
991
+ // Concurrency
992
+ busy: taskBusy,
993
+ watchdogActive: !!(currentTaskContext?.watchdog),
994
+ currentTaskGoal: currentTaskContext?.goalName || null,
995
+ // Emotional
996
+ anger: anger.size,
997
+ topAngerLevel: topAnger ? Number(topAnger.level.toFixed(1)) : 0,
998
+ topAngerWith: topAnger?.name || null,
999
+ emotion,
1000
+ // Behaviour
1001
+ followTarget: state.followTarget,
1002
+ combatMode: state.goal === 'attacking',
1003
+ // Pathfinder state
1004
+ pathActive: !!(bot.pathfinder?.goal),
1005
+ isMoving: !!bot.pathfinder?.isMoving?.(),
1006
+ // Damage pipeline
1007
+ damageState: damagePipeline?.getDamageState() ?? 'safe', // safe | hurt | reacting
1008
+ recentDamageHits: damagePipeline?.getDamageWindowSize() ?? 0, // events in current 1.5s window
1009
+ msSinceLastReaction: damagePipeline ? Date.now() - damagePipeline.getLastReactionAt() : null,
1010
+ knownHazardZones: damagePipeline?.getHazardZonesCount() ?? 0,
1011
+ // Recovery
1012
+ recoveryAttempts,
1013
+ // Anti-spam
1014
+ cooldowns: damagePipeline?.getCooldowns() ?? { hitChat: 0, counterPunch: 0, reaction: 0 },
1015
+ })
1016
+ }
1017
+ }, 1000)
1018
+ }
1019
+
1020
+ // ── Agent Loop (250 ms) ────────────────────────────────────────────────────────
1021
+
1022
+ // Goals that block auto-follow-resume — bot must finish evading/fighting/resting first.
1023
+ const FOLLOW_BLOCK_GOALS = new Set(['evading', 'fleeing', 'attacking', 'resting'])
1024
+
1025
+ function startAgentLoop() {
1026
+ let prevGoal = 'idle'
1027
+ let lostTicks = 0 // grace period before "Lost you"
1028
+ let prevFollowTarget = null
1029
+ let followRefreshTicks = 0 // re-issue follow goal periodically — recovers from stuck pathfinder
1030
+ let stuckCheckTicks = 0
1031
+ let lastBotPos = null
1032
+ let stuckTicks = 0
1033
+
1034
+ setInterval(() => {
1035
+ // Advance entity liveness state machine — must run every 250ms tick.
1036
+ liveness.tick()
1037
+
1038
+ // Auto-resume follow if we have a follow target but no active task or goal.
1039
+ // This survives ALL interruptions — counter-attacks, auto-eat, autonomous tasks, etc.
1040
+ // Guard: never resume following while the bot is evading, fleeing, attacking, or
1041
+ // resting — these goals must run to completion before the follow loop re-engages.
1042
+ if (state.followTarget && state.goal === 'idle' && !taskBusy && !FOLLOW_BLOCK_GOALS.has(state.goal)) {
1043
+ log.info('follow_auto_resumed', { target: state.followTarget, prevGoal })
1044
+ setGoal('following', { source: 'agent_loop', reason: 'follow_auto_resume' })
1045
+ }
1046
+
1047
+ // Look at the player we're following, or the nearest one if just idling
1048
+ const lookTarget = state.goal === 'following'
1049
+ ? getPlayer(state.followTarget)
1050
+ : getNearestPlayer()
1051
+ if (lookTarget) {
1052
+ try { bot.lookAt(lookTarget.entity.position.offset(0, lookTarget.entity.height, 0)) } catch {}
1053
+ }
1054
+
1055
+ if (bot.entity?.isInWater && !taskBusy) {
1056
+ bot.setControlState('jump', true)
1057
+ }
1058
+
1059
+ if (state.goal === 'following') {
1060
+ const target = getPlayer(state.followTarget)
1061
+ if (!target) {
1062
+ lostTicks++
1063
+ if (lostTicks >= 20) {
1064
+ movement.stop('follow')
1065
+ bot.clearControlStates()
1066
+ setGoal('idle', { source: 'agent_loop', reason: 'follow_lost' })
1067
+ state.followTarget = null
1068
+ safeChat('Lost you.')
1069
+ log.info('follow_lost', { target: prevFollowTarget })
1070
+ lostTicks = 0
1071
+ }
1072
+ } else {
1073
+ lostTicks = 0
1074
+ followRefreshTicks++
1075
+
1076
+ // Re-issue follow goal:
1077
+ // 1. on transition into following
1078
+ // 2. on follow-target change
1079
+ // 3. periodically every 10s (recover from pathfinder hangs caused by knockback, NaN, etc.)
1080
+ const shouldReissue =
1081
+ prevGoal !== 'following' ||
1082
+ prevFollowTarget !== state.followTarget ||
1083
+ followRefreshTicks >= 40
1084
+
1085
+ if (shouldReissue) {
1086
+ followRefreshTicks = 0
1087
+ movement.follow(target.entity, 2, movement.PRIORITY.LOW, 'follow')
1088
+ }
1089
+
1090
+ // Stuck detection: tighten to ~4s of no movement.
1091
+ // Sample every 1s (4 ticks) and require 4 consecutive failed samples.
1092
+ stuckCheckTicks++
1093
+ if (stuckCheckTicks >= 4) { // every 1s
1094
+ stuckCheckTicks = 0
1095
+ const pos = bot.entity?.position
1096
+ if (pos && lastBotPos && !isNaN(pos.x) && !isNaN(pos.z)) {
1097
+ const moved = Math.hypot(pos.x - lastBotPos.x, pos.z - lastBotPos.z)
1098
+ const targetDist = pos.distanceTo(target.entity.position)
1099
+ if (targetDist > 3 && moved < 0.3) {
1100
+ stuckTicks++
1101
+ if (stuckTicks >= 4) { // 4 × 1s = 4s of no movement
1102
+ log.warn('follow_stuck_reset', { targetDist, moved, target: state.followTarget })
1103
+ movement.forceStop('stuck')
1104
+ bot.clearControlStates()
1105
+ movement.follow(target.entity, 2, movement.PRIORITY.LOW, 'follow')
1106
+ stuckTicks = 0
1107
+ }
1108
+ } else {
1109
+ stuckTicks = 0
1110
+ }
1111
+ }
1112
+ lastBotPos = pos && !isNaN(pos.x) ? { x: pos.x, z: pos.z } : lastBotPos
1113
+ }
1114
+ }
1115
+ } else {
1116
+ lostTicks = 0
1117
+ followRefreshTicks = 0
1118
+ stuckTicks = 0
1119
+ stuckCheckTicks = 0
1120
+ }
1121
+
1122
+ if (prevGoal === 'following' && state.goal !== 'following') {
1123
+ movement.stop('follow')
1124
+ bot.clearControlStates()
1125
+ }
1126
+
1127
+ prevGoal = state.goal
1128
+ prevFollowTarget = state.followTarget
1129
+ }, 250)
1130
+ }
1131
+
1132
+ function getNearestPlayer() {
1133
+ let nearest = null, minDist = Infinity
1134
+ for (const name in bot.players) {
1135
+ if (name === BOT_NAME) continue
1136
+ const p = bot.players[name]
1137
+ if (!p.entity) continue
1138
+ const d = bot.entity.position.distanceTo(p.entity.position)
1139
+ if (d < minDist) { minDist = d; nearest = { name, entity: p.entity, dist: d } }
1140
+ }
1141
+ return nearest
1142
+ }
1143
+
1144
+ // Look up a specific player by name (for follow target tracking)
1145
+ function getPlayer(username) {
1146
+ if (!username) return null
1147
+ const p = bot.players[username]
1148
+ if (!p?.entity) return null
1149
+ return { name: username, entity: p.entity, dist: bot.entity.position.distanceTo(p.entity.position) }
1150
+ }
1151
+
1152
+ // ── Decision Pipeline ──────────────────────────────────────────────────────────
1153
+
1154
+ async function handleMessage(username, message) {
1155
+ // INSULT CHECK — runs BEFORE LLM, always
1156
+ if (detectInsult(message)) {
1157
+ bumpAnger(username, 1, 'insulted me')
1158
+ const rec = anger.get(username)
1159
+ if (rec.level >= 3) safeChat(`Watch your mouth, ${username}.`)
1160
+ else safeChat(`Don't talk to me like that.`)
1161
+ if (maybeAttackForAnger(username)) return
1162
+ }
1163
+
1164
+ if (llmBusy) { safeChat('Hold on...'); return }
1165
+ llmBusy = true
1166
+
1167
+ try {
1168
+ const intent = classifyIntent(message)
1169
+ console.log(`[${BOT_NAME}] intent=${intent} from=${username} msg="${message}"`)
1170
+
1171
+ const survivalBlock = evaluateSurvival(state)
1172
+ if (survivalBlock && ['follow','gather','explore','attack','collect','build','craft'].includes(intent)) {
1173
+ safeChat(survivalBlock.say)
1174
+ return
1175
+ }
1176
+
1177
+ const groundedState = buildGroundedState(bot, state, memory, anger, envPerception)
1178
+
1179
+ let result
1180
+ try {
1181
+ const raw = await queryLLM(groundedState, intent, message, BOT_NAME)
1182
+ result = validateLLMOutput(raw)
1183
+ if (!result) {
1184
+ console.warn(`[${BOT_NAME}] LLM output invalid:`, JSON.stringify(raw))
1185
+ result = safeDefault(intent)
1186
+ }
1187
+ } catch (err) {
1188
+ console.error(`[${BOT_NAME}] LLM error:`, err.message)
1189
+ safeChat(`LLM error: ${err.message.slice(0, 60)}`)
1190
+ result = safeDefault(intent)
1191
+ }
1192
+
1193
+ console.log(`[${BOT_NAME}] decision=${result.decision} action=${result.action} reason="${result.reason}"`)
1194
+
1195
+ executeAction(result, username, groundedState)
1196
+ safeChat(result.say)
1197
+
1198
+ if (result.action !== 'none') {
1199
+ rememberEvent(memory, 'acted', { intent, action: result.action, decision: result.decision })
1200
+ }
1201
+ } catch (err) {
1202
+ console.error(`[${BOT_NAME}] handleMessage fatal:`, err.message)
1203
+ safeChat(`Internal error: ${err.message.slice(0, 80)}`)
1204
+ } finally {
1205
+ llmBusy = false
1206
+ }
1207
+ }
1208
+
1209
+ // ── Safe Action Map ────────────────────────────────────────────────────────────
1210
+
1211
+ function executeAction(result, username, groundedState) {
1212
+ if (result.decision !== 'accept') return
1213
+
1214
+ const movementActions = ['follow','explore','gather_wood','craft_planks','attack_mobs','attack_player','collect_items','build_house_smart']
1215
+ if (movementActions.includes(result.action) && state.energy < 25) {
1216
+ result.say = 'Too tired right now.'
1217
+ return
1218
+ }
1219
+
1220
+ const taskActions = ['explore','gather_wood','craft_planks','go_to','attack_mobs','attack_player','collect_items','craft','build_house_smart']
1221
+ if (taskActions.includes(result.action) && taskBusy) {
1222
+ result.say = "Still busy — give me a sec."
1223
+ return
1224
+ }
1225
+
1226
+ switch (result.action) {
1227
+
1228
+ case 'follow':
1229
+ state.followTarget = username // track who specifically — survives other players joining
1230
+ setGoal('following', { source: 'llm', reason: 'action=follow' })
1231
+ state.idleTicks = 0
1232
+ rememberKnowledge(memory, 'following_player', username)
1233
+ break
1234
+
1235
+ case 'stop':
1236
+ cancelCurrentTask()
1237
+ state.followTarget = null
1238
+ break
1239
+
1240
+ case 'explore': runTask('exploring', tasks.taskExplore); break
1241
+ case 'craft_planks': runTask('crafting', tasks.taskCraftPlanks); break
1242
+
1243
+ case 'gather_wood': {
1244
+ const hasLogs = groundedState.nearbyBlocks.some(b => b.type.endsWith('_log'))
1245
+ if (!hasLogs) { result.say = 'No trees in range.'; return }
1246
+ runTask('gathering', tasks.taskGatherWood)
1247
+ break
1248
+ }
1249
+
1250
+ case 'go_to':
1251
+ if (result.target) runTask('going_to', () => tasks.taskGoTo(result.target))
1252
+ break
1253
+
1254
+ case 'remember_here':
1255
+ rememberLocation(memory, `${username}_mark`, bot.entity.position)
1256
+ break
1257
+
1258
+ case 'attack_mobs':
1259
+ if (!groundedState.hostileMobs.length) { result.say = 'No hostile mobs visible.'; return }
1260
+ runTask('attacking', tasks.taskAttackMobs)
1261
+ break
1262
+
1263
+ case 'attack_player': {
1264
+ const tgt = result.target
1265
+ const player = tgt ? bot.players[tgt] : null
1266
+ if (!player?.entity) { result.say = `Can't see ${tgt || 'them'}.`; return }
1267
+ runTask('attacking', () => tasks.taskAttackPlayer(player.entity, tgt))
1268
+ break
1269
+ }
1270
+
1271
+ case 'collect_items':
1272
+ if (groundedState.droppedCount === 0) { result.say = 'No items on the ground.'; return }
1273
+ runTask('collecting', tasks.taskCollectNearby)
1274
+ break
1275
+
1276
+ case 'craft': {
1277
+ const itemName = result.target || 'crafting_table'
1278
+ runTask('crafting', () => tasks.taskCraftItem(itemName))
1279
+ break
1280
+ }
1281
+
1282
+ case 'place_block': {
1283
+ const blockName = result.target || 'crafting_table'
1284
+ runTask('placing', () => tasks.taskPlaceBlock(blockName))
1285
+ break
1286
+ }
1287
+
1288
+ case 'mine_block': {
1289
+ const blockName = result.target || 'stone'
1290
+ const m = blockName.match(/^(.+?)\s*x?\s*(\d+)$/)
1291
+ const name = m ? m[1] : blockName
1292
+ const count = m ? parseInt(m[2]) : 1
1293
+ runTask('mining', () => tasks.taskMineBlock(name, Math.min(count, 16)))
1294
+ break
1295
+ }
1296
+
1297
+ case 'eat_food':
1298
+ runTask('eating', tasks.taskEatFood)
1299
+ break
1300
+
1301
+ case 'flee':
1302
+ runTask('fleeing', tasks.taskFlee)
1303
+ break
1304
+
1305
+ case 'escape':
1306
+ runTask('escaping', tasks.taskEscape)
1307
+ break
1308
+
1309
+ case 'build_house_smart':
1310
+ runTask('building', tasks.taskBuildHouseSmart)
1311
+ break
1312
+
1313
+ case 'none':
1314
+ default: break
1315
+ }
1316
+ }
1317
+
1318
+ // ── Fallback Commands (LLM offline) ───────────────────────────────────────────
1319
+
1320
+ function fallbackCommand(username, message) {
1321
+ const cmd = message.trim().toLowerCase()
1322
+
1323
+ if (cmd === 'follow me') { if (state.energy < 25) { safeChat('Too tired.'); return }; state.followTarget = username; setGoal('following', { source: 'fallback_cmd', reason: 'follow_me' }); safeChat('Following.') }
1324
+ else if (cmd === 'stop') { cancelCurrentTask(); state.followTarget = null; safeChat('Stopped.') }
1325
+ else if (cmd === 'status') { safeChat(`Goal: ${state.goal} | E:${state.energy.toFixed(0)} H:${state.hunger.toFixed(0)} | anger:${anger.size}`) }
1326
+ else if (cmd === 'explore') { if (!runTask('exploring', tasks.taskExplore)) safeChat("Busy.") }
1327
+ else if (cmd === 'get wood' || cmd === 'chop tree'){ if (!runTask('gathering', tasks.taskGatherWood)) safeChat("Busy.") }
1328
+ else if (cmd === 'make planks') { if (!runTask('crafting', tasks.taskCraftPlanks)) safeChat("Busy.") }
1329
+ else if (cmd === 'attack' || cmd === 'fight') { if (!runTask('attacking', tasks.taskAttackMobs)) safeChat("Busy.") }
1330
+ else if (cmd === 'collect' || cmd === 'pick up') { if (!runTask('collecting', tasks.taskCollectNearby)) safeChat("Busy.") }
1331
+ else if (cmd === 'build house') { if (!runTask('building', tasks.taskBuildHouseSmart)) safeChat("Busy.") }
1332
+ else if (cmd === 'inventory') { const it = bot.inventory.items(); safeChat(it.length ? it.map(i=>`${i.name}x${i.count}`).join(', ') : 'Empty.') }
1333
+ else if (cmd === 'look around') { const gs = buildGroundedState(bot, state, memory, anger, envPerception); safeChat(`I see: ${chatSummary(gs) || 'nothing'}`) }
1334
+ else {
1335
+ const m = cmd.match(/^craft (.+)$/); if (m) { if (!runTask('crafting', () => tasks.taskCraftItem(m[1]))) safeChat("Busy."); return }
1336
+ const p = cmd.match(/^place (.+)$/); if (p) { if (!runTask('placing', () => tasks.taskPlaceBlock(p[1].trim()))) safeChat("Busy."); return }
1337
+ const mn = cmd.match(/^mine (.+)$/); if (mn) {
1338
+ const parts = mn[1].trim().match(/^(.+?)\s+(\d+)$/)
1339
+ const name = parts ? parts[1] : mn[1].trim()
1340
+ const cnt = parts ? Math.min(parseInt(parts[2]), 16) : 1
1341
+ if (!runTask('mining', () => tasks.taskMineBlock(name, cnt))) safeChat("Busy."); return
1342
+ }
1343
+ if (cmd === 'eat') { if (!runTask('eating', tasks.taskEatFood)) safeChat("Busy."); return }
1344
+ if (cmd === 'flee' || cmd === 'run') { if (!runTask('fleeing', tasks.taskFlee)) safeChat("Busy."); return }
1345
+ if (cmd === 'escape' || cmd === 'climb out' || cmd === 'get out') { if (!runTask('escaping', tasks.taskEscape)) safeChat("Busy."); return }
1346
+ const w = cmd.match(/^where is (.+)$/); if (w) { const loc = recallLocation(memory, w[1].trim()); safeChat(loc ? `${w[1]}: ${loc.pos.x}, ${loc.pos.y}, ${loc.pos.z}` : `Don't know.`); return }
1347
+ const g = cmd.match(/^go to (.+)$/); if (g) { if (!runTask('going_to', () => tasks.taskGoTo(g[1].trim()))) safeChat("Busy."); else safeChat(`Going to ${g[1]}.`) }
1348
+ }
1349
+ }
1350
+
1351
+ // ── Chat Entry ─────────────────────────────────────────────────────────────────
1352
+
1353
+ bot.on('chat', (username, message) => {
1354
+ if (username === BOT_NAME) return
1355
+ // Ignore other bot instances: their spawn announcements and busy responses
1356
+ if (/online \(.+\)\.?$/.test(message)) return
1357
+ if (message === 'Hold on...' || message === 'LLM offline — using fallback commands.') return
1358
+ try {
1359
+ if (llmEnabled) handleMessage(username, message)
1360
+ else fallbackCommand(username, message)
1361
+ } catch (err) {
1362
+ console.error(`[${BOT_NAME}] chat handler error:`, err)
1363
+ safeChat(`Error: ${err.message.slice(0, 80)}`)
1364
+ }
1365
+ })
1366
+
1367
+ // ── Exit reason persistence ───────────────────────────────────────────────────
1368
+ // Written before each exit so botSupervisor.js can classify the restart.
1369
+ // Consumed (and deleted) by the supervisor on the next launch.
1370
+
1371
+ const EXIT_REASON_PATH = path.join(__dirname, 'exit_reason.json')
1372
+ const RECOVERY_CHAIN_ID = process.env.RECOVERY_CHAIN_ID || null
1373
+
1374
+ function writeExitReason(reason) {
1375
+ try {
1376
+ fs.writeFileSync(EXIT_REASON_PATH, JSON.stringify({
1377
+ reason,
1378
+ exitAt: Date.now(),
1379
+ livenessState: liveness?.getState() || null,
1380
+ chainId: RECOVERY_CHAIN_ID,
1381
+ }))
1382
+ } catch {}
1383
+ }
1384
+
1385
+ // ── Errors / shutdown ─────────────────────────────────────────────────────────
1386
+
1387
+ bot.on('error', err => {
1388
+ log.error('socket_error', { message: err.message })
1389
+ })
1390
+ bot.on('kicked', reason => {
1391
+ const r = typeof reason === 'string' ? reason : JSON.stringify(reason)
1392
+ log.fatal('kicked', { reason: r })
1393
+ writeExitReason('kicked')
1394
+ setTimeout(() => process.exit(0), 200) // give logger time to flush
1395
+ })
1396
+ bot.on('end', reason => {
1397
+ const r = String(reason)
1398
+ log.warn('disconnected', { reason: r })
1399
+ const livenessState = liveness?.getState()
1400
+ const exitClass = (livenessState === 'LIVE_FATAL' || r === 'disconnect.quitting')
1401
+ ? 'entity_desync'
1402
+ : 'server_disconnect'
1403
+ writeExitReason(exitClass)
1404
+ setTimeout(() => process.exit(0), 200)
1405
+ })
1406
+
1407
+ process.on('uncaughtException', err => {
1408
+ log.fatal('uncaught_exception', { message: err.message, stack: err.stack })
1409
+ safeChat(`Crash: ${err.message.slice(0,80)}`)
1410
+ writeExitReason('crash')
1411
+ setTimeout(() => process.exit(1), 500)
1412
+ })
1413
+ process.on('unhandledRejection', err => {
1414
+ log.error('unhandled_rejection', { message: String(err) })
1415
+ })