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