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 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:
|
// A reflex returns one of:
|
||||||
// { action: "noop" } — nothing to do
|
// { action: "noop" } — nothing to do
|
||||||
// { action: "dispatched", kind, label } — dispatched an async action
|
// { action: "dispatched", kind, label } — dispatched an async action
|
||||||
@@ -45,50 +50,155 @@ const REFLEX_LOG = "reflex";
|
|||||||
|
|
||||||
// ---- defend ----------------------------------------------------------------
|
// ---- 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) {
|
function defendReflex(ctx) {
|
||||||
const s = ctx.snapshot;
|
const s = ctx.snapshot;
|
||||||
if (!s.connected) return { action: "noop" };
|
if (!s.connected) return { action: "noop" };
|
||||||
if (!s.closestHostile) 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;
|
const lowHp = (s.health ?? 20) <= 8;
|
||||||
|
|
||||||
// Three regimes — tightened to avoid the "82 distant hostiles → constant
|
// Three regimes — tightened to avoid the "82 distant hostiles → constant
|
||||||
// flee" pathology observed at this spawn:
|
// 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
|
// - within 8m (and visibly hostile to us): flee
|
||||||
// - low-HP fallback: flee anything within 12m
|
// - low-HP fallback: flee anything within 12m
|
||||||
// Anything beyond 8m with full HP is ignored regardless of how many
|
// Anything beyond 8m with full HP is ignored regardless of how many
|
||||||
// hostiles the perceive snapshot enumerates.
|
// hostiles the perceive snapshot enumerates.
|
||||||
if (dist <= 4) {
|
if (dist <= 4) {
|
||||||
|
if (shouldRetreatFromStuckAttack(ctx, hostile.name)) {
|
||||||
|
ctx.defendAttackStuck = null;
|
||||||
|
return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true });
|
||||||
|
}
|
||||||
ctx.dispatch(
|
ctx.dispatch(
|
||||||
() => attackNearest(ctx.bot, s.closestHostile.name),
|
() => attackNearestUntilClear(ctx.bot, hostile.name, {
|
||||||
`attack ${s.closestHostile.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);
|
const shouldFlee = (dist <= 8) || (lowHp && dist <= 12);
|
||||||
if (!shouldFlee) return { action: "noop" };
|
if (!shouldFlee) return { action: "noop" };
|
||||||
|
|
||||||
// Cooldown — if we just fled from this same mob type and it didn't work
|
// 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.
|
// (timed out), don't immediately re-fire. Let other reflexes run.
|
||||||
const lastFlee = ctx.lastFleeAttempt;
|
return dispatchDefendFlee(ctx, hostile, dist);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- eat -------------------------------------------------------------------
|
// ---- eat -------------------------------------------------------------------
|
||||||
|
|||||||
+74
-1
@@ -7,6 +7,36 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
import { runTick } from "./reflex.js";
|
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({
|
function makeCtx({
|
||||||
snapshot,
|
snapshot,
|
||||||
busy = false,
|
busy = false,
|
||||||
@@ -27,7 +57,7 @@ function makeCtx({
|
|||||||
lastCurriculumAt,
|
lastCurriculumAt,
|
||||||
skillBackoff,
|
skillBackoff,
|
||||||
dispatch(fn, label, opts = {}) {
|
dispatch(fn, label, opts = {}) {
|
||||||
dispatches.push({ label, opts });
|
dispatches.push({ fn, label, opts });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return { ctx, dispatches };
|
return { ctx, dispatches };
|
||||||
@@ -66,6 +96,49 @@ test("defend wins over curriculum when hostile in melee", () => {
|
|||||||
assert.match(dispatches[0].label, /attack zombie/);
|
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", () => {
|
test("eat wins over curriculum when food low and bot has food in inventory", () => {
|
||||||
const { ctx, dispatches } = makeCtx({
|
const { ctx, dispatches } = makeCtx({
|
||||||
snapshot: {
|
snapshot: {
|
||||||
|
|||||||
Reference in New Issue
Block a user