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,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
|
+
}
|