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,121 @@
1
+ // entityLiveness.js — Entity desync detection and degraded-position provider.
2
+ //
3
+ // Diagnosis §5 confirmed bot.entity.position x/z becomes NaN during Mineflayer
4
+ // entity desync (velocity update with bad delta-time), and that this NaN window
5
+ // can persist for 50+ seconds. A binary valid/invalid model is too coarse:
6
+ // a briefly-invalid position shouldn't drop damage events, but a 30s NaN window
7
+ // means the client is corrupted and the bot should quit and reconnect.
8
+ //
9
+ // Five liveness states (diagnosis §13B):
10
+ // LIVE_VALID — position is live and finite
11
+ // LIVE_TRANSIENT_INVALID — invalid for < 500ms (post-teleport glitch, safe to ignore)
12
+ // LIVE_STALE_USING_CACHE — invalid but cached pos is < CACHE_MAX_MS old (degraded ok)
13
+ // LIVE_RECOVERING — invalid > 500ms, cache too stale (degrade + emit events)
14
+ // LIVE_FATAL — invalid > FATAL_THRESHOLD_MS (quit + reconnect)
15
+ //
16
+ // getBestPosition() implements degraded runtime mode (diagnosis §13E):
17
+ // instead of silently dropping operations, return the best available position
18
+ // with a source tag so callers can degrade gracefully instead of aborting.
19
+
20
+ const TRANSIENT_MS = 500
21
+ const CACHE_MAX_MS = 5000
22
+ const FATAL_THRESHOLD_MS = 8000 // was 30000 — the desync owner reconnects at DESYNC_HARD_MS,
23
+ // so LIVE_FATAL should never be reached in a pure desync now.
24
+ const DESYNC_HARD_MS = 5000 // structurally-invalid position for this long ⇒ reconnect now
25
+
26
+ const STATES = Object.freeze({
27
+ LIVE_VALID: 'LIVE_VALID',
28
+ LIVE_TRANSIENT_INVALID: 'LIVE_TRANSIENT_INVALID',
29
+ LIVE_STALE_USING_CACHE: 'LIVE_STALE_USING_CACHE',
30
+ LIVE_RECOVERING: 'LIVE_RECOVERING',
31
+ LIVE_FATAL: 'LIVE_FATAL',
32
+ })
33
+
34
+ function isValidVec(v) {
35
+ return v != null &&
36
+ typeof v.x === 'number' && Number.isFinite(v.x) &&
37
+ typeof v.y === 'number' && Number.isFinite(v.y) &&
38
+ typeof v.z === 'number' && Number.isFinite(v.z)
39
+ }
40
+
41
+ module.exports = function createEntityLivenessMonitor(bot, log) {
42
+ let currentState = STATES.LIVE_VALID
43
+ let lastValidPos = null // { x, y, z } snapshot when last valid
44
+ let lastValidAt = 0 // ms timestamp
45
+ let invalidSince = null // ms timestamp when invalidity began
46
+
47
+ function tick() {
48
+ const livePos = bot.entity?.position
49
+ const now = Date.now()
50
+
51
+ if (isValidVec(livePos)) {
52
+ const wasInvalid = currentState !== STATES.LIVE_VALID
53
+ lastValidPos = { x: livePos.x, y: livePos.y, z: livePos.z }
54
+ lastValidAt = now
55
+ invalidSince = null
56
+ if (wasInvalid) {
57
+ log.info('liveness_state_change', {
58
+ from: currentState, to: STATES.LIVE_VALID,
59
+ recoveredAfterMs: invalidSince ? now - invalidSince : null,
60
+ })
61
+ }
62
+ currentState = STATES.LIVE_VALID
63
+ return
64
+ }
65
+
66
+ // Position is invalid — classify severity
67
+ if (!invalidSince) invalidSince = now
68
+ const invalidMs = now - invalidSince
69
+ const cacheAgeMs = lastValidAt ? now - lastValidAt : Infinity
70
+
71
+ let next
72
+ if (invalidMs < TRANSIENT_MS) {
73
+ next = STATES.LIVE_TRANSIENT_INVALID
74
+ } else if (cacheAgeMs < CACHE_MAX_MS) {
75
+ next = STATES.LIVE_STALE_USING_CACHE
76
+ } else if (invalidMs < FATAL_THRESHOLD_MS) {
77
+ next = STATES.LIVE_RECOVERING
78
+ } else {
79
+ next = STATES.LIVE_FATAL
80
+ }
81
+
82
+ if (next !== currentState) {
83
+ log.warn('liveness_state_change', { from: currentState, to: next, invalidMs, cacheAgeMs })
84
+ currentState = next
85
+ }
86
+ }
87
+
88
+ // Returns the best available position with a quality tag, or null if nothing usable.
89
+ // source === 'live' — fresh, fully trust it
90
+ // source === 'cached' — recent cache, safe for most operations
91
+ // source === 'unknown' — stale/absent; caller should degrade or use blind behaviour
92
+ function getBestPosition() {
93
+ const livePos = bot.entity?.position
94
+ if (isValidVec(livePos)) {
95
+ return { pos: { x: livePos.x, y: livePos.y, z: livePos.z }, source: 'live' }
96
+ }
97
+ if (lastValidPos) {
98
+ const cacheAgeMs = lastValidAt ? Date.now() - lastValidAt : Infinity
99
+ if (cacheAgeMs < CACHE_MAX_MS) {
100
+ return { pos: lastValidPos, source: 'cached' }
101
+ }
102
+ // Cache is stale but it's all we have — return it as 'unknown' so callers
103
+ // can still attempt degraded reactions rather than doing nothing at all.
104
+ return { pos: lastValidPos, source: 'unknown' }
105
+ }
106
+ return null
107
+ }
108
+
109
+ function getState() { return currentState }
110
+ function getCachedPos() { return lastValidPos }
111
+ function getCachedAge() { return lastValidAt ? Date.now() - lastValidAt : Infinity }
112
+ function getInvalidMs() { return invalidSince ? Date.now() - invalidSince : 0 }
113
+
114
+ // True when the live position has been structurally invalid (NaN/null component,
115
+ // or entity absent) continuously for longer than DESYNC_HARD_MS. This is the single
116
+ // trigger for the fast reconnect path — a desync is binary, not graded: no local
117
+ // maneuver can fix it, so the only correct response is to quit and reconnect.
118
+ function isDesynced() { return invalidSince != null && (Date.now() - invalidSince) > DESYNC_HARD_MS }
119
+
120
+ return { tick, getState, getBestPosition, getCachedPos, getCachedAge, getInvalidMs, isDesynced, STATES }
121
+ }
@@ -0,0 +1,38 @@
1
+ // env.js — Minimal .env loader (zero deps)
2
+ // Looks up the project root by walking parent directories, loads .env if found.
3
+ // Existing process.env values take precedence (so CLI/TUI can override).
4
+
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+
8
+ function loadEnv() {
9
+ let dir = __dirname
10
+ for (let i = 0; i < 5; i++) {
11
+ const candidate = path.join(dir, '.env')
12
+ if (fs.existsSync(candidate)) {
13
+ try {
14
+ const content = fs.readFileSync(candidate, 'utf8')
15
+ for (const rawLine of content.split('\n')) {
16
+ const line = rawLine.trim()
17
+ if (!line || line.startsWith('#')) continue
18
+ const eq = line.indexOf('=')
19
+ if (eq === -1) continue
20
+ const key = line.slice(0, eq).trim()
21
+ let value = line.slice(eq + 1).trim()
22
+ if ((value.startsWith('"') && value.endsWith('"')) ||
23
+ (value.startsWith("'") && value.endsWith("'"))) {
24
+ value = value.slice(1, -1)
25
+ }
26
+ if (process.env[key] === undefined) process.env[key] = value
27
+ }
28
+ } catch {}
29
+ return candidate
30
+ }
31
+ const parent = path.dirname(dir)
32
+ if (parent === dir) break // reached filesystem root
33
+ dir = parent
34
+ }
35
+ return null
36
+ }
37
+
38
+ module.exports = { loadEnv }
@@ -0,0 +1,384 @@
1
+ // environmentPerception.js — Real-time environmental perception layer.
2
+ //
3
+ // Continuously scans nearby world state and builds:
4
+ // - local hazard map (fatal/damage/slow/liquid blocks in configurable radius)
5
+ // - traversability analysis (N/S/E/W classified as walkable/blocked/fall_risk/unsafe_*)
6
+ // - escape vector (direction with lowest danger score)
7
+ // - hazard memory (dangerous coordinates, TTL-evicted, max 100)
8
+ // - stuck classifier (lava_immobilization/collision_deadlock/terrain_deadlock/…)
9
+ // - locomotion risk (0–10 score)
10
+ //
11
+ // scan() → full perception report
12
+ // getLLMContext → compact string for LLM prompt (no raw chunk data)
13
+ // getHazardMemory→ read hazard memory entries
14
+
15
+ 'use strict'
16
+
17
+ // ── Hazard taxonomy ────────────────────────────────────────────────────────────
18
+
19
+ const SEVERITY = Object.freeze({ FATAL: 3, DAMAGE: 2, SLOW: 1, LIQUID: 1 })
20
+
21
+ const HAZARD_FATAL = new Set(['lava', 'fire', 'soul_fire'])
22
+ const HAZARD_DAMAGE = new Set(['cactus', 'sweet_berry_bush', 'wither_rose', 'magma_block',
23
+ 'campfire', 'soul_campfire'])
24
+ const HAZARD_SLOW = new Set(['cobweb', 'soul_sand', 'mud', 'honey_block', 'powder_snow'])
25
+ const HAZARD_LIQUID = new Set(['water'])
26
+ const AIR_BLOCKS = new Set(['air', 'cave_air', 'void_air'])
27
+
28
+ const SCAN_RADIUS_DEFAULT = 12
29
+ const HAZARD_TTL_MS = 5 * 60 * 1000 // 5 minutes
30
+ const HAZARD_MEMORY_MAX = 100
31
+
32
+ const DIRECTIONS = Object.freeze({
33
+ N: { dx: 0, dz: -1, label: 'north' },
34
+ S: { dx: 0, dz: 1, label: 'south' },
35
+ E: { dx: 1, dz: 0, label: 'east' },
36
+ W: { dx: -1, dz: 0, label: 'west' },
37
+ })
38
+
39
+ // ── Module factory ─────────────────────────────────────────────────────────────
40
+
41
+ module.exports = function createEnvironmentPerception(bot, log) {
42
+ // hazardMemory: array of { key, type, ts, pos } — oldest-first, evicted by age or length
43
+ const hazardMemory = []
44
+
45
+ // ── Block helpers ────────────────────────────────────────────────────────────
46
+
47
+ function _blockAt(x, y, z) {
48
+ try { return bot.blockAt(bot.vec3(x, y, z)) } catch { return null }
49
+ }
50
+
51
+ function _isAir(block) {
52
+ return !block || AIR_BLOCKS.has(block.name)
53
+ }
54
+
55
+ function _isSolid(block) {
56
+ return block != null && !AIR_BLOCKS.has(block.name) && block.boundingBox === 'block'
57
+ }
58
+
59
+ function _hazardSeverity(block) {
60
+ if (!block) return 0
61
+ if (HAZARD_FATAL.has(block.name)) return SEVERITY.FATAL
62
+ if (HAZARD_DAMAGE.has(block.name)) return SEVERITY.DAMAGE
63
+ if (HAZARD_SLOW.has(block.name)) return SEVERITY.SLOW
64
+ if (HAZARD_LIQUID.has(block.name)) return SEVERITY.LIQUID
65
+ return 0
66
+ }
67
+
68
+ function _hazardClass(block) {
69
+ if (!block) return null
70
+ if (HAZARD_FATAL.has(block.name)) return 'fatal'
71
+ if (HAZARD_DAMAGE.has(block.name)) return 'damage'
72
+ if (HAZARD_SLOW.has(block.name)) return 'slow'
73
+ if (HAZARD_LIQUID.has(block.name)) return 'liquid'
74
+ return null
75
+ }
76
+
77
+ function _coordKey(x, y, z) {
78
+ return `${Math.floor(x)},${Math.floor(y)},${Math.floor(z)}`
79
+ }
80
+
81
+ // ── Hazard memory ────────────────────────────────────────────────────────────
82
+
83
+ function _pruneMemory() {
84
+ const cutoff = Date.now() - HAZARD_TTL_MS
85
+ while (hazardMemory.length > 0 && hazardMemory[0].ts < cutoff) hazardMemory.shift()
86
+ }
87
+
88
+ function recordHazardPosition(x, y, z, type) {
89
+ const key = _coordKey(x, y, z)
90
+ if (!hazardMemory.find(e => e.key === key)) {
91
+ hazardMemory.push({ key, type, ts: Date.now(), pos: { x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) } })
92
+ while (hazardMemory.length > HAZARD_MEMORY_MAX) hazardMemory.shift()
93
+ }
94
+ }
95
+
96
+ function isKnownHazard(x, y, z) {
97
+ const key = _coordKey(x, y, z)
98
+ return hazardMemory.some(e => e.key === key)
99
+ }
100
+
101
+ // ── Traversability classifier ────────────────────────────────────────────────
102
+ // Returns { label, score } for one horizontal direction.
103
+ // Score: 0=safe, 10=lethal — used to rank escape vectors.
104
+
105
+ function _classifyDirection(bx, by, bz, dx, dz) {
106
+ let worstLabel = 'walkable', worstScore = 0
107
+
108
+ for (const step of [1, 2]) {
109
+ const tx = bx + dx * step
110
+ const tz = bz + dz * step
111
+
112
+ const foot = _blockAt(tx, by, tz)
113
+ const head = _blockAt(tx, by + 1, tz)
114
+ const floor = _blockAt(tx, by - 1, tz)
115
+ const sub1 = _blockAt(tx, by - 2, tz)
116
+ const sub2 = _blockAt(tx, by - 3, tz)
117
+
118
+ let label = 'walkable', score = 0
119
+
120
+ if (HAZARD_FATAL.has(foot?.name) || HAZARD_FATAL.has(head?.name)) {
121
+ label = (foot?.name === 'lava' || head?.name === 'lava') ? 'unsafe_lava' : 'unsafe_fire'
122
+ score = 10
123
+ } else if (HAZARD_DAMAGE.has(foot?.name) || HAZARD_DAMAGE.has(head?.name)) {
124
+ label = 'hazardous'; score = 6
125
+ } else if (HAZARD_LIQUID.has(foot?.name)) {
126
+ label = 'swim_risk'; score = 3
127
+ } else if (_isSolid(foot) && _isSolid(head)) {
128
+ label = 'blocked'; score = 7
129
+ } else if (!_isSolid(foot) && _isSolid(head)) {
130
+ label = 'low_ceiling'; score = 2
131
+ } else if (_isAir(foot) && _isAir(floor)) {
132
+ const depth = _isAir(sub1) ? (_isAir(sub2) ? 3 : 2) : 1
133
+ label = depth >= 3 ? 'fall_risk' : 'step_down'
134
+ score = depth >= 3 ? 7 : 1
135
+ } else if (isKnownHazard(tx, by, tz)) {
136
+ label = 'hazard_memory'; score = Math.max(score, 5)
137
+ }
138
+
139
+ if (HAZARD_SLOW.has(floor?.name)) score += 1
140
+
141
+ if (score > worstScore) { worstScore = score; worstLabel = label }
142
+ if (worstScore >= 10) break // already worst possible
143
+ }
144
+
145
+ return { label: worstLabel, score: worstScore }
146
+ }
147
+
148
+ // ── Cliff detector ───────────────────────────────────────────────────────────
149
+
150
+ function _detectCliff(bx, by, bz) {
151
+ for (const [key, dir] of Object.entries(DIRECTIONS)) {
152
+ const tx = bx + dir.dx, tz = bz + dir.dz
153
+ if (_isAir(_blockAt(tx, by, tz)) &&
154
+ _isAir(_blockAt(tx, by - 1, tz)) &&
155
+ _isAir(_blockAt(tx, by - 2, tz))) {
156
+ return { hasCliff: true, direction: key, label: dir.label }
157
+ }
158
+ }
159
+ return { hasCliff: false, direction: null, label: null }
160
+ }
161
+
162
+ // ── Risk scorer ──────────────────────────────────────────────────────────────
163
+
164
+ function _computeRisk(s) {
165
+ let r = 0
166
+ if (s.maxSeverity >= SEVERITY.FATAL) r += 5
167
+ if (s.maxSeverity >= SEVERITY.DAMAGE) r += 2
168
+ if (s.isEnclosed) r += 2
169
+ if (s.cliff.hasCliff) r += 1
170
+ for (const v of Object.values(s.traversability)) {
171
+ if (['unsafe_lava','unsafe_fire','fall_risk'].includes(v)) r++
172
+ }
173
+ if (HAZARD_FATAL.has(s.feetBlock?.name) || HAZARD_FATAL.has(s.headBlock?.name)) r += 4
174
+ if (HAZARD_DAMAGE.has(s.standingOn?.name)) r += 2
175
+ return Math.min(10, r)
176
+ }
177
+
178
+ // ── Stuck classifier ─────────────────────────────────────────────────────────
179
+
180
+ function _classifyStuck({ feetBlock, headBlock, hazards, traversability, locomotionRisk }) {
181
+ if (HAZARD_FATAL.has(feetBlock?.name)) return 'lava_immobilization'
182
+ if (HAZARD_FATAL.has(headBlock?.name)) return 'suffocation_hazard'
183
+ const blocked = Object.values(traversability).filter(v =>
184
+ ['blocked','unsafe_lava','unsafe_fire'].includes(v)
185
+ ).length
186
+ if (blocked >= 4) return 'collision_deadlock'
187
+ if (locomotionRisk >= 8) return 'terrain_deadlock'
188
+ if (HAZARD_LIQUID.has(feetBlock?.name)) return 'liquid_drag'
189
+ if (hazards.some(h => h.severity === SEVERITY.FATAL && h.dist < 3)) return 'lava_proximity'
190
+ return 'movement_blocked'
191
+ }
192
+
193
+ // ── Hazard summary text ──────────────────────────────────────────────────────
194
+
195
+ function _hazardSummary(hazards) {
196
+ if (!hazards.length) return 'clear'
197
+ const seen = new Set()
198
+ const parts = []
199
+ for (const h of hazards.slice(0, 5)) {
200
+ if (seen.has(h.type)) continue
201
+ seen.add(h.type)
202
+ const dir = Math.abs(h.dx) >= Math.abs(h.dz)
203
+ ? (h.dx > 0 ? 'E' : 'W')
204
+ : (h.dz > 0 ? 'S' : 'N')
205
+ parts.push(`${h.type.replace(/_/g,' ')} ${Math.round(h.dist)}m ${dir}`)
206
+ }
207
+ return parts.join('; ')
208
+ }
209
+
210
+ // ── Main scan ────────────────────────────────────────────────────────────────
211
+
212
+ function scan(radius = SCAN_RADIUS_DEFAULT) {
213
+ _pruneMemory()
214
+
215
+ if (!bot.entity?.position) return _invalid('no_entity')
216
+
217
+ const pos = bot.entity.position
218
+ // NaN/null component = entity desync (LIVE_FATAL window). Math.floor(NaN) = NaN,
219
+ // making every blockAt probe return null — scan would report "all clear" and
220
+ // escapeVector would default North. Reject early so callers see valid:false,
221
+ // not a plausible-looking zero-risk result.
222
+ if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y) || !Number.isFinite(pos.z)) {
223
+ log.warn('perception_position_invalid', { x: pos.x, y: pos.y, z: pos.z })
224
+ return _invalid('position_nan')
225
+ }
226
+
227
+ const bx = Math.floor(pos.x)
228
+ const by = Math.floor(pos.y)
229
+ const bz = Math.floor(pos.z)
230
+
231
+ // Collect hazards in sphere of radius
232
+ const hazards = []
233
+ const nearbyBlocksCompact = []
234
+ let maxSeverity = 0
235
+
236
+ for (let dx = -radius; dx <= radius; dx++) {
237
+ for (let dy = -2; dy <= 3; dy++) {
238
+ for (let dz = -radius; dz <= radius; dz++) {
239
+ if (dx*dx + dz*dz > radius*radius) continue
240
+ const block = _blockAt(bx+dx, by+dy, bz+dz)
241
+ if (!block) continue
242
+ const sev = _hazardSeverity(block)
243
+ if (sev <= 0) continue
244
+
245
+ const dist = Math.sqrt(dx*dx + dy*dy + dz*dz)
246
+ hazards.push({
247
+ type: block.name,
248
+ hazardClass: _hazardClass(block),
249
+ severity: sev,
250
+ dx, dy, dz, dist,
251
+ })
252
+ if (sev > maxSeverity) maxSeverity = sev
253
+ recordHazardPosition(bx+dx, by+dy, bz+dz, _hazardClass(block))
254
+
255
+ // Compact list (close range only)
256
+ if (Math.abs(dx) <= 4 && Math.abs(dy) <= 2 && Math.abs(dz) <= 4) {
257
+ nearbyBlocksCompact.push({ type: block.name, dx, dy, dz })
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ hazards.sort((a, b) => a.dist - b.dist)
264
+
265
+ const standingOn = _blockAt(bx, by-1, bz)
266
+ const feetBlock = _blockAt(bx, by, bz)
267
+ const headBlock = _blockAt(bx, by+1, bz)
268
+
269
+ // Traversability per direction
270
+ const traversability = {}
271
+ const dirScores = {}
272
+ for (const [key, dir] of Object.entries(DIRECTIONS)) {
273
+ const result = _classifyDirection(bx, by, bz, dir.dx, dir.dz)
274
+ traversability[key] = result.label
275
+ dirScores[key] = result.score
276
+ }
277
+
278
+ // Escape vector: direction with lowest danger
279
+ const [bestDir] = Object.entries(dirScores).sort((a, b) => a[1] - b[1])
280
+ const escapeDir = DIRECTIONS[bestDir[0]]
281
+ const escapeVector = {
282
+ direction: bestDir[0],
283
+ label: escapeDir.label,
284
+ dx: escapeDir.dx,
285
+ dz: escapeDir.dz,
286
+ score: bestDir[1],
287
+ }
288
+
289
+ // Enclosed: 3+ directions lethal/blocked
290
+ const blockedDirs = Object.values(traversability).filter(v =>
291
+ ['blocked','unsafe_lava','unsafe_fire','hazardous'].includes(v)
292
+ ).length
293
+ const isEnclosed = blockedDirs >= 3
294
+
295
+ const cliff = _detectCliff(bx, by, bz)
296
+
297
+ const locomotionRisk = _computeRisk({ maxSeverity, isEnclosed, cliff, traversability,
298
+ feetBlock, headBlock, standingOn })
299
+ const stuckClass = _classifyStuck({ feetBlock, headBlock, hazards, traversability, locomotionRisk })
300
+ const hazardSummary = _hazardSummary(hazards)
301
+
302
+ const result = {
303
+ valid: true,
304
+ hazards: hazards.slice(0, 8),
305
+ hazardSummary,
306
+ traversability,
307
+ escapeVector,
308
+ standingOn: standingOn?.name || 'unknown',
309
+ feetBlock: feetBlock?.name || 'air',
310
+ headBlock: headBlock?.name || 'air',
311
+ locomotionRisk,
312
+ isEnclosed,
313
+ cliffNearby: cliff.hasCliff,
314
+ cliffDirection: cliff.label,
315
+ stuckClass,
316
+ nearbyBlocksCompact: nearbyBlocksCompact.slice(0, 12),
317
+ hazardMemorySize: hazardMemory.length,
318
+ }
319
+
320
+ if (hazards.length > 0 || isEnclosed || cliff.hasCliff) {
321
+ log.info('environment_scan', {
322
+ hazardCount: hazards.length,
323
+ closest: hazards[0] ? `${hazards[0].type}@(${hazards[0].dx},${hazards[0].dy},${hazards[0].dz})` : null,
324
+ locomotionRisk,
325
+ isEnclosed,
326
+ cliffNearby: cliff.hasCliff,
327
+ escapeDir: escapeVector.direction,
328
+ stuckClass,
329
+ })
330
+ }
331
+
332
+ return result
333
+ }
334
+
335
+ // ── LLM context string ───────────────────────────────────────────────────────
336
+ // Compact symbolic text; fits within the existing prompt without bloat.
337
+
338
+ function getLLMContext(s) {
339
+ if (!s?.traversability) return 'perception: unavailable'
340
+ const travLines = Object.entries(s.traversability)
341
+ .map(([d, v]) => ` ${d}:${v}`)
342
+ .join(' ')
343
+ const flags = [
344
+ s.isEnclosed && 'ENCLOSED',
345
+ s.cliffNearby && `cliff-${s.cliffDirection}`,
346
+ s.locomotionRisk >= 7 && `HIGH-RISK(${s.locomotionRisk})`,
347
+ ].filter(Boolean).join(' ')
348
+
349
+ return [
350
+ `standing_on: ${s.standingOn}`,
351
+ `hazards: ${s.hazardSummary}`,
352
+ `risk: ${s.locomotionRisk}/10${flags ? ' ' + flags : ''}`,
353
+ `movement: ${travLines}`,
354
+ `escape: ${s.escapeVector.label}`,
355
+ ].join(' | ')
356
+ }
357
+
358
+ // Returns an explicitly-invalid scan result. Callers must check valid===false before
359
+ // trusting any fields — in particular locomotionRisk and escapeVector are meaningless.
360
+ function _invalid(reason) {
361
+ return {
362
+ valid: false,
363
+ reason,
364
+ hazards: [],
365
+ hazardSummary: 'position_invalid',
366
+ traversability: { N:'unknown', S:'unknown', E:'unknown', W:'unknown' },
367
+ escapeVector: { direction:'N', label:'north', dx:0, dz:-1, score:0 },
368
+ standingOn: 'unknown',
369
+ feetBlock: 'unknown',
370
+ headBlock: 'unknown',
371
+ locomotionRisk: null,
372
+ isEnclosed: false,
373
+ cliffNearby: false,
374
+ cliffDirection: null,
375
+ stuckClass: 'unknown',
376
+ nearbyBlocksCompact: [],
377
+ hazardMemorySize: hazardMemory.length,
378
+ }
379
+ }
380
+
381
+ function getHazardMemory() { return [...hazardMemory] }
382
+
383
+ return { scan, getLLMContext, recordHazardPosition, isKnownHazard, getHazardMemory }
384
+ }
@@ -0,0 +1,42 @@
1
+ // fatalDesyncRecovery.js — Detect entity position desync; report it to the arbiter.
2
+ //
3
+ // When bot.entity.position has a NaN/null component (Mineflayer entity desync — a
4
+ // velocity packet with a bad delta-time corrupts the position) for longer than
5
+ // DESYNC_HARD_MS (~5s), the client is in an unrecoverable state: no local maneuver
6
+ // (cancelTask, blind survival, escape) can fix it, because the bot genuinely isn't
7
+ // where it thinks it is (diagnosis#2 §1.3). The only correct response is to reconnect.
8
+ //
9
+ // This module DETECTS and REPORTS only. recoveryEngine is the single arbiter that
10
+ // owns the reconnect — see diagnosis#2 §2.1: recovery used to be multi-headed and the
11
+ // heads raced (this module's own bot.quit() fired ~4s before / after others' would
12
+ // have). Now the flow is: detect desync → recovery.report('DESYNC') → arbiter quits.
13
+ // botSupervisor.js still gets its short reconnect backoff because the arbiter writes
14
+ // exit_reason.json {reason:"entity_desync"} before quitting.
15
+
16
+ const CHECK_INTERVAL_MS = 1000
17
+
18
+ module.exports = function createFatalDesyncRecovery(bot, liveness, log, onDesync) {
19
+ let reported = false
20
+
21
+ const interval = setInterval(() => {
22
+ if (reported) return
23
+ if (!liveness.isDesynced()) return
24
+
25
+ reported = true
26
+ clearInterval(interval)
27
+ log.fatal('fatal_desync_quit', {
28
+ invalidMs: liveness.getInvalidMs(),
29
+ cachedPos: liveness.getCachedPos(),
30
+ livenessState: liveness.getState(),
31
+ })
32
+ try {
33
+ onDesync({ source: 'fatal_desync', invalidMs: liveness.getInvalidMs() })
34
+ } catch (e) {
35
+ log.error('fatal_desync_report_error', { message: e.message })
36
+ // Last-resort fallback: if the arbiter is unreachable, quit anyway.
37
+ try { bot.quit() } catch { process.exit(0) }
38
+ }
39
+ }, CHECK_INTERVAL_MS)
40
+
41
+ return { interval }
42
+ }
@@ -0,0 +1,49 @@
1
+ // goalRegistry.js — Encapsulates state.goal mutations.
2
+ // Every transition is logged with source, previous goal, and reason.
3
+ // Invariant violations are logged loudly but still proceed — the mutation
4
+ // is not blocked, which avoids introducing new failure modes during the refactor.
5
+
6
+ const COMBAT_GOALS = new Set(['attacking'])
7
+
8
+ module.exports = function createGoalRegistry(state, getOwnership, log) {
9
+ // getOwnership() → { taskBusy, currentTaskToken }
10
+
11
+ function setGoal(name, meta = {}) {
12
+ const prev = state.goal
13
+ const { source = 'unknown', token = null, reason = null } = meta
14
+ const { taskBusy, currentTaskToken } = getOwnership()
15
+
16
+ // Invariant: 'following' must not overwrite active combat
17
+ if (name === 'following' && COMBAT_GOALS.has(prev)) {
18
+ log.warn('invariant_violation', {
19
+ violation: 'follow_overwrites_combat',
20
+ prev, next: name, source,
21
+ ...(reason && { reason }),
22
+ })
23
+ }
24
+
25
+ // Invariant: goal should not be set to a task name when no task is active
26
+ // (source 'task_start' is exempt — taskBusy is set the line before setGoal is called)
27
+ if (name !== 'idle' && name !== 'following' && name !== 'resting' &&
28
+ source !== 'task_start' && !taskBusy) {
29
+ log.warn('invariant_violation', {
30
+ violation: 'task_goal_without_active_task',
31
+ next: name, source, taskBusy,
32
+ ...(reason && { reason }),
33
+ })
34
+ }
35
+
36
+ log.debug('goal_transition', {
37
+ prev,
38
+ next: name,
39
+ source,
40
+ taskBusy,
41
+ ...(reason && { reason }),
42
+ ...(token && { token: token.toString() }),
43
+ })
44
+
45
+ state.goal = name
46
+ }
47
+
48
+ return { setGoal }
49
+ }