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,939 @@
1
+ // tasks.js — All the `taskXxx` bodies + their helpers, extracted from bot.js.
2
+ //
3
+ // Behaviour-preserving move (Fix 9 Step 2). createTasks(deps) returns the bound
4
+ // task functions; bot.js calls them via `tasks.taskXxx`. The task runner
5
+ // (runTask/replaceTask/cancelCurrentTask), navNear, makeMovements, and the goto
6
+ // lifecycle tracking stay in bot.js — tasks receive navNear as a dependency.
7
+ //
8
+ // Two small non-functional clean-ups vs. the original bodies (forced by the move
9
+ // into a module that has `log` (the logger) in scope):
10
+ // - taskGatherWood / taskBuildHouseSmart: the `findBlock` result was named `log`,
11
+ // shadowing the logger — renamed to `logBlock`.
12
+ // - taskCraftPlanks: the `for (const log of logs)` loop var renamed to `logItem`.
13
+ // Everything else is verbatim.
14
+
15
+ 'use strict'
16
+
17
+ const { HOSTILE_MOB_NAMES } = require('./state')
18
+
19
+ module.exports = function createTasks(deps) {
20
+ const {
21
+ bot, log, state, memory, movement, liveness,
22
+ safeChat,
23
+ safeDig, safeCraft, safeEquip, safeConsume, safePlaceBlock, safeAttack,
24
+ navNear, awaitValidPosition,
25
+ isValidVec, isFiniteNum, safeFloor, safeNormalize2D,
26
+ rememberEvent, rememberLocation, recallLocation,
27
+ anger,
28
+ BOT_NAME,
29
+ getLastEnvScan, getEnvPerception, getLocomotionRecovery,
30
+ } = deps
31
+
32
+ // ── Shared item-name constants / helpers ─────────────────────────────────────
33
+
34
+ const LOG_NAMES = ['oak_log','birch_log','spruce_log','jungle_log','acacia_log','dark_oak_log','mangrove_log','cherry_log']
35
+ const PLANK_NAMES = ['oak_planks','birch_planks','spruce_planks','jungle_planks','acacia_planks','dark_oak_planks','mangrove_planks','cherry_planks']
36
+
37
+ const logIds = () => LOG_NAMES.map(n => bot.registry.blocksByName[n]?.id).filter(Boolean)
38
+ const plankIds = () => PLANK_NAMES.map(n => bot.registry.itemsByName[n]?.id).filter(Boolean)
39
+
40
+ function countInInv(names) {
41
+ return bot.inventory.items()
42
+ .filter(i => names.includes(i.name))
43
+ .reduce((s, i) => s + i.count, 0)
44
+ }
45
+
46
+ async function equipBestWeapon() {
47
+ const weapons = [
48
+ 'netherite_sword','diamond_sword','iron_sword','stone_sword','wooden_sword',
49
+ 'netherite_axe','diamond_axe','iron_axe','stone_axe','wooden_axe',
50
+ ]
51
+ for (const w of weapons) {
52
+ const id = bot.registry.itemsByName[w]?.id
53
+ const item = id ? bot.inventory.findInventoryItem(id, null, false) : null
54
+ if (item) { await safeEquip(bot, item, 'hand'); return w }
55
+ }
56
+ return null
57
+ }
58
+
59
+ async function equipBestTool(blockName) {
60
+ // Map block to preferred tool tier (best first)
61
+ const toolPrefs = {
62
+ stone: ['netherite_pickaxe','diamond_pickaxe','iron_pickaxe','stone_pickaxe','wooden_pickaxe'],
63
+ coal_ore: ['netherite_pickaxe','diamond_pickaxe','iron_pickaxe','stone_pickaxe','wooden_pickaxe'],
64
+ iron_ore: ['netherite_pickaxe','diamond_pickaxe','iron_pickaxe','stone_pickaxe'],
65
+ diamond_ore: ['netherite_pickaxe','diamond_pickaxe','iron_pickaxe'],
66
+ gold_ore: ['netherite_pickaxe','diamond_pickaxe','iron_pickaxe'],
67
+ sand: ['netherite_shovel','diamond_shovel','iron_shovel','stone_shovel','wooden_shovel'],
68
+ gravel: ['netherite_shovel','diamond_shovel','iron_shovel','stone_shovel','wooden_shovel'],
69
+ dirt: ['netherite_shovel','diamond_shovel','iron_shovel','stone_shovel','wooden_shovel'],
70
+ }
71
+ const isLog = blockName.endsWith('_log')
72
+ const prefs = isLog
73
+ ? ['netherite_axe','diamond_axe','iron_axe','stone_axe','wooden_axe']
74
+ : (toolPrefs[blockName] || [])
75
+ for (const tool of prefs) {
76
+ const id = bot.registry.itemsByName[tool]?.id
77
+ const item = id ? bot.inventory.findInventoryItem(id, null, false) : null
78
+ if (item) {
79
+ try { await safeEquip(bot, item, 'hand'); return tool } catch {}
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ // ── Crafting helpers ─────────────────────────────────────────────────────────
86
+
87
+ // Items whose recipes always require sticks (vanilla wooden/stone/iron/gold/diamond/netherite tools)
88
+ const STICK_TOOL_RE = /^(wooden|stone|iron|golden|diamond|netherite)_(pickaxe|sword|axe|shovel|hoe)$/
89
+
90
+ function countItemsByName(name) {
91
+ // Match by name string — more robust across protocol/registry version drift
92
+ return bot.inventory.items()
93
+ .filter(i => i.name === name)
94
+ .reduce((s, i) => s + i.count, 0)
95
+ }
96
+
97
+ function countMaterialFamily(suffix) {
98
+ // e.g., "_planks" → counts all plank types together
99
+ return bot.inventory.items()
100
+ .filter(i => i.name.endsWith(suffix))
101
+ .reduce((s, i) => s + i.count, 0)
102
+ }
103
+
104
+ // Track items that "successfully" crafted but didn't actually consume materials —
105
+ // likely a mineflayer/server protocol mismatch. Don't keep retrying these.
106
+ const phantomCraftBlocklist = new Map() // itemName -> blockedUntilTimestamp
107
+ const PHANTOM_BLOCK_MS = 5 * 60 * 1000 // block re-attempts for 5 minutes
108
+
109
+ function inventoryHas(predicate) {
110
+ return bot.inventory.items().some(predicate)
111
+ }
112
+
113
+ // Auto-craft sticks if needed. Returns true if sticks are now available.
114
+ async function ensureSticks() {
115
+ const stickId = bot.registry.itemsByName['stick']?.id
116
+ if (!stickId) return false
117
+ if (bot.inventory.findInventoryItem(stickId, null, false)) return true
118
+
119
+ // Need at least 2 planks (any kind). If we don't have planks but have logs, craft planks first.
120
+ const totalPlanks = bot.inventory.items().filter(i => i.name.endsWith('_planks')).reduce((s, i) => s + i.count, 0)
121
+ if (totalPlanks < 2) {
122
+ if (bot.inventory.items().some(i => i.name.endsWith('_log'))) {
123
+ log.info('auto_craft_planks_for_sticks')
124
+ await taskCraftPlanks()
125
+ } else {
126
+ log.warn('cannot_make_sticks', { reason: 'no planks or logs' })
127
+ return false
128
+ }
129
+ }
130
+
131
+ // Sticks: 2 planks → 4 sticks. Inventory crafting (no table required).
132
+ const recipes = bot.recipesFor(stickId, null, 1, null)
133
+ if (!recipes.length) {
134
+ log.warn('no_stick_recipe_available', { invPlanks: totalPlanks })
135
+ return false
136
+ }
137
+ try {
138
+ await safeCraft(bot, recipes[0], 1, null)
139
+ log.info('crafted_sticks_auto', { sticksNow: countItemsByName('stick') })
140
+ return true
141
+ } catch (e) {
142
+ log.error('stick_craft_exception', { message: e.message })
143
+ return false
144
+ }
145
+ }
146
+
147
+ // Determine which ingredients are missing for a recipe (best-effort diagnostic).
148
+ function describeRecipe(itemId) {
149
+ // Search the registry for any recipe producing this item
150
+ try {
151
+ const recipes = bot.registry.recipes?.[itemId]
152
+ if (!recipes || !recipes.length) return null
153
+ return recipes[0] // raw shape varies by mineflayer version, used for logging only
154
+ } catch { return null }
155
+ }
156
+
157
+ // Place a block from inventory in front of the bot (or adjacent if blocked).
158
+ // Returns true on success, false if no spot found or no item.
159
+ async function placeFromInventory(blockName) {
160
+ const normalized = blockName.replace(/ /g, '_').toLowerCase()
161
+ const itemId = bot.registry.itemsByName[normalized]?.id
162
+ if (!itemId) return false
163
+ const item = bot.inventory.findInventoryItem(itemId, null, false)
164
+ if (!item) return false
165
+
166
+ await safeEquip(bot, item, 'hand')
167
+
168
+ // Compute "in front of bot" target — uses yaw to find facing direction
169
+ const yaw = bot.entity.yaw
170
+ const dx = -Math.round(Math.sin(yaw))
171
+ const dz = -Math.round(Math.cos(yaw))
172
+ const here = bot.entity.position.floored()
173
+
174
+ // Try positions in priority order: directly in front (ground level), then sides, then behind
175
+ const offsets = [
176
+ [dx, 0, dz], [dx, -1, dz], // in front, ground or one below
177
+ [dz, 0, -dx], [-dz, 0, dx], // left, right
178
+ [-dx, 0, -dz], // behind (last resort)
179
+ ]
180
+
181
+ for (const [ox, oy, oz] of offsets) {
182
+ const targetPos = here.offset(ox, oy, oz)
183
+ const current = bot.blockAt(targetPos)
184
+ if (!current || (current.name !== 'air' && current.name !== 'cave_air' && current.name !== 'grass')) continue
185
+
186
+ // Find a solid reference block adjacent to the target to "click on"
187
+ const adj = [[0,-1,0],[1,0,0],[-1,0,0],[0,0,1],[0,0,-1],[0,1,0]]
188
+ for (const [adx, ady, adz] of adj) {
189
+ const refBlock = bot.blockAt(targetPos.offset(adx, ady, adz))
190
+ if (!refBlock || refBlock.boundingBox !== 'block') continue
191
+ try {
192
+ const faceVec = targetPos.minus(refBlock.position)
193
+ await safePlaceBlock(bot, refBlock, faceVec)
194
+ log.info('placed_block', { block: normalized, pos: { x: targetPos.x, y: targetPos.y, z: targetPos.z } })
195
+ return true
196
+ } catch (e) {
197
+ // Try next reference block
198
+ }
199
+ }
200
+ }
201
+ return false
202
+ }
203
+
204
+ // ── Tasks: gather / craft / mine / eat ───────────────────────────────────────
205
+
206
+ async function taskExplore() {
207
+ let p
208
+ try { p = await awaitValidPosition(2000) }
209
+ catch (e) { log.warn('explore_skip_invalid_position', { reason: e.message }); return }
210
+
211
+ const angle = Math.random() * Math.PI * 2
212
+ const dist = 10 + Math.random() * 20 // 10-30 blocks — short enough to reliably reach
213
+ const tx = Math.floor(p.x + Math.sin(angle) * dist)
214
+ const ty = Math.floor(p.y)
215
+ const tz = Math.floor(p.z + Math.cos(angle) * dist)
216
+ if (!isFiniteNum(tx) || !isFiniteNum(ty) || !isFiniteNum(tz)) {
217
+ log.error('explore_target_invalid', { tx, ty, tz })
218
+ return
219
+ }
220
+ log.debug('explore_target', { from: safeFloor(p), to: { x: tx, y: ty, z: tz } })
221
+
222
+ // Fail fast on complex terrain — try another direction next idle cycle
223
+ movement.setThinkingTimeout(2000)
224
+ try {
225
+ await navNear(tx, ty, tz, 5)
226
+ rememberLocation(memory, 'last_explored', bot.entity.position)
227
+ } finally {
228
+ movement.restoreThinkingTimeout()
229
+ }
230
+ }
231
+
232
+ async function taskGatherWood() {
233
+ const ids = logIds()
234
+ const logBlock = bot.findBlock({ matching: ids, maxDistance: 50 })
235
+ if (!logBlock) { safeChat('No trees in range.'); return }
236
+ console.log(`[${BOT_NAME}] Chopping log at ${logBlock.position}`)
237
+ await navNear(logBlock.position.x, logBlock.position.y, logBlock.position.z, 2)
238
+ const fresh = bot.blockAt(logBlock.position)
239
+ if (fresh && ids.includes(fresh.type) && bot.canDigBlock(fresh)) {
240
+ await safeDig(bot, fresh)
241
+ safeChat('Got wood.')
242
+ rememberEvent(memory, 'gathered_wood', {})
243
+ }
244
+ }
245
+
246
+ async function taskCraftPlanks() {
247
+ const logs = bot.inventory.items().filter(i => LOG_NAMES.includes(i.name))
248
+ if (!logs.length) { safeChat('No logs to convert.'); return }
249
+
250
+ let totalCrafted = 0
251
+ for (const logItem of logs) {
252
+ const plankName = logItem.name.replace('_log', '_planks')
253
+ const plankId = bot.registry.itemsByName[plankName]?.id
254
+ if (!plankId) continue
255
+
256
+ const recipes = bot.recipesFor(plankId, null, 1, null)
257
+ if (!recipes.length) continue
258
+
259
+ try {
260
+ const count = logItem.count
261
+ await safeCraft(bot, recipes[0], count, null)
262
+ totalCrafted += count * 4
263
+ } catch (err) {
264
+ console.error(`[${BOT_NAME}] Plank craft failed:`, err.message)
265
+ }
266
+ }
267
+
268
+ if (totalCrafted > 0) {
269
+ safeChat(`Made ${totalCrafted} planks.`)
270
+ rememberEvent(memory, 'crafted_planks', { count: totalCrafted })
271
+ } else {
272
+ safeChat('Could not make planks.')
273
+ }
274
+ }
275
+
276
+ async function taskGoTo(name) {
277
+ const loc = recallLocation(memory, name)
278
+ if (!loc) { safeChat(`Don't know where "${name}" is.`); return }
279
+ console.log(`[${BOT_NAME}] Going to ${name}`)
280
+ await navNear(loc.pos.x, loc.pos.y, loc.pos.z)
281
+ safeChat(`Reached ${name}.`)
282
+ rememberEvent(memory, 'visited', { name })
283
+ }
284
+
285
+ async function taskCollectNearby() {
286
+ const pos = bot.entity.position
287
+ const drops = Object.values(bot.entities)
288
+ .filter(e => e.type === 'object' && e.objectType === 'Item' && e.position.distanceTo(pos) < 25)
289
+ .sort((a, b) => a.position.distanceTo(pos) - b.position.distanceTo(pos))
290
+ .slice(0, 10)
291
+
292
+ if (!drops.length) { safeChat('No items nearby.'); return }
293
+
294
+ safeChat(`Collecting ${drops.length} item(s).`)
295
+ for (const e of drops) {
296
+ if (!e.isValid) continue
297
+ try { await navNear(e.position.x, e.position.y, e.position.z, 1) } catch {}
298
+ await new Promise(r => setTimeout(r, 300))
299
+ }
300
+ rememberEvent(memory, 'collected_items', {})
301
+ }
302
+
303
+ async function taskCraftItem(itemName) {
304
+ const normalized = (itemName || '').replace(/ /g, '_').toLowerCase()
305
+ if (!normalized) { safeChat('Craft what?'); return }
306
+
307
+ const itemData = bot.registry.itemsByName[normalized]
308
+ if (!itemData) { safeChat(`Don't know what "${itemName}" is.`); return }
309
+
310
+ // Skip items that previously phantom-crafted (succeeded silently without consuming materials)
311
+ const blockedUntil = phantomCraftBlocklist.get(normalized)
312
+ if (blockedUntil && Date.now() < blockedUntil) {
313
+ const secsLeft = Math.ceil((blockedUntil - Date.now()) / 1000)
314
+ log.warn('craft_skip_phantom_blocked', { item: normalized, secsLeft })
315
+ safeChat(`Skipping ${itemName} — recipe seems broken.`)
316
+ return
317
+ }
318
+
319
+ const beforeCount = countItemsByName(normalized)
320
+ const invSnapshot = bot.inventory.items().map(i => `${i.name}x${i.count}`)
321
+ log.info('craft_start', { item: normalized, before: beforeCount, inventory: invSnapshot })
322
+
323
+ // ── 1. Try inventory crafting (recipes that don't need a table) ────────────
324
+ let recipes = bot.recipesFor(itemData.id, null, 1, null)
325
+ if (recipes.length > 0) {
326
+ try {
327
+ const stickBefore = countItemsByName('stick')
328
+ const planksBefore = countMaterialFamily('_planks')
329
+ await safeCraft(bot, recipes[0], 1, null)
330
+ const after = countItemsByName(normalized)
331
+ const stickAfter = countItemsByName('stick')
332
+ const planksAfter = countMaterialFamily('_planks')
333
+ const consumed = (stickAfter < stickBefore) || (planksAfter < planksBefore)
334
+ if (after > beforeCount && consumed) {
335
+ safeChat(`Crafted ${itemName}.`)
336
+ log.info('craft_success', { item: normalized, table: false, before: beforeCount, after })
337
+ rememberEvent(memory, 'crafted', { item: itemName })
338
+ return
339
+ }
340
+ if (after > beforeCount && !consumed) {
341
+ log.error('craft_phantom', { item: normalized, table: false, beforeCount, after, stickBefore, stickAfter, planksBefore, planksAfter })
342
+ phantomCraftBlocklist.set(normalized, Date.now() + PHANTOM_BLOCK_MS)
343
+ safeChat(`Recipe broken for ${itemName}.`)
344
+ return
345
+ }
346
+ log.warn('craft_no_count_change', { item: normalized, table: false })
347
+ } catch (e) {
348
+ log.error('craft_inventory_exception', { item: normalized, message: e.message })
349
+ }
350
+ }
351
+
352
+ // ── 2. Need a crafting table — find or auto-place one ──────────────────────
353
+ const tableId = bot.registry.blocksByName['crafting_table']?.id
354
+ let table = tableId ? bot.findBlock({ matching: [tableId], maxDistance: 20 }) : null
355
+
356
+ if (!table) {
357
+ const tableItemId = bot.registry.itemsByName['crafting_table']?.id
358
+ if (tableItemId && bot.inventory.findInventoryItem(tableItemId, null, false)) {
359
+ log.info('auto_place_table', { reason: `crafting ${normalized}` })
360
+ const placed = await placeFromInventory('crafting_table')
361
+ if (placed) {
362
+ await new Promise(r => setTimeout(r, 300))
363
+ table = tableId ? bot.findBlock({ matching: [tableId], maxDistance: 5 }) : null
364
+ }
365
+ }
366
+ }
367
+
368
+ if (!table) {
369
+ safeChat('No crafting table.')
370
+ log.warn('craft_no_table', { item: normalized, hasTableInInv: inventoryHas(i => i.name === 'crafting_table') })
371
+ return
372
+ }
373
+ await navNear(table.position.x, table.position.y, table.position.z, 2)
374
+
375
+ // ── 3. Try with the table ──────────────────────────────────────────────────
376
+ recipes = bot.recipesFor(itemData.id, null, 1, table)
377
+
378
+ // ── 4. If no recipe is currently makeable and this is a tool, auto-make sticks ──
379
+ if (recipes.length === 0 && STICK_TOOL_RE.test(normalized) && !inventoryHas(i => i.name === 'stick')) {
380
+ log.info('auto_resolve_sticks', { reason: `tool prerequisite for ${normalized}` })
381
+ safeChat('Need sticks first.')
382
+ const ok = await ensureSticks()
383
+ if (ok) {
384
+ recipes = bot.recipesFor(itemData.id, null, 1, table)
385
+ }
386
+ }
387
+
388
+ // ── 5. Final sanity check ──────────────────────────────────────────────────
389
+ if (recipes.length === 0) {
390
+ const inv = bot.inventory.items().map(i => `${i.name}x${i.count}`).join(', ') || 'empty'
391
+ log.warn('craft_failed_no_recipe', { item: normalized, inventory: inv })
392
+ safeChat(`Can't craft ${itemName}. Have: ${inv.length > 80 ? inv.slice(0, 77) + '...' : inv}`)
393
+ return
394
+ }
395
+
396
+ // ── 6. Craft and verify (also detect phantom: success but materials not consumed) ──
397
+ try {
398
+ const stickBefore = countItemsByName('stick')
399
+ const planksBefore = countMaterialFamily('_planks')
400
+ await safeCraft(bot, recipes[0], 1, table)
401
+ const after = countItemsByName(normalized)
402
+ const stickAfter = countItemsByName('stick')
403
+ const planksAfter = countMaterialFamily('_planks')
404
+ const consumed = (stickAfter < stickBefore) || (planksAfter < planksBefore)
405
+
406
+ if (after > beforeCount && consumed) {
407
+ safeChat(`Crafted ${itemName}.`)
408
+ log.info('craft_success', { item: normalized, table: true, before: beforeCount, after, consumed: { stick: stickBefore - stickAfter, planks: planksBefore - planksAfter } })
409
+ rememberEvent(memory, 'crafted', { item: itemName })
410
+ } else if (after > beforeCount && !consumed) {
411
+ // Phantom craft: server didn't actually accept the recipe (likely protocol/data version drift)
412
+ // Don't keep retrying the same item — block for 5 minutes.
413
+ log.error('craft_phantom', { item: normalized, table: true, beforeCount, after, stickBefore, stickAfter, planksBefore, planksAfter })
414
+ phantomCraftBlocklist.set(normalized, Date.now() + PHANTOM_BLOCK_MS)
415
+ safeChat(`Recipe broken for ${itemName}. Skipping.`)
416
+ } else {
417
+ log.warn('craft_silent_failure', { item: normalized, before: beforeCount, after })
418
+ safeChat(`Crafted ${itemName} but inventory unchanged?`)
419
+ }
420
+ } catch (e) {
421
+ log.error('craft_table_exception', { item: normalized, message: e.message, stack: e.stack?.split('\n')[1]?.trim() })
422
+ safeChat(`Craft error: ${e.message.slice(0, 50)}`)
423
+ }
424
+ }
425
+
426
+ async function taskPlaceBlock(blockName) {
427
+ const normalized = (blockName || '').replace(/ /g, '_').toLowerCase()
428
+ if (!normalized) { safeChat("Place what?"); return }
429
+ if (!bot.registry.itemsByName[normalized]) {
430
+ safeChat(`Don't know what "${blockName}" is.`); return
431
+ }
432
+ const itemId = bot.registry.itemsByName[normalized].id
433
+ if (!bot.inventory.findInventoryItem(itemId, null, false)) {
434
+ safeChat(`No ${blockName} in inventory.`); return
435
+ }
436
+ const ok = await placeFromInventory(normalized)
437
+ if (ok) {
438
+ safeChat(`Placed ${blockName}.`)
439
+ rememberEvent(memory, 'placed', { block: normalized })
440
+ } else {
441
+ safeChat(`No good spot to place ${blockName}.`)
442
+ }
443
+ }
444
+
445
+ // Generic mining — works for stone, ores, sand, etc.
446
+ // Resolves singular → plural (e.g. "stone" finds stone block).
447
+ async function taskMineBlock(blockName, count = 1) {
448
+ const normalized = (blockName || '').replace(/ /g, '_').toLowerCase()
449
+ if (!normalized) { safeChat('Mine what?'); return }
450
+
451
+ // Resolve a few common aliases
452
+ const aliasMap = {
453
+ wood: 'oak_log', tree: 'oak_log', log: 'oak_log',
454
+ stone_block: 'stone',
455
+ iron: 'iron_ore', coal: 'coal_ore', gold: 'gold_ore', diamond: 'diamond_ore',
456
+ }
457
+ const target = aliasMap[normalized] || normalized
458
+ const blockId = bot.registry.blocksByName[target]?.id
459
+ if (!blockId) {
460
+ safeChat(`Don't know how to find "${blockName}".`)
461
+ log.warn('mine_unknown_block', { block: normalized })
462
+ return
463
+ }
464
+
465
+ log.info('mine_start', { block: target, count })
466
+ let mined = 0
467
+ let consecutiveFailures = 0
468
+ while (mined < count && consecutiveFailures < 3) {
469
+ const block = bot.findBlock({ matching: [blockId], maxDistance: 32 })
470
+ if (!block) {
471
+ log.warn('mine_no_block_found', { block: target, mined, requested: count })
472
+ safeChat(mined > 0 ? `Got ${mined}. No more nearby.` : `No ${blockName} nearby.`)
473
+ return
474
+ }
475
+
476
+ if (!bot.canDigBlock(block)) {
477
+ log.warn('mine_cant_dig', { block: target, hasPickaxe: inventoryHas(i => i.name.includes('pickaxe')) })
478
+ safeChat(`Can't break ${blockName} — wrong tool?`)
479
+ return
480
+ }
481
+
482
+ try {
483
+ await navNear(block.position.x, block.position.y, block.position.z, 2)
484
+ const fresh = bot.blockAt(block.position)
485
+ if (!fresh || fresh.type !== blockId) {
486
+ consecutiveFailures++
487
+ continue // someone else broke it, or it changed
488
+ }
489
+ // Equip the best tool we have
490
+ await equipBestTool(target)
491
+ await safeDig(bot, fresh)
492
+ mined++
493
+ consecutiveFailures = 0
494
+ log.info('mined', { block: target, count: mined })
495
+ } catch (e) {
496
+ consecutiveFailures++
497
+ log.error('mine_exception', { block: target, message: e.message, attempt: consecutiveFailures })
498
+ }
499
+ }
500
+
501
+ if (mined > 0) {
502
+ safeChat(`Got ${mined} ${blockName}.`)
503
+ rememberEvent(memory, 'mined', { block: target, count: mined })
504
+ } else {
505
+ safeChat(`Couldn't mine ${blockName}.`)
506
+ }
507
+ }
508
+
509
+ // Eat food when hungry. Picks the best available food (cooked > raw > emergency).
510
+ const FOOD_PRIORITY = [
511
+ 'cooked_beef','cooked_porkchop','cooked_chicken','cooked_mutton','cooked_rabbit',
512
+ 'cooked_cod','cooked_salmon','baked_potato','bread','pumpkin_pie',
513
+ 'beef','porkchop','chicken','mutton','rabbit',
514
+ 'apple','carrot','melon_slice','sweet_berries','cookie','golden_apple',
515
+ // Last resort — these have side effects
516
+ 'rotten_flesh','spider_eye','poisonous_potato',
517
+ ]
518
+
519
+ async function taskEatFood() {
520
+ if (bot.food >= 20) { safeChat('Already full.'); return }
521
+
522
+ const inv = bot.inventory.items()
523
+ let food = null
524
+ for (const fn of FOOD_PRIORITY) {
525
+ food = inv.find(i => i.name === fn)
526
+ if (food) break
527
+ }
528
+
529
+ if (!food) {
530
+ log.warn('eat_no_food', { hunger: bot.food })
531
+ safeChat('No food.')
532
+ return
533
+ }
534
+
535
+ log.info('eat_start', { food: food.name, hungerBefore: bot.food })
536
+ try {
537
+ await safeEquip(bot, food, 'hand')
538
+ await safeConsume(bot)
539
+ log.info('eat_success', { food: food.name, hungerBefore: bot.food, hungerAfter: bot.food })
540
+ safeChat(`Ate ${food.name.replace(/_/g, ' ')}.`)
541
+ rememberEvent(memory, 'ate', { food: food.name })
542
+ } catch (e) {
543
+ log.error('eat_exception', { food: food.name, message: e.message })
544
+ safeChat(`Couldn't eat: ${e.message.slice(0, 50)}`)
545
+ }
546
+ }
547
+
548
+ // ── Tasks: combat / survival ─────────────────────────────────────────────────
549
+
550
+ async function taskAttackMobs() {
551
+ const mob = bot.nearestEntity(e =>
552
+ e.name && HOSTILE_MOB_NAMES.has(e.name) && e.position.distanceTo(bot.entity.position) < 20
553
+ )
554
+ if (!mob) { safeChat('No hostile mobs nearby.'); return }
555
+
556
+ const weapon = await equipBestWeapon()
557
+ console.log(`[${BOT_NAME}] Attacking ${mob.name} with ${weapon || 'fists'}`)
558
+ safeChat(`Fighting ${mob.name}.`)
559
+ await safeAttack(bot, mob, { maxMs: 10_000, label: mob.name })
560
+
561
+ if (mob.isValid) safeChat('Got away.')
562
+ else { safeChat(`${mob.name} down.`); rememberEvent(memory, 'killed_mob', { name: mob.name }) }
563
+ }
564
+
565
+ async function taskAttackPlayer(entity, username) {
566
+ if (!entity || !entity.isValid) { safeChat(`${username} is gone.`); return }
567
+
568
+ const weapon = await equipBestWeapon()
569
+ console.log(`[${BOT_NAME}] Attacking player ${username} with ${weapon || 'fists'}`)
570
+ safeChat(`Coming for you, ${username}.`)
571
+ await safeAttack(bot, entity, { maxMs: 10_000, label: username })
572
+
573
+ // Reduce anger after fighting
574
+ const rec = anger.get(username)
575
+ if (rec) rec.level = Math.max(0, rec.level - 3)
576
+ safeChat('We even now.')
577
+ rememberEvent(memory, 'fought_player', { username })
578
+ }
579
+
580
+ // Escape from being stuck (in a hole, surrounded, etc.)
581
+ // Strategy: try to navigate to a higher Y at our current X/Z (pathfinder will dig
582
+ // or pillar with our scaffolding blocks). If that fails, dig straight up as a last resort.
583
+ async function taskEscape() {
584
+ let p
585
+ try { p = await awaitValidPosition(2000) }
586
+ catch (e) { log.warn('escape_skip_invalid_position', { reason: e.message }); safeChat("Can't escape yet."); return }
587
+
588
+ // Get current perception to classify stuck state and orient escape
589
+ const ep = getEnvPerception()
590
+ const scan = getLastEnvScan() || (ep ? ep.scan() : null)
591
+ const stuckClass = scan?.stuckClass || 'unknown'
592
+
593
+ log.info('escape_start', {
594
+ from: { x: Math.floor(p.x), y: Math.floor(p.y), z: Math.floor(p.z) },
595
+ stuckClass,
596
+ locomotionRisk: scan?.locomotionRisk ?? null,
597
+ hazardSummary: scan?.hazardSummary ?? null,
598
+ })
599
+ safeChat('Stuck — escaping.')
600
+
601
+ // Phase 1: locomotion micro-escape first (fast, no pathfinder)
602
+ const lr = getLocomotionRecovery()
603
+ if (lr && scan?.valid !== false) {
604
+ log.info('escape_attempt', { method: 'locomotion_phase', stuckClass })
605
+ await lr.runHazardEscape(stuckClass, scan, 'task_escape')
606
+ await new Promise(r => setTimeout(r, 300))
607
+ }
608
+
609
+ // Re-read position after locomotion phase
610
+ const p2 = bot.entity?.position
611
+ if (!isValidVec(p2)) {
612
+ log.warn('escape_position_invalid_after_locomotion')
613
+ return
614
+ }
615
+
616
+ const startY = Math.floor(p2.y)
617
+ const targetY = Math.min(75, startY + 15)
618
+
619
+ // Phase 2: pathfinder escape — prefer escape-vector direction if known
620
+ const ev = scan?.escapeVector
621
+ const cx = Math.floor(p2.x)
622
+ const cz = Math.floor(p2.z)
623
+
624
+ const attempts = [
625
+ { x: cx, z: cz, y: targetY }, // straight up
626
+ // Escape-vector-biased targets first
627
+ ...(ev ? [
628
+ { x: cx + ev.dx * 6, z: cz + ev.dz * 6, y: targetY },
629
+ { x: cx + ev.dx * 4, z: cz + ev.dz * 4, y: startY },
630
+ ] : []),
631
+ { x: cx + 6, z: cz, y: targetY },
632
+ { x: cx - 6, z: cz, y: targetY },
633
+ { x: cx, z: cz + 6, y: targetY },
634
+ { x: cx, z: cz - 6, y: targetY },
635
+ ]
636
+
637
+ movement.setThinkingTimeout(5000)
638
+ try {
639
+ for (const a of attempts) {
640
+ if (!isFiniteNum(a.x) || !isFiniteNum(a.y) || !isFiniteNum(a.z)) continue
641
+ try {
642
+ await navNear(Math.floor(a.x), Math.floor(a.y), Math.floor(a.z), 3)
643
+ const newY = Math.floor(bot.entity.position.y)
644
+ log.info('escape_complete', { method: 'pathfinder', reached: a, newY, stuckClass })
645
+ safeChat('Out!')
646
+ return
647
+ } catch (e) {
648
+ log.warn('escape_attempt_failed', { target: a, message: e.message })
649
+ }
650
+ }
651
+ } finally {
652
+ movement.restoreThinkingTimeout()
653
+ }
654
+
655
+ // Phase 3: dig up as last resort
656
+ log.warn('escape_falling_back_to_dig_up', { stuckClass })
657
+ await digUp(12)
658
+ log.info('escape_complete', { method: 'dig_up', endY: Math.floor(bot.entity.position.y) })
659
+ }
660
+
661
+ // Mine the block above the bot's head, jump to fill the new space, repeat.
662
+ // Used when pathfinder can't find a way out.
663
+ async function digUp(maxBlocks = 10) {
664
+ let dug = 0
665
+ for (let i = 0; i < maxBlocks; i++) {
666
+ const headBlock = bot.blockAt(bot.entity.position.offset(0, 2, 0))
667
+ if (!headBlock || ['air', 'cave_air', 'void_air'].includes(headBlock.name)) {
668
+ break // already at open air
669
+ }
670
+ if (!bot.canDigBlock(headBlock)) {
671
+ log.warn('dig_up_blocked', { block: headBlock.name })
672
+ safeChat(`Can't dig ${headBlock.name} — wrong tool?`)
673
+ break
674
+ }
675
+ try {
676
+ await equipBestTool(headBlock.name)
677
+ await safeDig(bot, headBlock)
678
+ dug++
679
+ // Jump up to occupy the new block space
680
+ bot.setControlState('jump', true)
681
+ await new Promise(r => setTimeout(r, 350))
682
+ bot.setControlState('jump', false)
683
+ } catch (e) {
684
+ log.warn('dig_up_step_failed', { message: e.message })
685
+ break
686
+ }
687
+ }
688
+ return dug
689
+ }
690
+
691
+ // Step away from an environmental hazard (cactus, lava, fire, etc.).
692
+ // `hazard` is optional — if null, just moves 5 blocks in a random horizontal direction.
693
+ async function taskEvadeHazard(hazard) {
694
+ // Wait for valid position — critical right after teleport / respawn,
695
+ // when bot.entity.position briefly has NaN x/z.
696
+ let pos
697
+ try {
698
+ pos = await awaitValidPosition(2000)
699
+ } catch (e) {
700
+ log.warn('evade_skip_invalid_position', { reason: e.message, hazard: hazard?.name })
701
+ safeChat('Need a moment.')
702
+ return
703
+ }
704
+
705
+ // Compute escape vector — always via safeNormalize2D so length-0 / NaN
706
+ // can never produce NaN target coords.
707
+ let rawDx, rawDz, label
708
+ const hazardPos = hazard?.block?.position
709
+ if (hazard && isValidVec(hazardPos)) {
710
+ rawDx = pos.x - hazardPos.x
711
+ rawDz = pos.z - hazardPos.z
712
+ label = (hazard.name || 'something').replace(/_/g, ' ')
713
+ } else {
714
+ rawDx = NaN
715
+ rawDz = NaN // forces safeNormalize2D into random fallback
716
+ label = hazard?.name?.replace(/_/g, ' ') || 'something'
717
+ }
718
+
719
+ const { dx, dz, fallback } = safeNormalize2D(rawDx, rawDz)
720
+ if (fallback) log.debug('evade_normalize_fallback', { reason: fallback, hazard: hazard?.name })
721
+
722
+ const tx = Math.floor(pos.x + dx * 5)
723
+ const ty = Math.floor(pos.y)
724
+ const tz = Math.floor(pos.z + dz * 5)
725
+
726
+ // Final safety net: if for any reason we still have NaN, pick a random walkable spot
727
+ if (!isFiniteNum(tx) || !isFiniteNum(ty) || !isFiniteNum(tz)) {
728
+ log.error('evade_target_invalid_after_safety', { tx, ty, tz, hazard: hazard?.name })
729
+ return
730
+ }
731
+
732
+ log.info('evade_start', {
733
+ hazard: hazard?.name || null,
734
+ from: safeFloor(pos),
735
+ to: { x: tx, y: ty, z: tz },
736
+ })
737
+ safeChat(`Ow — ${label}!`)
738
+
739
+ movement.forceStop('evade_start')
740
+ bot.clearControlStates()
741
+ await new Promise(r => setTimeout(r, 100))
742
+ await navNear(tx, ty, tz, 2, movement.PRIORITY.HIGH)
743
+ log.info('evade_complete', { newPos: safeFloor(bot.entity?.position) })
744
+ }
745
+
746
+ // Run away from any nearby threats
747
+ async function taskFlee() {
748
+ let pos
749
+ try { pos = await awaitValidPosition(2000) }
750
+ catch (e) { log.warn('flee_skip_invalid_position', { reason: e.message }); return }
751
+
752
+ const threats = Object.values(bot.entities)
753
+ .filter(e => e.name && HOSTILE_MOB_NAMES.has(e.name) && isValidVec(e.position) && e.position.distanceTo(pos) < 24)
754
+
755
+ if (threats.length === 0) {
756
+ safeChat('Nothing to run from.')
757
+ log.info('flee_no_threats')
758
+ return
759
+ }
760
+
761
+ // Direction directly away from the centroid of threats — guarded against NaN.
762
+ let avgX = 0, avgZ = 0
763
+ for (const t of threats) { avgX += t.position.x; avgZ += t.position.z }
764
+ avgX /= threats.length; avgZ /= threats.length
765
+ const { dx, dz, fallback } = safeNormalize2D(pos.x - avgX, pos.z - avgZ)
766
+ if (fallback) log.debug('flee_normalize_fallback', { reason: fallback })
767
+
768
+ const fleeX = Math.floor(pos.x + dx * 30)
769
+ const fleeY = Math.floor(pos.y)
770
+ const fleeZ = Math.floor(pos.z + dz * 30)
771
+ if (!isFiniteNum(fleeX) || !isFiniteNum(fleeY) || !isFiniteNum(fleeZ)) {
772
+ log.error('flee_target_invalid', { fleeX, fleeY, fleeZ })
773
+ return
774
+ }
775
+
776
+ log.info('flee_start', { threats: threats.map(t => t.name), to: { x: fleeX, y: fleeY, z: fleeZ } })
777
+ safeChat('Falling back!')
778
+ try {
779
+ await navNear(fleeX, fleeY, fleeZ, 5)
780
+ log.info('flee_complete', { newPos: safeFloor(bot.entity?.position) })
781
+ } catch (e) {
782
+ log.warn('flee_path_failed', { message: e.message })
783
+ }
784
+ }
785
+
786
+ // Blind survival: sprint away in a random direction using raw control states.
787
+ // Used when position is invalid (NaN) — does NOT use the pathfinder.
788
+ // This breaks the "validator dead-end" failure where the bot stands still dying
789
+ // because all damage events are dropped due to stale position cache (diagnosis F18).
790
+ async function taskBlindSurvival() {
791
+ const ep = getEnvPerception()
792
+ const scan = getLastEnvScan() || (ep ? ep.scan() : null)
793
+ const stuck = scan?.stuckClass || 'unknown'
794
+ log.warn('blind_survival_start', { livenessState: liveness.getState(), stuckClass: stuck, risk: scan?.locomotionRisk ?? null })
795
+ safeChat('Taking hits. Moving.')
796
+
797
+ const lr = getLocomotionRecovery()
798
+ if (lr && scan?.valid !== false) {
799
+ // Use perception-aware escape rather than random yaw
800
+ await lr.runHazardEscape(stuck, scan, 'blind_survival')
801
+ } else {
802
+ // Fallback: random direction sprint
803
+ const angle = Math.random() * Math.PI * 2
804
+ try { bot.entity.yaw = angle } catch {}
805
+ bot.setControlState('forward', true)
806
+ bot.setControlState('sprint', true)
807
+ bot.setControlState('jump', true)
808
+ await new Promise(r => setTimeout(r, 400))
809
+ bot.setControlState('jump', false)
810
+ await new Promise(r => setTimeout(r, 1600))
811
+ bot.clearControlStates()
812
+ }
813
+
814
+ log.info('blind_survival_complete', { livenessState: liveness.getState() })
815
+ }
816
+
817
+ // ── Tasks: building ──────────────────────────────────────────────────────────
818
+
819
+ async function placeBlockAt(targetPos, blockName) {
820
+ const current = bot.blockAt(targetPos)
821
+ if (current && current.name !== 'air' && current.name !== 'cave_air') return true
822
+
823
+ const itemId = bot.registry.itemsByName[blockName]?.id
824
+ const item = itemId ? bot.inventory.findInventoryItem(itemId, null, false) : null
825
+ if (!item) return false
826
+
827
+ await safeEquip(bot, item, 'hand')
828
+
829
+ const adj = [[0,-1,0],[0,1,0],[1,0,0],[-1,0,0],[0,0,1],[0,0,-1]]
830
+ for (const [dx, dy, dz] of adj) {
831
+ const refBlock = bot.blockAt(targetPos.offset(dx, dy, dz))
832
+ if (!refBlock || refBlock.boundingBox !== 'block') continue
833
+ try {
834
+ await navNear(targetPos.x, targetPos.y, targetPos.z, 4)
835
+ const faceVec = targetPos.minus(refBlock.position)
836
+ await safePlaceBlock(bot, refBlock, faceVec)
837
+ return true
838
+ } catch {}
839
+ }
840
+ return false
841
+ }
842
+
843
+ async function buildHouseStructure() {
844
+ const planks = bot.inventory.items().filter(i => PLANK_NAMES.includes(i.name))
845
+ if (!planks.length) { safeChat('No planks to build with.'); return }
846
+ const plankName = planks[0].name
847
+
848
+ const origin = bot.entity.position.floored().offset(0, 0, -5)
849
+ console.log(`[${BOT_NAME}] Building house at ${origin}`)
850
+
851
+ let outOfBlocks = false
852
+
853
+ // Walls: 5x5 perimeter, 3 high, door gap at (0, 1-2, -2)
854
+ for (let y = 1; y <= 3 && !outOfBlocks; y++) {
855
+ for (let x = -2; x <= 2 && !outOfBlocks; x++) {
856
+ for (let z = -2; z <= 2 && !outOfBlocks; z++) {
857
+ if (Math.abs(x) !== 2 && Math.abs(z) !== 2) continue
858
+ if (z === -2 && x === 0 && y <= 2) continue // door
859
+
860
+ const ok = await placeBlockAt(origin.offset(x, y, z), plankName)
861
+ if (!ok && countInInv([plankName]) === 0) { safeChat('Out of planks.'); outOfBlocks = true }
862
+ }
863
+ }
864
+ }
865
+
866
+ // Roof
867
+ for (let x = -2; x <= 2 && !outOfBlocks; x++) {
868
+ for (let z = -2; z <= 2 && !outOfBlocks; z++) {
869
+ const ok = await placeBlockAt(origin.offset(x, 4, z), plankName)
870
+ if (!ok && countInInv([plankName]) === 0) { safeChat('Out of planks on roof.'); outOfBlocks = true }
871
+ }
872
+ }
873
+
874
+ if (!outOfBlocks) {
875
+ safeChat('House done!')
876
+ rememberLocation(memory, 'house', origin)
877
+ rememberEvent(memory, 'built_house', {})
878
+ }
879
+ }
880
+
881
+ // Smart agentic house building — auto-chains: gather wood → craft planks → place blocks
882
+ async function taskBuildHouseSmart() {
883
+ safeChat('Starting house. Will gather and craft if needed.')
884
+
885
+ let plankCount = countInInv(PLANK_NAMES)
886
+
887
+ // Step 1: convert any logs to planks
888
+ if (countInInv(LOG_NAMES) > 0) {
889
+ await taskCraftPlanks()
890
+ plankCount = countInInv(PLANK_NAMES)
891
+ }
892
+
893
+ // Step 2: while not enough, gather more wood and craft
894
+ let attempts = 0
895
+ while (plankCount < 30 && attempts < 12) {
896
+ attempts++
897
+ const stillNeed = 30 - plankCount
898
+ const logsNeeded = Math.ceil(stillNeed / 4)
899
+
900
+ safeChat(`Have ${plankCount} planks, need ${30 - plankCount} more. Gathering wood (try ${attempts}/12).`)
901
+
902
+ let chopped = 0
903
+ while (chopped < logsNeeded) {
904
+ const ids = logIds()
905
+ const logBlock = bot.findBlock({ matching: ids, maxDistance: 60 })
906
+ if (!logBlock) { safeChat('No more trees in range. Stopping.'); return }
907
+ try {
908
+ await navNear(logBlock.position.x, logBlock.position.y, logBlock.position.z, 2)
909
+ const fresh = bot.blockAt(logBlock.position)
910
+ if (fresh && ids.includes(fresh.type)) {
911
+ await safeDig(bot, fresh)
912
+ chopped++
913
+ } else { break }
914
+ } catch (e) {
915
+ console.error(`[${BOT_NAME}] gather error:`, e.message)
916
+ break
917
+ }
918
+ }
919
+
920
+ await taskCraftPlanks()
921
+ plankCount = countInInv(PLANK_NAMES)
922
+ }
923
+
924
+ if (plankCount < 30) {
925
+ safeChat(`Couldn't gather enough planks (${plankCount}). Aborting.`)
926
+ return
927
+ }
928
+
929
+ safeChat(`Have ${plankCount} planks. Building now.`)
930
+ await buildHouseStructure()
931
+ }
932
+
933
+ return {
934
+ taskExplore, taskGatherWood, taskCraftPlanks, taskGoTo, taskAttackMobs, taskAttackPlayer,
935
+ taskCollectNearby, taskCraftItem, taskPlaceBlock, taskMineBlock, taskEatFood,
936
+ taskEscape, taskEvadeHazard, taskFlee, taskBuildHouseSmart, taskBlindSurvival,
937
+ FOOD_PRIORITY,
938
+ }
939
+ }