From b23aad21281bd031b1a70a6b125ad81dbc277119 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 16:09:07 +0300 Subject: [PATCH] fix(runtime/reflex): verify melee clears hostile --- runtime/reflex.js | 154 +++++++++++++++++++++++++++++++++++------ runtime/reflex.test.js | 75 +++++++++++++++++++- 2 files changed, 206 insertions(+), 23 deletions(-) diff --git a/runtime/reflex.js b/runtime/reflex.js index 8c6d7e0..7bf2891 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -37,6 +37,11 @@ let consecutiveWanderHints = 0; const REFLEX_LOG = "reflex"; +const DEFEND_ATTACK_MAX_SWINGS = 5; +const DEFEND_ATTACK_SETTLE_MS = 650; +const DEFEND_CLEAR_RADIUS = 4.5; +const DEFEND_STUCK_WINDOW_MS = 20_000; + // A reflex returns one of: // { action: "noop" } — nothing to do // { action: "dispatched", kind, label } — dispatched an async action @@ -45,50 +50,155 @@ const REFLEX_LOG = "reflex"; // ---- defend ---------------------------------------------------------------- +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function nearestHostileDistance(bot, hostileName) { + const here = bot?.entity?.position; + if (!here) return null; + let nearest = null; + for (const e of Object.values(bot.entities ?? {})) { + if (!e?.position) continue; + if (hostileName && e.name !== hostileName) continue; + let dist; + try { + dist = e.position.distanceTo(here); + } catch { + continue; + } + if (!Number.isFinite(dist)) continue; + if (nearest === null || dist < nearest) nearest = dist; + } + return nearest; +} + +function distanceDetail(dist) { + return Number.isFinite(dist) ? Number(dist.toFixed(1)) : dist; +} + +async function attackNearestUntilClear(bot, hostileName, opts = {}) { + if (!bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing position" }; + const maxSwings = Math.max(1, opts.maxSwings ?? DEFEND_ATTACK_MAX_SWINGS); + const settleMs = Math.max(0, opts.settleMs ?? DEFEND_ATTACK_SETTLE_MS); + let lastDetail = null; + + for (let swings = 0; swings < maxSwings; swings++) { + const before = nearestHostileDistance(bot, hostileName); + if (before === null || before > DEFEND_CLEAR_RADIUS) { + return { ok: true, code: "done", detail: { target: hostileName, cleared: true, swings } }; + } + + const res = await attackNearest(bot, hostileName); + lastDetail = res?.detail ?? null; + if (!res?.ok) { + return { + ok: false, + code: res?.code ?? "no_target", + detail: res?.detail ?? "no target in reach", + }; + } + if (settleMs > 0) await delay(settleMs); + } + + const after = nearestHostileDistance(bot, hostileName); + if (after === null || after > DEFEND_CLEAR_RADIUS) { + return { ok: true, code: "done", detail: { target: hostileName, cleared: true, swings: maxSwings } }; + } + return { + ok: false, + code: "hostile_still_near", + detail: { + target: hostileName, + distance: distanceDetail(after), + swings: maxSwings, + last: lastDetail, + }, + }; +} + +function rememberDefendAttack(ctx, hostileName, res) { + if (res?.ok) { + if (ctx.defendAttackStuck?.name === hostileName) ctx.defendAttackStuck = null; + return; + } + if (res?.code !== "hostile_still_near") return; + const prev = ctx.defendAttackStuck; + const now = Date.now(); + const count = prev?.name === hostileName && now - prev.ts < DEFEND_STUCK_WINDOW_MS + ? prev.count + 1 + : 1; + ctx.defendAttackStuck = { name: hostileName, count, ts: now }; +} + +function shouldRetreatFromStuckAttack(ctx, hostileName) { + const stuck = ctx.defendAttackStuck; + if (!stuck || stuck.name !== hostileName) return false; + return stuck.count >= 1 && Date.now() - stuck.ts < DEFEND_STUCK_WINDOW_MS; +} + +function matchingHostileEntity(ctx, hostileName, dist) { + return Object.values(ctx.bot?.entities ?? {}).find( + (e) => + e.name === hostileName && + e.position && + Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5, + ); +} + +function dispatchDefendFlee(ctx, hostile, dist, opts = {}) { + const lastFlee = ctx.lastFleeAttempt; + if (!opts.ignoreCooldown && lastFlee && lastFlee.name === hostile.name && Date.now() - lastFlee.ts < 60_000) { + return { action: "noop" }; + } + ctx.lastFleeAttempt = { name: hostile.name, ts: Date.now() }; + + const fromEntity = matchingHostileEntity(ctx, hostile.name, dist); + ctx.dispatch( + () => fleeFrom(ctx.bot, fromEntity, 16), + `flee from ${hostile.name}`, + ); + return { action: "dispatched", kind: "defend-flee", label: hostile.name }; +} + function defendReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; if (!s.closestHostile) return { action: "noop" }; - const dist = s.closestHostile.distance; + const hostile = s.closestHostile; + const dist = hostile.distance; const lowHp = (s.health ?? 20) <= 8; // Three regimes — tightened to avoid the "82 distant hostiles → constant // flee" pathology observed at this spawn: - // - within 4m: melee attack + // - within 4m: verified melee attack (do not report success while the + // hostile is still standing in reach) // - within 8m (and visibly hostile to us): flee // - low-HP fallback: flee anything within 12m // Anything beyond 8m with full HP is ignored regardless of how many // hostiles the perceive snapshot enumerates. if (dist <= 4) { + if (shouldRetreatFromStuckAttack(ctx, hostile.name)) { + ctx.defendAttackStuck = null; + return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); + } ctx.dispatch( - () => attackNearest(ctx.bot, s.closestHostile.name), - `attack ${s.closestHostile.name}`, + () => attackNearestUntilClear(ctx.bot, hostile.name, { + maxSwings: ctx.defendAttackMaxSwings, + settleMs: ctx.defendAttackSettleMs, + }), + `attack ${hostile.name}`, + { onComplete: (res) => rememberDefendAttack(ctx, hostile.name, res) }, ); - return { action: "dispatched", kind: "defend-attack", label: s.closestHostile.name }; + return { action: "dispatched", kind: "defend-attack", label: hostile.name }; } const shouldFlee = (dist <= 8) || (lowHp && dist <= 12); if (!shouldFlee) return { action: "noop" }; // Cooldown — if we just fled from this same mob type and it didn't work // (timed out), don't immediately re-fire. Let other reflexes run. - const lastFlee = ctx.lastFleeAttempt; - if (lastFlee && lastFlee.name === s.closestHostile.name && Date.now() - lastFlee.ts < 60_000) { - return { action: "noop" }; - } - ctx.lastFleeAttempt = { name: s.closestHostile.name, ts: Date.now() }; - - const fromEntity = Object.values(ctx.bot.entities).find( - (e) => - e.name === s.closestHostile.name && - e.position && - Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5, - ); - ctx.dispatch( - () => fleeFrom(ctx.bot, fromEntity, 16), - `flee from ${s.closestHostile.name}`, - ); - return { action: "dispatched", kind: "defend-flee", label: s.closestHostile.name }; + return dispatchDefendFlee(ctx, hostile, dist); } // ---- eat ------------------------------------------------------------------- diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 699636f..e435814 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -7,6 +7,36 @@ import assert from "node:assert/strict"; import { runTick } from "./reflex.js"; +function vec(x, y, z) { + return { + x, + y, + z, + distanceTo(other) { + return Math.hypot(this.x - other.x, this.y - other.y, this.z - other.z); + }, + offset(dx, dy, dz) { + return vec(this.x + dx, this.y + dy, this.z + dz); + }, + }; +} + +function makeCombatBot({ removeOnAttack = false } = {}) { + return { + attacks: 0, + entity: { position: vec(0, 64, 0) }, + entities: { + zombie1: { name: "zombie", height: 1.8, position: vec(3, 64, 0) }, + }, + inventory: { items: () => [] }, + lookAt: async () => {}, + attack() { + this.attacks++; + if (removeOnAttack) delete this.entities.zombie1; + }, + }; +} + function makeCtx({ snapshot, busy = false, @@ -27,7 +57,7 @@ function makeCtx({ lastCurriculumAt, skillBackoff, dispatch(fn, label, opts = {}) { - dispatches.push({ label, opts }); + dispatches.push({ fn, label, opts }); }, }; return { ctx, dispatches }; @@ -66,6 +96,49 @@ test("defend wins over curriculum when hostile in melee", () => { assert.match(dispatches[0].label, /attack zombie/); }); +test("defend attack does not report success while hostile remains nearby", async () => { + const bot = makeCombatBot(); + const { ctx, dispatches } = makeCtx({ + bot, + snapshot: { + connected: true, + health: 20, + closestHostile: { name: "zombie", distance: 3 }, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + ctx.defendAttackMaxSwings = 2; + ctx.defendAttackSettleMs = 0; + + const out = runTick(ctx); + assert.equal(out.reflex, "defend"); + const res = await dispatches[0].fn(); + assert.equal(res.ok, false); + assert.equal(res.code, "hostile_still_near"); + assert.equal(bot.attacks, 2); +}); + +test("defend flees after a verified attack leaves hostile in reach", () => { + const bot = makeCombatBot(); + const { ctx, dispatches } = makeCtx({ + bot, + snapshot: { + connected: true, + health: 20, + closestHostile: { name: "zombie", distance: 3 }, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const first = runTick(ctx); + assert.equal(first.reflex, "defend"); + dispatches[0].opts.onComplete({ ok: false, code: "hostile_still_near" }); + + const second = runTick(ctx); + assert.equal(second.reflex, "defend"); + assert.equal(second.kind, "defend-flee"); + assert.equal(dispatches[1].label, "flee from zombie"); +}); + test("eat wins over curriculum when food low and bot has food in inventory", () => { const { ctx, dispatches } = makeCtx({ snapshot: {