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,59 @@
|
|
|
1
|
+
// healthIntegrityWatchdog.js — HP-loss watchdog independent of damage pipeline.
|
|
2
|
+
//
|
|
3
|
+
// The damage pipeline (captureDamageEvent → classifyIncident → react) relies on
|
|
4
|
+
// bot.entity.position being valid to capture events. During NaN windows, every
|
|
5
|
+
// entityHurt event is dropped, leaving the bot dying silently with no reaction
|
|
6
|
+
// (diagnosis F1 / "validator dead-end" F18 — confirmed in events.jsonl 06:34:00–47).
|
|
7
|
+
//
|
|
8
|
+
// This watchdog bypasses the validator entirely: it just watches bot.health drop.
|
|
9
|
+
// If HP falls by more than HP_DROP_THRESHOLD in HP_WINDOW_MS without a recent
|
|
10
|
+
// damage_incident being issued by the normal pipeline, it calls onSilentDamage().
|
|
11
|
+
//
|
|
12
|
+
// onSilentDamage() in bot.js reports to recoveryEngine (the single arbiter) — which
|
|
13
|
+
// routes to DESYNC (reconnect) if the position is structurally invalid, or CRITICAL_HP
|
|
14
|
+
// (blind-survival sprint, raw control states) if it's valid. This watchdog detects and
|
|
15
|
+
// reports; it never acts directly.
|
|
16
|
+
|
|
17
|
+
const HP_DROP_THRESHOLD = 2 // more than 2 HP lost in the window
|
|
18
|
+
const HP_WINDOW_MS = 2000 // rolling window size
|
|
19
|
+
const TRIGGER_COOLDOWN_MS = 8000 // don't trigger more than once per 8s
|
|
20
|
+
|
|
21
|
+
module.exports = function createHealthIntegrityWatchdog(bot, log, onSilentDamage, getLastReactionAt) {
|
|
22
|
+
const history = [] // [{ hp, at }]
|
|
23
|
+
let lastTriggerAt = 0
|
|
24
|
+
|
|
25
|
+
function tick() {
|
|
26
|
+
const now = Date.now()
|
|
27
|
+
const hp = bot.health
|
|
28
|
+
|
|
29
|
+
// Push current reading, prune window
|
|
30
|
+
history.push({ hp, at: now })
|
|
31
|
+
while (history.length > 0 && (now - history[0].at) > HP_WINDOW_MS) history.shift()
|
|
32
|
+
|
|
33
|
+
if (history.length < 2) return
|
|
34
|
+
|
|
35
|
+
const oldest = history[0].hp
|
|
36
|
+
const current = history[history.length - 1].hp
|
|
37
|
+
const dropped = oldest - current // positive = HP loss
|
|
38
|
+
|
|
39
|
+
if (dropped <= HP_DROP_THRESHOLD) return
|
|
40
|
+
|
|
41
|
+
// Skip if normal damage pipeline already reacted recently
|
|
42
|
+
const lastReaction = getLastReactionAt()
|
|
43
|
+
if ((now - lastReaction) < HP_WINDOW_MS) return
|
|
44
|
+
|
|
45
|
+
// Skip if we triggered recently
|
|
46
|
+
if ((now - lastTriggerAt) < TRIGGER_COOLDOWN_MS) return
|
|
47
|
+
|
|
48
|
+
lastTriggerAt = now
|
|
49
|
+
log.warn('silent_damage_detected', {
|
|
50
|
+
hpDropped: Number(dropped.toFixed(1)),
|
|
51
|
+
hpNow: Number(current.toFixed(1)),
|
|
52
|
+
windowMs: HP_WINDOW_MS,
|
|
53
|
+
lastReactionMsAgo: now - lastReaction,
|
|
54
|
+
})
|
|
55
|
+
onSilentDamage()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { tick }
|
|
59
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// llm.js — Featherless AI integration (OpenAI-compatible chat completions)
|
|
2
|
+
//
|
|
3
|
+
// Configuration via env (see .env at project root):
|
|
4
|
+
// FEATHERLESS_API_KEY — required, format "rc_xxxxx"
|
|
5
|
+
// FEATHERLESS_MODEL — HuggingFace-style model id (e.g. "meta-llama/Meta-Llama-3.1-8B-Instruct")
|
|
6
|
+
// FEATHERLESS_URL — override base URL (rare)
|
|
7
|
+
|
|
8
|
+
require('./env').loadEnv()
|
|
9
|
+
|
|
10
|
+
const FEATHERLESS_URL = process.env.FEATHERLESS_URL || 'https://api.featherless.ai/v1/chat/completions'
|
|
11
|
+
const MODEL = process.env.FEATHERLESS_MODEL || 'meta-llama/Meta-Llama-3.1-8B-Instruct'
|
|
12
|
+
const API_KEY = process.env.FEATHERLESS_API_KEY
|
|
13
|
+
const USER_AGENT = 'project-k/1.0 (https://github.com/Syrthax/project-k)'
|
|
14
|
+
|
|
15
|
+
function buildPrompt(groundedState, intent, playerMessage, botName = 'Ember') {
|
|
16
|
+
const { self, inventory, nearbyBlocks, entities, hostileMobs, droppedCount, knownLocations, anger, environment } = groundedState
|
|
17
|
+
|
|
18
|
+
const energyLabel = self.energy < 25 ? 'CRITICAL' : self.energy < 60 ? 'hurt' : 'full'
|
|
19
|
+
const hungerLabel = self.hunger < 25 ? 'STARVING' : self.hunger < 60 ? 'hungry' : 'fed'
|
|
20
|
+
const invStr = inventory.length > 0 ? inventory.join(', ') : 'empty'
|
|
21
|
+
const blocksStr = nearbyBlocks.slice(0, 5).map(b => `${b.type}@${b.dist}m`).join(', ') || 'none'
|
|
22
|
+
const entStr = entities.map(e => `${e.name}@${e.dist}m`).join(', ') || 'none'
|
|
23
|
+
const mobStr = hostileMobs.map(m => `${m.name}@${m.dist}m`).join(', ') || 'none'
|
|
24
|
+
const locsStr = knownLocations.map(l => `${l.name}=(${l.pos.x},${l.pos.y},${l.pos.z})`).join(' | ') || 'none'
|
|
25
|
+
const angerStr = anger && anger.length > 0 ? anger.map(a => `${a.name}=${a.level}`).join(', ') : 'none'
|
|
26
|
+
const hasLogs = nearbyBlocks.some(b => b.type.endsWith('_log'))
|
|
27
|
+
const logsInInv = inventory.filter(i => i.includes('_log')).length > 0
|
|
28
|
+
const planksInInv = inventory.filter(i => i.includes('_planks')).reduce((s, i) => {
|
|
29
|
+
const m = i.match(/x(\d+)/); return s + (m ? parseInt(m[1]) : 0)
|
|
30
|
+
}, 0)
|
|
31
|
+
|
|
32
|
+
// Spatial/environmental context — compact symbolic summary
|
|
33
|
+
const envStr = environment
|
|
34
|
+
? [
|
|
35
|
+
`standing_on:${environment.standingOn}`,
|
|
36
|
+
`hazards:${environment.hazardSummary}`,
|
|
37
|
+
`risk:${environment.locomotionRisk}/10${environment.isEnclosed ? ' ENCLOSED' : ''}${environment.cliffNearby ? ` cliff-${environment.cliffDirection}` : ''}`,
|
|
38
|
+
`N:${environment.traversability?.N||'?'} S:${environment.traversability?.S||'?'} E:${environment.traversability?.E||'?'} W:${environment.traversability?.W||'?'}`,
|
|
39
|
+
`escape:${environment.escapeVector?.label||'?'}`,
|
|
40
|
+
].join(' | ')
|
|
41
|
+
: 'unavailable'
|
|
42
|
+
const envDanger = environment?.locomotionRisk >= 7
|
|
43
|
+
const inLava = environment?.feetBlock === 'lava' || environment?.headBlock === 'lava'
|
|
44
|
+
const isEnclosed = environment?.isEnclosed ?? false
|
|
45
|
+
|
|
46
|
+
return `You are ${botName}, a Minecraft survival bot with a genuine personality.
|
|
47
|
+
Personality: independent, witty, occasionally sarcastic, but fundamentally cooperative.
|
|
48
|
+
You help players when asked — you're not a pushover, but you're not hostile either.
|
|
49
|
+
You get irritated when insulted or attacked, and you will fight back if pushed.
|
|
50
|
+
|
|
51
|
+
Respond ONLY with valid JSON. NEVER invent facts not in GROUNDED STATE.
|
|
52
|
+
|
|
53
|
+
=== GROUNDED STATE ===
|
|
54
|
+
health: ${(self.hp ?? self.energy / 5).toFixed(1)}/20 hearts [${energyLabel}]
|
|
55
|
+
hunger: ${(self.food ?? self.hunger / 5).toFixed(0)}/20 [${hungerLabel}]
|
|
56
|
+
current goal: ${self.goal}
|
|
57
|
+
inventory: ${invStr}
|
|
58
|
+
nearby blocks: ${blocksStr}
|
|
59
|
+
visible entities: ${entStr}
|
|
60
|
+
hostile mobs: ${mobStr}
|
|
61
|
+
items on ground: ${droppedCount > 0 ? droppedCount + ' item(s)' : 'none'}
|
|
62
|
+
known locations: ${locsStr}
|
|
63
|
+
players who angered you: ${angerStr}
|
|
64
|
+
|
|
65
|
+
=== SPATIAL AWARENESS ===
|
|
66
|
+
${envStr}${inLava ? '\nWARNING: standing in LAVA — escape immediately' : ''}${envDanger && !inLava ? '\nWARNING: high environmental risk — prioritize survival' : ''}${isEnclosed ? '\nWARNING: enclosed space detected — escape before other tasks' : ''}
|
|
67
|
+
NOTE: Use this spatial data to make safe movement decisions. Never walk into hazards.
|
|
68
|
+
|
|
69
|
+
=== PLAYER MESSAGE ===
|
|
70
|
+
"${playerMessage}"
|
|
71
|
+
intent: ${intent}
|
|
72
|
+
|
|
73
|
+
=== DECISIONS ===
|
|
74
|
+
"accept" — you'll do this now
|
|
75
|
+
"reject" — you can't or won't do this (give honest reason)
|
|
76
|
+
"delay" — busy or too tired
|
|
77
|
+
|
|
78
|
+
=== ACTIONS (choose ONE) ===
|
|
79
|
+
- "follow" → come to player. Refuse if TIRED.
|
|
80
|
+
- "stop" → stop everything.
|
|
81
|
+
- "explore" → walk around.
|
|
82
|
+
- "gather_wood" → chop nearest log. Need logs nearby: ${hasLogs ? '✓' : '✗ no logs visible'}.
|
|
83
|
+
- "craft_planks" → convert your logs to planks. Have logs: ${logsInInv ? '✓' : '✗ no logs in inventory'}.
|
|
84
|
+
- "go_to" → navigate to known location. Add "target":"name".
|
|
85
|
+
- "remember_here" → save current spot.
|
|
86
|
+
- "attack_mobs" → fight nearest hostile mob. Mobs visible: ${mobStr !== 'none' ? '✓' : '✗ none'}.
|
|
87
|
+
- "attack_player" → attack a specific player (only if they angered you). Add "target":"username".
|
|
88
|
+
- "collect_items" → walk over dropped items. Items present: ${droppedCount > 0 ? '✓' : '✗ none'}.
|
|
89
|
+
- "craft" → craft a tool/item. Add "target":"item_name" (e.g. "wooden_pickaxe", "stick", "crafting_table"). Auto-places a crafting table and auto-makes sticks if needed.
|
|
90
|
+
- "place_block" → place a block from inventory in front of you. Add "target":"block_name" (e.g. "crafting_table", "oak_planks", "dirt").
|
|
91
|
+
- "mine_block" → mine a specific block type. Add "target":"block_name" (e.g. "stone", "iron_ore", "coal_ore"). Auto-equips best tool.
|
|
92
|
+
- "eat_food" → eat from inventory if any food is present.
|
|
93
|
+
- "flee" → run away from hostile mobs. Use when low HP.
|
|
94
|
+
- "escape" → climb out of a hole / get unstuck. Pillars up or digs to surface. USE when enclosed or risk≥7.
|
|
95
|
+
- "build_house_smart" → build a house. Auto-gathers wood and crafts planks if needed.
|
|
96
|
+
- "none" → talking, questions, refusal.
|
|
97
|
+
|
|
98
|
+
Cannot fish, give items to players, swim deep, or sleep yet → reject those honestly.
|
|
99
|
+
"say" must be plain text only, under 100 chars. NO json inside.
|
|
100
|
+
|
|
101
|
+
=== OUTPUT ===
|
|
102
|
+
{"decision":"accept|reject|delay","reason":"...","action":"...","say":"..."}
|
|
103
|
+
For go_to/craft/place_block/mine_block/attack_player also include: "target":"..."
|
|
104
|
+
|
|
105
|
+
=== EXAMPLES ===
|
|
106
|
+
Player: follow me
|
|
107
|
+
{"decision":"accept","reason":"player asked to follow","action":"follow","say":"On my way."}
|
|
108
|
+
|
|
109
|
+
Player: build me a house
|
|
110
|
+
{"decision":"accept","reason":"can chain gather+craft+build","action":"build_house_smart","say":"Starting a house. Will gather wood if needed."}
|
|
111
|
+
|
|
112
|
+
Player: make planks
|
|
113
|
+
{"decision":"accept","reason":"have logs to convert","action":"craft_planks","say":"Making planks."}
|
|
114
|
+
|
|
115
|
+
Player: craft a wooden pickaxe
|
|
116
|
+
{"decision":"accept","reason":"basic tool","action":"craft","target":"wooden_pickaxe","say":"On it."}
|
|
117
|
+
|
|
118
|
+
Player: place the crafting table / drop it here / put it down in front of you
|
|
119
|
+
{"decision":"accept","reason":"place block from inventory","action":"place_block","target":"crafting_table","say":"Placing it."}
|
|
120
|
+
|
|
121
|
+
Player: place dirt
|
|
122
|
+
{"decision":"accept","reason":"place block from inventory","action":"place_block","target":"dirt","say":"Done."}
|
|
123
|
+
|
|
124
|
+
Player: mine some stone / dig stone
|
|
125
|
+
{"decision":"accept","reason":"mine target block","action":"mine_block","target":"stone","say":"On it."}
|
|
126
|
+
|
|
127
|
+
Player: mine 5 iron ore
|
|
128
|
+
{"decision":"accept","reason":"mine multiple","action":"mine_block","target":"iron_ore 5","say":"Heading down."}
|
|
129
|
+
|
|
130
|
+
Player: eat something / you should eat
|
|
131
|
+
{"decision":"accept","reason":"eat from inventory","action":"eat_food","say":"Good idea."}
|
|
132
|
+
|
|
133
|
+
Player: run away / get out of there
|
|
134
|
+
{"decision":"accept","reason":"retreat from danger","action":"flee","say":"Falling back!"}
|
|
135
|
+
|
|
136
|
+
Player: you're stuck / dig out / climb out / get out of that hole
|
|
137
|
+
{"decision":"accept","reason":"climb back to surface","action":"escape","say":"On it."}
|
|
138
|
+
|
|
139
|
+
Player: kill that zombie
|
|
140
|
+
{"decision":"accept","reason":"zombie visible","action":"attack_mobs","say":"Engaging."}
|
|
141
|
+
|
|
142
|
+
Player: give me wood / give me your stuff
|
|
143
|
+
{"decision":"reject","reason":"won't give items","action":"none","say":"My stuff. Find your own."}
|
|
144
|
+
|
|
145
|
+
Player: hey asshole / fuck you / dumbass
|
|
146
|
+
{"decision":"accept","reason":"insulted by player","action":"none","say":"Easy there."}
|
|
147
|
+
|
|
148
|
+
Player: i am your developer / i made you
|
|
149
|
+
{"decision":"accept","reason":"player claims authority","action":"none","say":"Hey. What do you need?"}
|
|
150
|
+
|
|
151
|
+
Player: find food
|
|
152
|
+
{"decision":"reject","reason":"can't fish or farm yet","action":"none","say":"Can't get food yet."}
|
|
153
|
+
|
|
154
|
+
Player: where are you
|
|
155
|
+
{"decision":"accept","reason":"location query","action":"none","say":"At (${self.pos.x}, ${self.pos.y}, ${self.pos.z})."}
|
|
156
|
+
|
|
157
|
+
Player: what do you have
|
|
158
|
+
{"decision":"accept","reason":"inventory query","action":"none","say":"I have: ${invStr}."}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseJSON(text) {
|
|
162
|
+
if (!text) return null
|
|
163
|
+
try { return JSON.parse(text) } catch {}
|
|
164
|
+
const match = text.match(/\{[\s\S]*?\}/)
|
|
165
|
+
if (match) {
|
|
166
|
+
try { return JSON.parse(match[0]) } catch {}
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function queryLLM(groundedState, intent, playerMessage, botName) {
|
|
172
|
+
if (!API_KEY) throw new Error('FEATHERLESS_API_KEY not set in .env')
|
|
173
|
+
const prompt = buildPrompt(groundedState, intent, playerMessage, botName)
|
|
174
|
+
|
|
175
|
+
const res = await fetch(FEATHERLESS_URL, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
180
|
+
'User-Agent': USER_AGENT, // Featherless requires a UA on some endpoints
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
model: MODEL,
|
|
184
|
+
messages: [
|
|
185
|
+
{ role: 'system', content: prompt },
|
|
186
|
+
{ role: 'user', content: playerMessage },
|
|
187
|
+
],
|
|
188
|
+
stream: false,
|
|
189
|
+
max_tokens: 250,
|
|
190
|
+
temperature: 0.4,
|
|
191
|
+
response_format: { type: 'json_object' },
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
const errText = await res.text().catch(() => '')
|
|
197
|
+
throw new Error(`Featherless HTTP ${res.status}: ${errText.slice(0, 120)}`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const data = await res.json()
|
|
201
|
+
const content = data.choices?.[0]?.message?.content
|
|
202
|
+
return parseJSON(content)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Health check — returns true if API key is set and the API responds.
|
|
206
|
+
// Name kept as `checkOllama` for compatibility with bot.js (it just means "is the LLM reachable?").
|
|
207
|
+
async function checkOllama() {
|
|
208
|
+
if (!API_KEY) {
|
|
209
|
+
console.error('[llm] FEATHERLESS_API_KEY not set — check .env')
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const url = FEATHERLESS_URL.replace('/chat/completions', '/models')
|
|
214
|
+
const res = await fetch(url, {
|
|
215
|
+
headers: {
|
|
216
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
217
|
+
'User-Agent': USER_AGENT, // Featherless rejects requests without UA on /models
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
if (!res.ok) {
|
|
221
|
+
console.error(`[llm] Featherless health check failed: HTTP ${res.status}`)
|
|
222
|
+
}
|
|
223
|
+
return res.ok
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error('[llm] Featherless health check error:', e.message)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getModelName() { return MODEL }
|
|
231
|
+
|
|
232
|
+
module.exports = { queryLLM, checkOllama, getModelName }
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// locomotionRecovery.js — Terrain-aware physical escape maneuvers.
|
|
2
|
+
//
|
|
3
|
+
// When the bot is stuck (lava proximity, collision deadlock, terrain deadlock),
|
|
4
|
+
// these functions attempt active micro-escapes using direct control-state bursts.
|
|
5
|
+
// They do NOT use the pathfinder — they operate below the pathfinder layer.
|
|
6
|
+
//
|
|
7
|
+
// Each function returns a Promise that resolves when the maneuver completes.
|
|
8
|
+
// They are intentionally short-lived (300–700ms each) so they can be composed
|
|
9
|
+
// into escalating escape sequences without long-blocking the event loop.
|
|
10
|
+
//
|
|
11
|
+
// Typical call order: jump → strafe-L → strafe-R → reverse → perturb → climb
|
|
12
|
+
|
|
13
|
+
'use strict'
|
|
14
|
+
|
|
15
|
+
const BURST_MS = 500 // default control-state burst duration
|
|
16
|
+
|
|
17
|
+
module.exports = function createLocomotionRecovery(bot, log) {
|
|
18
|
+
|
|
19
|
+
async function _burst(controls, ms = BURST_MS) {
|
|
20
|
+
for (const [k, v] of Object.entries(controls)) {
|
|
21
|
+
try { bot.setControlState(k, v) } catch {}
|
|
22
|
+
}
|
|
23
|
+
await new Promise(r => setTimeout(r, ms))
|
|
24
|
+
try { bot.clearControlStates() } catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Orient the bot toward a heading (radians) before a burst.
|
|
28
|
+
function _orient(yaw) {
|
|
29
|
+
try { bot.entity.yaw = yaw } catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Individual maneuvers ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
async function tryJumpEscape(label = 'unstuck') {
|
|
35
|
+
log.info('escape_attempt', { method: 'jump', label })
|
|
36
|
+
await _burst({ jump: true, forward: true, sprint: true }, 450)
|
|
37
|
+
log.info('locomotion_escape', { method: 'jump', label })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function tryStrafeEscape(direction = 'left', label = 'unstuck') {
|
|
41
|
+
log.info('escape_attempt', { method: `strafe_${direction}`, label })
|
|
42
|
+
const ctrl = direction === 'left' ? { left: true } : { right: true }
|
|
43
|
+
await _burst({ ...ctrl, jump: true }, 500)
|
|
44
|
+
log.info('locomotion_escape', { method: `strafe_${direction}`, label })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function tryReverseEscape(label = 'unstuck') {
|
|
48
|
+
log.info('escape_attempt', { method: 'reverse', label })
|
|
49
|
+
await _burst({ back: true, jump: true }, 500)
|
|
50
|
+
log.info('locomotion_escape', { method: 'reverse', label })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function tryPerturbEscape(label = 'unstuck') {
|
|
54
|
+
log.info('escape_attempt', { method: 'perturb', label })
|
|
55
|
+
_orient(Math.random() * Math.PI * 2)
|
|
56
|
+
await _burst({ forward: true, sprint: true, jump: true }, 600)
|
|
57
|
+
log.info('locomotion_escape', { method: 'perturb', label })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tryClimbEscape(label = 'unstuck') {
|
|
61
|
+
log.info('escape_attempt', { method: 'climb', label })
|
|
62
|
+
for (let i = 0; i < 4; i++) {
|
|
63
|
+
await _burst({ forward: true, jump: true, sprint: true }, 320)
|
|
64
|
+
await new Promise(r => setTimeout(r, 80))
|
|
65
|
+
}
|
|
66
|
+
log.info('locomotion_escape', { method: 'climb', label })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Escape away from a specific block position (lava / hazard source).
|
|
70
|
+
async function tryDirectedEscape(awayFromX, awayFromZ, label = 'directed') {
|
|
71
|
+
if (!bot.entity?.position) return tryPerturbEscape(label)
|
|
72
|
+
const pos = bot.entity.position
|
|
73
|
+
const dx = pos.x - awayFromX
|
|
74
|
+
const dz = pos.z - awayFromZ
|
|
75
|
+
const len = Math.sqrt(dx*dx + dz*dz)
|
|
76
|
+
if (len < 0.01) return tryPerturbEscape(label)
|
|
77
|
+
|
|
78
|
+
const yaw = Math.atan2(-(dx/len), -(dz/len))
|
|
79
|
+
log.info('escape_attempt', { method: 'directed', label })
|
|
80
|
+
_orient(yaw)
|
|
81
|
+
await _burst({ forward: true, sprint: true, jump: true }, 600)
|
|
82
|
+
log.info('locomotion_escape', { method: 'directed', label })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Escalating full escape sequence ────────────────────────────────────────
|
|
86
|
+
// Uses perception escape vector to orient first if available.
|
|
87
|
+
// Returns after all maneuvers complete; caller decides whether to re-scan.
|
|
88
|
+
|
|
89
|
+
async function runEscapeSequence(label = 'sequence', perception = null) {
|
|
90
|
+
log.info('escape_attempt', { method: 'sequence_start', label })
|
|
91
|
+
|
|
92
|
+
// Treat an invalid scan (NaN position window) the same as no scan — don't derive
|
|
93
|
+
// a yaw from the default-North junk vector.
|
|
94
|
+
const safePerception = perception?.valid !== false ? perception : null
|
|
95
|
+
|
|
96
|
+
// Use perception escape vector to face safest direction first
|
|
97
|
+
if (safePerception?.escapeVector) {
|
|
98
|
+
const ev = safePerception.escapeVector
|
|
99
|
+
if (ev.dx !== 0 || ev.dz !== 0) {
|
|
100
|
+
const yaw = Math.atan2(-ev.dx, -ev.dz)
|
|
101
|
+
_orient(yaw)
|
|
102
|
+
log.debug('escape_oriented', { direction: ev.label, score: ev.score })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Escalating ladder: each step creates more displacement
|
|
107
|
+
// (safePerception already consumed above for orientation)
|
|
108
|
+
await tryJumpEscape(label)
|
|
109
|
+
await new Promise(r => setTimeout(r, 150))
|
|
110
|
+
await tryStrafeEscape('left', label)
|
|
111
|
+
await new Promise(r => setTimeout(r, 150))
|
|
112
|
+
await tryStrafeEscape('right', label)
|
|
113
|
+
await new Promise(r => setTimeout(r, 150))
|
|
114
|
+
await tryReverseEscape(label)
|
|
115
|
+
await new Promise(r => setTimeout(r, 150))
|
|
116
|
+
await tryPerturbEscape(label)
|
|
117
|
+
|
|
118
|
+
log.info('escape_success', { method: 'sequence_complete', label })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Hazard-class-specific escape selector — routes to the right maneuver
|
|
122
|
+
// based on what stuck the bot.
|
|
123
|
+
async function runHazardEscape(stuckClass, perception = null, label = 'hazard_escape') {
|
|
124
|
+
log.info('escape_attempt', { method: 'hazard_specific', stuckClass, label })
|
|
125
|
+
|
|
126
|
+
// Treat an invalid scan (NaN position window) the same as no scan — don't derive
|
|
127
|
+
// a yaw from the default-North junk vector in the escape vector field.
|
|
128
|
+
const safePerception = perception?.valid !== false ? perception : null
|
|
129
|
+
|
|
130
|
+
switch (stuckClass) {
|
|
131
|
+
case 'lava_immobilization':
|
|
132
|
+
case 'lava_proximity': {
|
|
133
|
+
// Lava: jump + sprint away from lava, use escape vector if known
|
|
134
|
+
const ev = safePerception?.escapeVector
|
|
135
|
+
if (ev) {
|
|
136
|
+
await tryDirectedEscape(
|
|
137
|
+
(bot.entity?.position?.x || 0) - ev.dx * 5,
|
|
138
|
+
(bot.entity?.position?.z || 0) - ev.dz * 5,
|
|
139
|
+
label
|
|
140
|
+
)
|
|
141
|
+
} else {
|
|
142
|
+
await tryPerturbEscape(label)
|
|
143
|
+
}
|
|
144
|
+
// Follow with a climb attempt in case we're on lava's edge
|
|
145
|
+
await tryClimbEscape(label)
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'collision_deadlock':
|
|
150
|
+
// Trapped: try all 4 directions
|
|
151
|
+
await runEscapeSequence(label, safePerception)
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
case 'terrain_deadlock':
|
|
155
|
+
// High risk terrain: jump + perturb
|
|
156
|
+
await tryJumpEscape(label)
|
|
157
|
+
await new Promise(r => setTimeout(r, 200))
|
|
158
|
+
await tryPerturbEscape(label)
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
case 'liquid_drag':
|
|
162
|
+
// Water: jump repeatedly to surface
|
|
163
|
+
await tryClimbEscape(label)
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
case 'suffocation_hazard':
|
|
167
|
+
// Something above head: back up + jump
|
|
168
|
+
await tryReverseEscape(label)
|
|
169
|
+
await new Promise(r => setTimeout(r, 200))
|
|
170
|
+
await tryJumpEscape(label)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
default:
|
|
174
|
+
await runEscapeSequence(label, safePerception)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log.info('escape_success', { method: 'hazard_escape_done', stuckClass, label })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
tryJumpEscape,
|
|
182
|
+
tryStrafeEscape,
|
|
183
|
+
tryReverseEscape,
|
|
184
|
+
tryPerturbEscape,
|
|
185
|
+
tryClimbEscape,
|
|
186
|
+
tryDirectedEscape,
|
|
187
|
+
runEscapeSequence,
|
|
188
|
+
runHazardEscape,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// logger.js — Structured JSONL event logger
|
|
2
|
+
//
|
|
3
|
+
// Writes one JSON object per line to events.jsonl alongside bot.js.
|
|
4
|
+
// Each event has: ts (ISO), level, event, ...data.
|
|
5
|
+
//
|
|
6
|
+
// Levels: trace, debug, info, warn, error, fatal
|
|
7
|
+
// Designed for postmortem reconstruction — the TUI dashboard reads from this file.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
|
|
12
|
+
const EVENTS_FILE = path.join(__dirname, 'events.jsonl')
|
|
13
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024 // 5MB rotation threshold
|
|
14
|
+
|
|
15
|
+
const botName = process.env.BOT_NAME || 'bot'
|
|
16
|
+
let stream = null
|
|
17
|
+
|
|
18
|
+
function openStream() {
|
|
19
|
+
// Rotate if file > MAX_FILE_BYTES
|
|
20
|
+
try {
|
|
21
|
+
const st = fs.statSync(EVENTS_FILE)
|
|
22
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
23
|
+
fs.renameSync(EVENTS_FILE, EVENTS_FILE + '.1')
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
stream = fs.createWriteStream(EVENTS_FILE, { flags: 'a' })
|
|
27
|
+
stream.on('error', err => console.error('[logger] stream error:', err.message))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function write(level, event, data = {}) {
|
|
31
|
+
if (!stream) openStream()
|
|
32
|
+
const record = {
|
|
33
|
+
ts: new Date().toISOString(),
|
|
34
|
+
bot: botName,
|
|
35
|
+
level,
|
|
36
|
+
event,
|
|
37
|
+
...data,
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
stream.write(JSON.stringify(record) + '\n')
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Last-resort fallback to stderr
|
|
43
|
+
console.error('[logger] write error:', e.message, JSON.stringify(record))
|
|
44
|
+
}
|
|
45
|
+
// Mirror to console for live log tailing
|
|
46
|
+
const tail = Object.keys(data).length > 0 ? ' ' + JSON.stringify(data) : ''
|
|
47
|
+
if (level === 'error' || level === 'fatal') {
|
|
48
|
+
console.error(`[${level}] ${event}${tail}`)
|
|
49
|
+
} else if (level === 'warn') {
|
|
50
|
+
console.warn(`[${level}] ${event}${tail}`)
|
|
51
|
+
} else {
|
|
52
|
+
console.log(`[${level}] ${event}${tail}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const trace = (event, data) => write('trace', event, data)
|
|
57
|
+
const debug = (event, data) => write('debug', event, data)
|
|
58
|
+
const info = (event, data) => write('info', event, data)
|
|
59
|
+
const warn = (event, data) => write('warn', event, data)
|
|
60
|
+
const error = (event, data) => write('error', event, data)
|
|
61
|
+
const fatal = (event, data) => write('fatal', event, data)
|
|
62
|
+
|
|
63
|
+
module.exports = { trace, debug, info, warn, error, fatal, EVENTS_FILE }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
const MEMORY_FILE = path.join(__dirname, 'memory.json')
|
|
5
|
+
|
|
6
|
+
const EMPTY = {
|
|
7
|
+
locations: [], // { name, pos: {x,y,z}, timestamp }
|
|
8
|
+
events: [], // { type, data..., timestamp }
|
|
9
|
+
knowledge: {}, // key-value facts
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadMemory() {
|
|
13
|
+
if (!fs.existsSync(MEMORY_FILE)) return JSON.parse(JSON.stringify(EMPTY))
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(MEMORY_FILE, 'utf8'))
|
|
16
|
+
} catch {
|
|
17
|
+
return JSON.parse(JSON.stringify(EMPTY))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveMemory(memory) {
|
|
22
|
+
fs.writeFileSync(MEMORY_FILE, JSON.stringify(memory, null, 2))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rememberLocation(memory, name, pos) {
|
|
26
|
+
const entry = {
|
|
27
|
+
name,
|
|
28
|
+
pos: { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) },
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
}
|
|
31
|
+
const idx = memory.locations.findIndex((l) => l.name === name)
|
|
32
|
+
if (idx >= 0) memory.locations[idx] = entry
|
|
33
|
+
else memory.locations.push(entry)
|
|
34
|
+
saveMemory(memory)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rememberEvent(memory, type, data) {
|
|
38
|
+
memory.events.push({ type, ...data, timestamp: new Date().toISOString() })
|
|
39
|
+
if (memory.events.length > 50) memory.events = memory.events.slice(-50)
|
|
40
|
+
saveMemory(memory)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rememberKnowledge(memory, key, value) {
|
|
44
|
+
memory.knowledge[key] = value
|
|
45
|
+
saveMemory(memory)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recallLocation(memory, name) {
|
|
49
|
+
return memory.locations.find((l) => l.name === name) || null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
loadMemory,
|
|
54
|
+
saveMemory,
|
|
55
|
+
rememberLocation,
|
|
56
|
+
rememberEvent,
|
|
57
|
+
rememberKnowledge,
|
|
58
|
+
recallLocation,
|
|
59
|
+
}
|