fix(runtime/reflex): verify melee clears hostile
This commit is contained in:
+132
-22
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user