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.
- package/README.md +351 -0
- package/botSupervisor.js +237 -0
- package/mc-server/bot/bot.js +1415 -0
- package/mc-server/bot/damagePipeline.js +402 -0
- package/mc-server/bot/engine.js +212 -0
- package/mc-server/bot/entityLiveness.js +121 -0
- package/mc-server/bot/env.js +38 -0
- package/mc-server/bot/environmentPerception.js +384 -0
- package/mc-server/bot/fatalDesyncRecovery.js +42 -0
- package/mc-server/bot/goalRegistry.js +49 -0
- package/mc-server/bot/healthIntegrityWatchdog.js +59 -0
- package/mc-server/bot/llm.js +232 -0
- package/mc-server/bot/locomotionRecovery.js +190 -0
- package/mc-server/bot/logger.js +63 -0
- package/mc-server/bot/memory.js +59 -0
- package/mc-server/bot/movementController.js +110 -0
- package/mc-server/bot/package.json +14 -0
- package/mc-server/bot/positionGuard.js +75 -0
- package/mc-server/bot/recoveryEngine.js +315 -0
- package/mc-server/bot/safeMineflayer.js +129 -0
- package/mc-server/bot/state.js +105 -0
- package/mc-server/bot/tasks.js +939 -0
- package/mc-server/server.properties +74 -0
- package/package.json +44 -0
- package/tui.js +1099 -0
|
@@ -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
|
+
})
|