From f13799de2892f49e18a36db8c711b4d46073c783 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 16:12:53 +0300 Subject: [PATCH] fix(runtime/reflex): retreat after repeated melee clears --- runtime/reflex.js | 19 ++++++++++++++++++- runtime/reflex.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/runtime/reflex.js b/runtime/reflex.js index 7bf2891..256e926 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -41,6 +41,7 @@ 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; +const DEFEND_REPEAT_OK_RETREAT_COUNT = 2; // A reflex returns one of: // { action: "noop" } — nothing to do @@ -118,13 +119,19 @@ async function attackNearestUntilClear(bot, hostileName, opts = {}) { } function rememberDefendAttack(ctx, hostileName, res) { + const now = Date.now(); if (res?.ok) { + const prev = ctx.defendAttackCleared; + const count = prev?.name === hostileName && now - prev.ts < DEFEND_STUCK_WINDOW_MS + ? prev.count + 1 + : 1; + ctx.defendAttackCleared = { name: hostileName, count, ts: now }; if (ctx.defendAttackStuck?.name === hostileName) ctx.defendAttackStuck = null; return; } + ctx.defendAttackCleared = null; 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; @@ -137,6 +144,12 @@ function shouldRetreatFromStuckAttack(ctx, hostileName) { return stuck.count >= 1 && Date.now() - stuck.ts < DEFEND_STUCK_WINDOW_MS; } +function shouldRetreatFromRepeatedClear(ctx, hostileName) { + const cleared = ctx.defendAttackCleared; + if (!cleared || cleared.name !== hostileName) return false; + return cleared.count >= DEFEND_REPEAT_OK_RETREAT_COUNT && Date.now() - cleared.ts < DEFEND_STUCK_WINDOW_MS; +} + function matchingHostileEntity(ctx, hostileName, dist) { return Object.values(ctx.bot?.entities ?? {}).find( (e) => @@ -179,6 +192,10 @@ function defendReflex(ctx) { // Anything beyond 8m with full HP is ignored regardless of how many // hostiles the perceive snapshot enumerates. if (dist <= 4) { + if (shouldRetreatFromRepeatedClear(ctx, hostile.name)) { + ctx.defendAttackCleared = null; + return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); + } if (shouldRetreatFromStuckAttack(ctx, hostile.name)) { ctx.defendAttackStuck = null; return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index e435814..234acec 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -139,6 +139,32 @@ test("defend flees after a verified attack leaves hostile in reach", () => { assert.equal(dispatches[1].label, "flee from zombie"); }); +test("defend flees after repeated attack successes still leave 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.kind, "defend-attack"); + dispatches[0].opts.onComplete({ ok: true, code: "done" }); + + const second = runTick(ctx); + assert.equal(second.kind, "defend-attack"); + dispatches[1].opts.onComplete({ ok: true, code: "done" }); + + const third = runTick(ctx); + assert.equal(third.reflex, "defend"); + assert.equal(third.kind, "defend-flee"); + assert.equal(dispatches[2].label, "flee from zombie"); +}); + test("eat wins over curriculum when food low and bot has food in inventory", () => { const { ctx, dispatches } = makeCtx({ snapshot: {