fix(runtime/reflex): verify melee clears hostile

This commit is contained in:
2026-05-26 16:09:21 +03:00
parent d87b83ea35
commit b23aad2128
2 changed files with 206 additions and 23 deletions
+132 -22
View File
@@ -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 -------------------------------------------------------------------
+74 -1
View File
@@ -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: {