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,402 @@
1
+ // damagePipeline.js — Decoupled capture / classify / react for incoming damage.
2
+ //
3
+ // Extracted verbatim from bot.js (behaviour-preserving). Architecture:
4
+ //
5
+ // raw entityHurt event ──► captureDamageEvent() ──► damageWindow[]
6
+ // │
7
+ // every 500ms, processDamageWindow():
8
+ // │
9
+ // trim window to last 1.5s
10
+ // enter REACTING / HURT / SAFE
11
+ // classifyIncident(window)
12
+ // if reactionCooldown elapsed
13
+ // (or a critical-HP flag is set):
14
+ // react ONCE per incident
15
+ //
16
+ // The reaction cooldown (3s) makes the cactus-loop / lava-loop impossible: we react
17
+ // once, take 3s to step out, then re-evaluate. The critical-HP flag (set by bot's
18
+ // 'health' handler) bypasses the cooldown for one incident so a sharp HP drop or
19
+ // critical floor gets an immediate real escape — this is the single damage-reaction
20
+ // authority (Fix 4), so the 'health' handler stages instead of acting directly.
21
+ //
22
+ // The factory registers the 'entityHurt' and 'health' listeners and the 500ms
23
+ // classifier interval itself. bot.js keeps 'death' / 'respawn' / 'forcedMove' (mixed
24
+ // concerns) and calls flushDamageState() from respawn/forcedMove.
25
+
26
+ 'use strict'
27
+
28
+ const DAMAGE_WINDOW_MS = 1500 // damage events within this window are one incident
29
+ const REACTION_COOLDOWN_MS = 3000 // never react more often than this
30
+ const HAZARD_MEMORY_MS = 60000 // hazard zones remembered for 1 min
31
+ const HIT_CHAT_COOLDOWN_MS = 4000
32
+ const COUNTER_PUNCH_COOLDOWN_MS = 1500
33
+
34
+ module.exports = function createDamagePipeline(deps) {
35
+ const {
36
+ bot, log, state, liveness,
37
+ getEnvPerception, // () => envPerception (created shortly after this)
38
+ getLastEnvScan, // () => lastEnvScan (mutable in bot.js)
39
+ isTaskBusy, // () => taskBusy (mutable in bot.js)
40
+ replaceTask,
41
+ safeChat, safeFloor, isValidVec,
42
+ getPlayer,
43
+ bumpAnger, ANGER_HIT, ANGER_ATTACK_LEVEL,
44
+ HAZARD_BLOCKS, HOSTILE_MOB_NAMES, BOT_NAME,
45
+ tasks, // { taskAttackPlayer, taskAttackMobs, taskFlee, taskEvadeHazard }
46
+ } = deps
47
+
48
+ const { taskAttackPlayer, taskAttackMobs, taskFlee, taskEvadeHazard } = tasks
49
+
50
+ // ── State ────────────────────────────────────────────────────────────────────
51
+ let lastHitChatAt = 0
52
+ let lastCounterPunchAt = 0
53
+ let currentPlayerAttackTarget = null // username we're currently in goal=attacking against
54
+
55
+ const damageWindow = [] // recent raw damage event captures
56
+ const hazardZones = [] // { x, y, z, type, ts } — places we took env damage
57
+ let damageState = 'safe' // safe | hurt | reacting
58
+ let lastReactionAt = 0
59
+ let damageCorrelationCounter = 0
60
+ // Single damage-reaction authority (Fix 4): 'health' handler sets this flag instead
61
+ // of acting directly. processDamageWindow reads it to bypass the cooldown and react.
62
+ let criticalHpFlag = null // { at: number, hp: number } | null
63
+ let prevHp = null // last seen HP for sharp-drop detection
64
+
65
+ // ── Hazard scan ──────────────────────────────────────────────────────────────
66
+
67
+ // Scan a 7×4×7 box around the bot for hazardous blocks. Returns the closest or null.
68
+ function findNearbyHazard(radius = 3) {
69
+ if (!bot.entity) return null
70
+ const pos = bot.entity.position
71
+ let nearest = null, minDist = Infinity
72
+ for (let dx = -radius; dx <= radius; dx++) {
73
+ for (let dy = -1; dy <= 2; dy++) { // feet, body, head
74
+ for (let dz = -radius; dz <= radius; dz++) {
75
+ const block = bot.blockAt(pos.offset(dx, dy, dz))
76
+ if (!block || !HAZARD_BLOCKS.has(block.name)) continue
77
+ const d = block.position.distanceTo(pos)
78
+ if (d < minDist) { minDist = d; nearest = { block, dist: d, name: block.name } }
79
+ }
80
+ }
81
+ }
82
+ return nearest
83
+ }
84
+
85
+ // ── Context-capture helpers ──────────────────────────────────────────────────
86
+
87
+ function getNearestNonBotPlayerWithin(maxDist) {
88
+ if (!bot.entity) return null
89
+ let nearest = null, minDist = maxDist
90
+ for (const name in bot.players) {
91
+ if (name === BOT_NAME) continue
92
+ const p = bot.players[name]
93
+ if (!p?.entity) continue
94
+ const d = p.entity.position.distanceTo(bot.entity.position)
95
+ if (d < minDist) { minDist = d; nearest = { name, entity: p.entity, dist: d } }
96
+ }
97
+ return nearest
98
+ }
99
+
100
+ function getNearestHostileMobWithin(maxDist) {
101
+ if (!bot.entity) return null
102
+ return bot.nearestEntity(e =>
103
+ e !== bot.entity && e.name && HOSTILE_MOB_NAMES.has(e.name) &&
104
+ e.position.distanceTo(bot.entity.position) < maxDist
105
+ )
106
+ }
107
+
108
+ function captureDamageEvent() {
109
+ const rawPos = bot.entity?.position
110
+ let usingCached = false
111
+ let pos = rawPos
112
+
113
+ if (!isValidVec(rawPos)) {
114
+ // Degraded mode: instead of silently dropping the event, use the best available
115
+ // position with a quality tag. 'unknown' source ⇒ blind reaction path.
116
+ const best = liveness.getBestPosition()
117
+ if (!best) {
118
+ log.warn('damage_capture_skipped', {
119
+ reason: 'no_position_available',
120
+ livenessState: liveness.getState(),
121
+ invalidMs: liveness.getInvalidMs(),
122
+ })
123
+ return null
124
+ }
125
+ pos = best.pos
126
+ usingCached = best.source !== 'live'
127
+ if (best.source === 'unknown') {
128
+ usingCached = true
129
+ pos = best.pos || { x: 0, y: 0, z: 0 }
130
+ log.debug('damage_capture_blind', { livenessState: liveness.getState(), cacheAgeMs: liveness.getCachedAge() })
131
+ }
132
+ }
133
+
134
+ const event = {
135
+ at: Date.now(),
136
+ hp: Number(bot.health.toFixed(1)),
137
+ hurtTime: bot.entity?.hurtTime ?? null,
138
+ pos: safeFloor(pos),
139
+ posSource: usingCached ? 'cached' : 'live',
140
+ velocity: isValidVec(bot.entity?.velocity) ? {
141
+ x: Number(bot.entity.velocity.x.toFixed(2)),
142
+ y: Number(bot.entity.velocity.y.toFixed(2)),
143
+ z: Number(bot.entity.velocity.z.toFixed(2)),
144
+ } : null,
145
+ nearestPlayer: getNearestNonBotPlayerWithin(5),
146
+ nearestHostileMob: getNearestHostileMobWithin(6),
147
+ hazard: findNearbyHazard(2),
148
+ inWater: !!bot.entity?.isInWater,
149
+ inLava: !!bot.entity?.isInLava,
150
+ }
151
+ if (usingCached) log.debug('damage_capture_using_cached_pos', { cachedAgeMs: liveness.getCachedAge(), livenessState: liveness.getState() })
152
+ return event
153
+ }
154
+
155
+ // ── Periodic classifier ──────────────────────────────────────────────────────
156
+
157
+ function processDamageWindow() {
158
+ // Trim window to last DAMAGE_WINDOW_MS
159
+ const cutoff = Date.now() - DAMAGE_WINDOW_MS
160
+ while (damageWindow.length > 0 && damageWindow[0].at < cutoff) damageWindow.shift()
161
+
162
+ // Trim hazard memory
163
+ const hzCutoff = Date.now() - HAZARD_MEMORY_MS
164
+ while (hazardZones.length > 0 && hazardZones[0].ts < hzCutoff) hazardZones.shift()
165
+
166
+ if (damageWindow.length === 0) {
167
+ if (damageState !== 'safe') {
168
+ log.info('damage_state_change', { from: damageState, to: 'safe' })
169
+ damageState = 'safe'
170
+ }
171
+ return
172
+ }
173
+
174
+ if (damageState === 'safe') {
175
+ log.info('damage_state_change', { from: 'safe', to: 'hurt' })
176
+ damageState = 'hurt'
177
+ }
178
+
179
+ // Reaction cooldown: don't act on every tick.
180
+ // Priority bypass: if the health handler flagged a critical HP event that is newer
181
+ // than our last reaction, skip the cooldown for this one incident only.
182
+ const sinceReaction = Date.now() - lastReactionAt
183
+ if (sinceReaction < REACTION_COOLDOWN_MS) {
184
+ if (!criticalHpFlag || criticalHpFlag.at <= lastReactionAt) return
185
+ log.warn('critical_hp_bypass', { hp: criticalHpFlag.hp, sinceReaction })
186
+ criticalHpFlag = null
187
+ }
188
+
189
+ // Classify and react
190
+ const incident = classifyIncident(damageWindow)
191
+ const incidentCid = ++damageCorrelationCounter
192
+ log.info('damage_incident', {
193
+ type: incident.type,
194
+ hits: damageWindow.length,
195
+ hp: bot.health,
196
+ detail: incident.summary,
197
+ cid: incidentCid,
198
+ })
199
+ lastReactionAt = Date.now()
200
+ damageState = 'reacting'
201
+ reactToIncident(incident)
202
+ }
203
+
204
+ function classifyIncident(events) {
205
+ // Check player consistency: same player nearby in all events
206
+ const playerNames = events.map(e => e.nearestPlayer?.name).filter(Boolean)
207
+ if (playerNames.length === events.length && new Set(playerNames).size === 1) {
208
+ const attacker = events[events.length - 1].nearestPlayer
209
+ return { type: 'player', attacker, summary: { player: attacker.name } }
210
+ }
211
+
212
+ // Mob consistency
213
+ const mobNames = events.map(e => e.nearestHostileMob?.name).filter(Boolean)
214
+ if (mobNames.length === events.length && new Set(mobNames).size === 1) {
215
+ const mob = events[events.length - 1].nearestHostileMob
216
+ return { type: 'mob', attacker: mob, summary: { mob: mob.name } }
217
+ }
218
+
219
+ // Lava / fire detection from entity flags
220
+ if (events.some(e => e.inLava)) {
221
+ return { type: 'environmental', hazard: { name: 'lava', block: bot.entity }, summary: { hazard: 'lava' } }
222
+ }
223
+
224
+ // Hazard block detection
225
+ const hazards = events.map(e => e.hazard).filter(Boolean)
226
+ if (hazards.length > 0) {
227
+ const hz = hazards[hazards.length - 1]
228
+ return { type: 'environmental', hazard: hz, summary: { hazard: hz.name } }
229
+ }
230
+
231
+ return { type: 'unknown', summary: {} }
232
+ }
233
+
234
+ function reactToIncident(incident) {
235
+ switch (incident.type) {
236
+ case 'player': return reactToPlayerAttack(incident.attacker)
237
+ case 'mob': return reactToMobAttack(incident.attacker)
238
+ case 'environmental': return reactToEnvironmental(incident.hazard)
239
+ case 'unknown': return reactToUnknownDamage()
240
+ }
241
+ }
242
+
243
+ function reactToPlayerAttack(attacker) {
244
+ const rec = bumpAnger(attacker.name, ANGER_HIT, 'attacked me')
245
+ const now = Date.now()
246
+ if (rec.level >= ANGER_ATTACK_LEVEL) {
247
+ // Symmetric guard matching reactToMobAttack: don't restart the attack task on every hit.
248
+ if (isTaskBusy() && state.goal === 'attacking') {
249
+ if (currentPlayerAttackTarget === attacker.name) return // already engaged with this player
250
+
251
+ // Different player is hitting us mid-attack. Switch only if the new attacker is
252
+ // closer than our current target (they're the more immediate threat).
253
+ const botPos = bot.entity?.position
254
+ if (botPos) {
255
+ const currentTarget = getPlayer(currentPlayerAttackTarget)
256
+ const newDist = attacker.entity?.position?.distanceTo(botPos) ?? Infinity
257
+ const curDist = currentTarget?.entity?.position?.distanceTo(botPos) ?? Infinity
258
+ if (newDist >= curDist) {
259
+ log.debug('player_attack_suppressed', {
260
+ incoming: attacker.name, current: currentPlayerAttackTarget,
261
+ newDist: Math.round(newDist), curDist: Math.round(curDist),
262
+ })
263
+ return // stay on the closer current target
264
+ }
265
+ } else {
266
+ return // no position data, don't switch
267
+ }
268
+ }
269
+
270
+ if (now - lastHitChatAt >= HIT_CHAT_COOLDOWN_MS) {
271
+ safeChat(`That's it, ${attacker.name}.`)
272
+ lastHitChatAt = now
273
+ }
274
+ currentPlayerAttackTarget = attacker.name
275
+ replaceTask('attacking', () => taskAttackPlayer(attacker.entity, attacker.name))
276
+ } else {
277
+ if (now - lastHitChatAt >= HIT_CHAT_COOLDOWN_MS) {
278
+ safeChat(`Stop hitting me, ${attacker.name}!`)
279
+ lastHitChatAt = now
280
+ }
281
+ if (now - lastCounterPunchAt >= COUNTER_PUNCH_COOLDOWN_MS) {
282
+ try { bot.attack(attacker.entity) } catch (e) {
283
+ log.warn('counter_attack_failed', { message: e.message })
284
+ }
285
+ lastCounterPunchAt = now
286
+ }
287
+ }
288
+ }
289
+
290
+ function reactToMobAttack(mob) {
291
+ if (isTaskBusy() && state.goal === 'attacking') return // already engaged
292
+ if (state.energy < 30) {
293
+ safeChat(`A ${mob.name}! Backing off.`)
294
+ log.info('mob_hit_decision', { mob: mob.name, choice: 'flee', energy: state.energy })
295
+ replaceTask('fleeing', taskFlee)
296
+ } else {
297
+ safeChat(`A ${mob.name}. Fighting back.`)
298
+ log.info('mob_hit_decision', { mob: mob.name, choice: 'fight', energy: state.energy })
299
+ replaceTask('attacking', () => taskAttackMobs())
300
+ }
301
+ }
302
+
303
+ function reactToEnvironmental(hazard) {
304
+ rememberHazard(hazard)
305
+ // Also record in the environmental perception hazard memory for path avoidance
306
+ const envPerception = getEnvPerception()
307
+ if (hazard?.block?.position && envPerception) {
308
+ const hp = hazard.block.position
309
+ envPerception.recordHazardPosition(hp.x, hp.y, hp.z, 'damage')
310
+ }
311
+ log.info('hazard_detected', {
312
+ name: hazard?.name || 'unknown',
313
+ knownZones: hazardZones.length,
314
+ envRisk: getLastEnvScan()?.locomotionRisk ?? null,
315
+ })
316
+ replaceTask('evading', () => taskEvadeHazard(hazard))
317
+ }
318
+
319
+ function reactToUnknownDamage() {
320
+ // No identifiable source. If HP is dropping fast, retreat.
321
+ if (bot.health < 12) {
322
+ log.warn('unknown_damage_critical_retreat', { hp: bot.health })
323
+ replaceTask('evading', () => taskEvadeHazard(null))
324
+ } else {
325
+ log.warn('unknown_damage_ignored_hp_ok', { hp: bot.health })
326
+ }
327
+ }
328
+
329
+ function rememberHazard(hazard) {
330
+ if (!hazard?.block?.position) return
331
+ const p = hazard.block.position
332
+ hazardZones.push({
333
+ x: Math.floor(p.x), y: Math.floor(p.y), z: Math.floor(p.z),
334
+ type: hazard.name,
335
+ ts: Date.now(),
336
+ })
337
+ while (hazardZones.length > 50) hazardZones.shift()
338
+ }
339
+
340
+ // ── Event handlers (registered below) ────────────────────────────────────────
341
+
342
+ function onEntityHurt(entity) {
343
+ if (entity !== bot.entity) return
344
+ const evt = captureDamageEvent()
345
+ if (!evt) return
346
+ damageWindow.push(evt)
347
+ // trace level — high-frequency; not useful as user-visible info
348
+ log.trace('damage_raw', {
349
+ hp: evt.hp,
350
+ player: evt.nearestPlayer?.name || null,
351
+ mob: evt.nearestHostileMob?.name || null,
352
+ hazard: evt.hazard?.name || null,
353
+ inLava: evt.inLava,
354
+ })
355
+ }
356
+
357
+ function onHealthUpdate() {
358
+ const hp = bot.health
359
+ const prev = prevHp ?? hp
360
+ prevHp = hp
361
+
362
+ const sharpDrop = (prev - hp) >= 2
363
+ const critical = hp <= 4
364
+
365
+ // Stage a high-priority flag for processDamageWindow instead of acting directly.
366
+ // Direct action here would race processDamageWindow's escape reaction, cancelling
367
+ // it on every lava tick and causing goal oscillation.
368
+ if ((sharpDrop || critical) && (!criticalHpFlag || criticalHpFlag.at < Date.now() - 500)) {
369
+ criticalHpFlag = { at: Date.now(), hp }
370
+ log.warn('critical_hp_queued', { hp, prev, sharpDrop, critical })
371
+ }
372
+ }
373
+
374
+ // Discard damage events that refer to a stale world (post-respawn / post-teleport).
375
+ function flushDamageState() {
376
+ damageWindow.length = 0
377
+ damageState = 'safe'
378
+ }
379
+
380
+ // ── Wire up ──────────────────────────────────────────────────────────────────
381
+ bot.on('entityHurt', onEntityHurt)
382
+ bot.on('health', onHealthUpdate)
383
+ const interval = setInterval(processDamageWindow, 500)
384
+
385
+ return {
386
+ flushDamageState,
387
+ // accessors for freeze-snapshot / health-beacon logging in bot.js
388
+ getDamageState: () => damageState,
389
+ getDamageWindowSize: () => damageWindow.length,
390
+ getLastReactionAt: () => lastReactionAt,
391
+ getHazardZonesCount: () => hazardZones.length,
392
+ getCooldowns: () => ({
393
+ hitChat: Math.max(0, HIT_CHAT_COOLDOWN_MS - (Date.now() - lastHitChatAt)),
394
+ counterPunch: Math.max(0, COUNTER_PUNCH_COOLDOWN_MS - (Date.now() - lastCounterPunchAt)),
395
+ reaction: Math.max(0, REACTION_COOLDOWN_MS - (Date.now() - lastReactionAt)),
396
+ }),
397
+ REACTION_COOLDOWN_MS,
398
+ _interval: interval,
399
+ }
400
+ }
401
+
402
+ module.exports.REACTION_COOLDOWN_MS = REACTION_COOLDOWN_MS
@@ -0,0 +1,212 @@
1
+ // engine.js — Decision Engine
2
+
3
+ const VALID_ACTIONS = [
4
+ 'follow','stop','explore','gather_wood','craft_planks','go_to','remember_here',
5
+ 'attack_mobs','attack_player','collect_items','craft','place_block',
6
+ 'mine_block','eat_food','flee','escape','build_house_smart','none',
7
+ ]
8
+ const VALID_DECISIONS = ['accept','reject','delay']
9
+
10
+ // Curse words trigger anger toward the speaker.
11
+ // Matched as whole words (case-insensitive).
12
+ const CURSE_WORDS = [
13
+ 'fuck','shit','asshole','bitch','dumbass','idiot','moron','cunt','bastard',
14
+ 'damn','retard','stupid','fag','dick','prick','whore','slut','crap',
15
+ ]
16
+ const CURSE_RE = new RegExp(`\\b(${CURSE_WORDS.join('|')})\\b`, 'i')
17
+
18
+ function detectInsult(message) {
19
+ return CURSE_RE.test(message)
20
+ }
21
+
22
+ // ── Pre-LLM intent classifier ─────────────────────────────────────────────────
23
+
24
+ const INTENT_PATTERNS = [
25
+ { intent: 'follow', re: /\b(follow|come here|come to me|come with me)\b/i },
26
+ { intent: 'stop', re: /\b(stop|wait|stay|halt|cease)\b/i },
27
+ { intent: 'attack', re: /\b(attack|kill|fight|hunt|slay|defeat)\b/i },
28
+ { intent: 'flee', re: /\b(flee|run away|retreat|fall back)\b/i },
29
+ { intent: 'escape', re: /\b(escape|climb out|get out|dig out|stuck|get unstuck)\b/i },
30
+ { intent: 'eat', re: /\b(eat|food|hungry|feed yourself)\b/i },
31
+ { intent: 'collect', re: /\b(pick up|collect|loot|grab|get the items)\b/i },
32
+ { intent: 'build', re: /\b(build|construct|house|shelter|home|make a house|make a shelter)\b/i },
33
+ { intent: 'craft', re: /\b(craft|forge|make a|create a|i need a|make planks|craft planks)\b/i },
34
+ { intent: 'place', re: /\b(place|put down|drop the|set down)\b/i },
35
+ { intent: 'mine', re: /\b(mine|dig|break the)\b/i },
36
+ { intent: 'gather', re: /\b(wood|tree|chop|gather|need some|get some|sand|stone|gravel|ore)\b/i },
37
+ { intent: 'explore', re: /\b(explore|wander|walk|roam|go to)\b/i },
38
+ { intent: 'query', re: /\b(where|what|who|how|status|see|have|inventory|can you|are you|bro)\b/i },
39
+ ]
40
+
41
+ function classifyIntent(message) {
42
+ for (const { intent, re } of INTENT_PATTERNS) {
43
+ if (re.test(message)) return intent
44
+ }
45
+ return 'unknown'
46
+ }
47
+
48
+ // ── Pre-LLM survival check ────────────────────────────────────────────────────
49
+
50
+ function evaluateSurvival(state) {
51
+ if (state.energy <= 15) {
52
+ return {
53
+ decision: 'delay',
54
+ reason: 'energy critical',
55
+ action: 'none',
56
+ say: `Exhausted. Resting. Energy: ${Math.floor(state.energy)}/100.`,
57
+ }
58
+ }
59
+ if (state.goal === 'resting') {
60
+ return {
61
+ decision: 'delay',
62
+ reason: 'recovering',
63
+ action: 'none',
64
+ say: `Resting. Energy: ${Math.floor(state.energy)}/100.`,
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ // ── Validate LLM output strictly ──────────────────────────────────────────────
71
+
72
+ function validateLLMOutput(raw) {
73
+ if (!raw || typeof raw !== 'object') return null
74
+ if (!VALID_DECISIONS.includes(raw.decision)) return null
75
+ if (!VALID_ACTIONS.includes(raw.action)) return null
76
+ if (typeof raw.say !== 'string' || !raw.say.trim()) return null
77
+
78
+ return {
79
+ decision: raw.decision,
80
+ reason: typeof raw.reason === 'string' ? raw.reason.slice(0, 200) : '',
81
+ action: raw.action,
82
+ say: raw.say.trim().slice(0, 150),
83
+ target: typeof raw.target === 'string' ? raw.target : undefined,
84
+ }
85
+ }
86
+
87
+ // ── Safe defaults when LLM fails ─────────────────────────────────────────────
88
+
89
+ const SAFE_DEFAULTS = {
90
+ follow: { decision: 'accept', reason: 'fallback', action: 'follow', say: 'On my way.' },
91
+ stop: { decision: 'accept', reason: 'fallback', action: 'stop', say: 'Stopped.' },
92
+ attack: { decision: 'accept', reason: 'fallback', action: 'attack_mobs', say: 'On it.' },
93
+ flee: { decision: 'accept', reason: 'fallback', action: 'flee', say: 'Pulling back.' },
94
+ escape: { decision: 'accept', reason: 'fallback', action: 'escape', say: 'Climbing out.' },
95
+ eat: { decision: 'accept', reason: 'fallback', action: 'eat_food', say: 'Eating.' },
96
+ collect: { decision: 'accept', reason: 'fallback', action: 'collect_items', say: 'Picking up.' },
97
+ craft: { decision: 'accept', reason: 'fallback', action: 'craft', say: 'Let me try.' },
98
+ place: { decision: 'accept', reason: 'fallback', action: 'place_block', say: 'Placing it.' },
99
+ mine: { decision: 'accept', reason: 'fallback', action: 'mine_block', say: 'Digging in.' },
100
+ build: { decision: 'accept', reason: 'fallback', action: 'build_house_smart', say: 'Starting build.' },
101
+ gather: { decision: 'accept', reason: 'fallback', action: 'gather_wood', say: 'Getting wood.' },
102
+ explore: { decision: 'accept', reason: 'fallback', action: 'explore', say: 'Going exploring.' },
103
+ query: { decision: 'accept', reason: 'fallback', action: 'none', say: '...' },
104
+ unknown: { decision: 'accept', reason: 'fallback', action: 'none', say: '...' },
105
+ }
106
+
107
+ function safeDefault(intent) {
108
+ return { ...(SAFE_DEFAULTS[intent] || SAFE_DEFAULTS.unknown) }
109
+ }
110
+
111
+ // ── Autonomous goal selection — priority-driven survival planner ────────────
112
+ //
113
+ // Order is strict (highest priority first):
114
+ // 1. THREATS — hostile mob nearby → attack
115
+ // 2. RESTING — energy critical → null (let state-loop rest)
116
+ // 3. SCAVENGE — items on ground (free resources) → collect
117
+ // 4. CRAFT — have logs, no planks yet → craft_planks
118
+ // 5. CRAFT_TOOL — have planks but no pickaxe → craft wooden_pickaxe
119
+ // 6. WOOD — have <8 planks and trees nearby → gather_wood
120
+ // 7. EXPLORE — nothing else useful → explore
121
+ //
122
+ // Each branch returns { action, say, target? }. Returning null = no autonomous action.
123
+
124
+ function countInv(inventory, predicate) {
125
+ return inventory.filter(predicate).reduce((s, i) => {
126
+ const m = i.match(/x(\d+)$/); return s + (m ? parseInt(m[1]) : 1)
127
+ }, 0)
128
+ }
129
+
130
+ function selectAutonomousGoal(groundedState) {
131
+ const inv = groundedState.inventory
132
+ const hasEnemies = groundedState.hostileMobs.length > 0
133
+ const hasDrops = groundedState.droppedCount > 0
134
+ const hasLog = groundedState.nearbyBlocks.some(b => b.type.endsWith('_log'))
135
+ const logsInInv = inv.some(i => i.includes('_log'))
136
+ const planksCount = countInv(inv, i => i.includes('_planks'))
137
+ const hasSticks = inv.some(i => i === 'stick' || i.startsWith('stickx'))
138
+ const hasPickaxe = inv.some(i => i.includes('pickaxe'))
139
+ const hasSword = inv.some(i => i.includes('sword'))
140
+ const hasTable = inv.some(i => i === 'crafting_table' || i.startsWith('crafting_table'))
141
+ const hasFood = inv.some(i => /^(cooked_|bread|apple|carrot|baked_potato|melon|berries|cookie|pumpkin_pie)/.test(i.split('x')[0]))
142
+ const food = groundedState.self.food ?? 20
143
+ const lowEnergy = groundedState.self.energy < 40
144
+ const criticalEnergy = groundedState.self.energy < 20
145
+
146
+ // 0. EMERGENCY — flee if critical HP and threats nearby
147
+ if (criticalEnergy && hasEnemies) {
148
+ return { action: 'flee', say: pick(['Falling back!', 'Too risky.']) }
149
+ }
150
+
151
+ // 1. EAT — prevent hunger from blocking regen (Minecraft regens HP only when food >= 18)
152
+ if (food < 16 && hasFood) {
153
+ return { action: 'eat_food', say: pick(['Need food.', 'Eating up.']) }
154
+ }
155
+
156
+ // 2. THREAT — fight hostile mobs unless near death
157
+ if (hasEnemies && !criticalEnergy) {
158
+ return { action: 'attack_mobs', say: pick(['Hostile target.', 'Engaging.', 'Threat in range.']) }
159
+ }
160
+
161
+ // 3. RESTING — too tired for productive work
162
+ if (criticalEnergy) return null
163
+
164
+ // 3. SCAVENGE — free resources nearby (always worth picking up)
165
+ if (hasDrops) {
166
+ return { action: 'collect_items', say: pick(['Picking up drops.', 'Free loot.']) }
167
+ }
168
+
169
+ // 4. CRAFT planks — convert logs in inventory to planks (unblocks other crafts)
170
+ if (logsInInv && planksCount < 16) {
171
+ return { action: 'craft_planks', say: pick(['Making planks.', 'Processing logs.']) }
172
+ }
173
+
174
+ // 5. CRAFT crafting_table — needed before tools
175
+ if (planksCount >= 4 && !hasTable) {
176
+ return { action: 'craft', target: 'crafting_table', say: 'Need a crafting table.' }
177
+ }
178
+
179
+ // 6a. CRAFT sticks (prerequisite for all wooden tools)
180
+ if (planksCount >= 4 && !hasSticks && (!hasPickaxe || !hasSword)) {
181
+ return { action: 'craft', target: 'stick', say: 'Need sticks.' }
182
+ }
183
+
184
+ // 6b. CRAFT pickaxe (most important survival tool)
185
+ if (planksCount >= 3 && hasSticks && !hasPickaxe && hasTable) {
186
+ return { action: 'craft', target: 'wooden_pickaxe', say: 'Making a pickaxe.' }
187
+ }
188
+
189
+ // 7. CRAFT sword (defense)
190
+ if (planksCount >= 2 && hasSticks && !hasSword && hasTable && hasPickaxe) {
191
+ return { action: 'craft', target: 'wooden_sword', say: 'Making a sword.' }
192
+ }
193
+
194
+ // 8. GATHER wood — keep stockpile up if trees are around
195
+ if (hasLog && planksCount < 32 && !lowEnergy) {
196
+ return { action: 'gather_wood', say: pick(['Need wood.', 'Heading for that tree.']) }
197
+ }
198
+
199
+ // 9. EXPLORE — default behavior, find new resources
200
+ if (!lowEnergy) {
201
+ return { action: 'explore', say: pick(['Walking around.', 'Looking around.', 'Off to scout.']) }
202
+ }
203
+
204
+ return null // resting, nothing to do
205
+ }
206
+
207
+ function pick(arr) { return arr[Math.floor(Math.random() * arr.length)] }
208
+
209
+ module.exports = {
210
+ classifyIntent, evaluateSurvival, validateLLMOutput, safeDefault, selectAutonomousGoal,
211
+ detectInsult, CURSE_WORDS,
212
+ }