feat(runtime): autonomous reflex — chop + wander + proactive movement #8
+147
-1
@@ -29,6 +29,24 @@ function ensurePathfinder(bot) {
|
|||||||
pluginLoaded.add(bot);
|
pluginLoaded.add(bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each action that uses pathfinder should set its own Movements profile
|
||||||
|
// before calling goto — otherwise it inherits whatever the previous caller
|
||||||
|
// left set, which has caused live regressions (e.g. flee setting canDig=false,
|
||||||
|
// then a later chop inheriting the same restrictive profile).
|
||||||
|
function setMovementsForGather(bot) {
|
||||||
|
const m = new Movements(bot);
|
||||||
|
m.canDig = true; // chopping is the entire point
|
||||||
|
m.allow1by1towers = false;
|
||||||
|
bot.pathfinder.setMovements(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMovementsForTravel(bot) {
|
||||||
|
const m = new Movements(bot);
|
||||||
|
m.canDig = true; // dig through leaves rather than get stuck
|
||||||
|
m.allow1by1towers = false;
|
||||||
|
bot.pathfinder.setMovements(m);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- combat ----------------------------------------------------------------
|
// ---- combat ----------------------------------------------------------------
|
||||||
|
|
||||||
const MELEE_WEAPONS = [
|
const MELEE_WEAPONS = [
|
||||||
@@ -96,8 +114,12 @@ export async function fleeFrom(bot, fromEntity, distance = 16) {
|
|||||||
const ty = Math.round(here.y);
|
const ty = Math.round(here.y);
|
||||||
info("action", `flee: from=${fromEntity?.name ?? "?"} → ${tx},${ty},${tz}`);
|
info("action", `flee: from=${fromEntity?.name ?? "?"} → ${tx},${ty},${tz}`);
|
||||||
|
|
||||||
|
// canDig:true here is deliberate — without it the bot gets permanently
|
||||||
|
// stuck in dense tree canopy (observed live: bot perched at Y=85 inside
|
||||||
|
// dark-oak leaves, every flee timed out for hours). We accept the risk of
|
||||||
|
// chopping through scenery while panicking; it's how a player would react.
|
||||||
const movements = new Movements(bot);
|
const movements = new Movements(bot);
|
||||||
movements.canDig = false;
|
movements.canDig = true;
|
||||||
movements.allow1by1towers = false;
|
movements.allow1by1towers = false;
|
||||||
bot.pathfinder.setMovements(movements);
|
bot.pathfinder.setMovements(movements);
|
||||||
|
|
||||||
@@ -194,6 +216,7 @@ export async function sleepInBed(bot) {
|
|||||||
if (bedBlock) {
|
if (bedBlock) {
|
||||||
info("action", `sleep: nearest bed at ${bedBlock.position.x},${bedBlock.position.y},${bedBlock.position.z}`);
|
info("action", `sleep: nearest bed at ${bedBlock.position.x},${bedBlock.position.y},${bedBlock.position.z}`);
|
||||||
ensurePathfinder(bot);
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForTravel(bot);
|
||||||
try {
|
try {
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
bot.pathfinder.goto(new goals.GoalNear(bedBlock.position.x, bedBlock.position.y, bedBlock.position.z, 2)),
|
bot.pathfinder.goto(new goals.GoalNear(bedBlock.position.x, bedBlock.position.y, bedBlock.position.z, 2)),
|
||||||
@@ -214,10 +237,133 @@ export async function sleepInBed(bot) {
|
|||||||
return { ok: false, detail: "no bed in range and won't place blindly" };
|
return { ok: false, detail: "no bed in range and won't place blindly" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- gathering -------------------------------------------------------------
|
||||||
|
|
||||||
|
const LOG_NAMES = [
|
||||||
|
"oak_log",
|
||||||
|
"dark_oak_log",
|
||||||
|
"spruce_log",
|
||||||
|
"birch_log",
|
||||||
|
"jungle_log",
|
||||||
|
"acacia_log",
|
||||||
|
"mangrove_log",
|
||||||
|
"cherry_log",
|
||||||
|
"pale_oak_log",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AXE_NAMES = [
|
||||||
|
"netherite_axe",
|
||||||
|
"diamond_axe",
|
||||||
|
"iron_axe",
|
||||||
|
"stone_axe",
|
||||||
|
"golden_axe",
|
||||||
|
"wooden_axe",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function equipBestAxe(bot) {
|
||||||
|
for (const name of AXE_NAMES) {
|
||||||
|
const item = bot.inventory.items().find((i) => i.name === name);
|
||||||
|
if (item) {
|
||||||
|
try {
|
||||||
|
await bot.equip(item, "hand");
|
||||||
|
return name;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-bot blacklist of (x,y,z) positions that pathfinder failed to reach
|
||||||
|
// recently. Cleared after BLACKLIST_TTL_MS so the bot can retry if the world
|
||||||
|
// has changed (a player chopped a path, the tree fell to natural decay, etc).
|
||||||
|
const chopBlacklist = new WeakMap(); // bot → Map<"x,y,z", expireMs>
|
||||||
|
const BLACKLIST_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
function getBlacklist(bot) {
|
||||||
|
let m = chopBlacklist.get(bot);
|
||||||
|
if (!m) {
|
||||||
|
m = new Map();
|
||||||
|
chopBlacklist.set(bot, m);
|
||||||
|
}
|
||||||
|
// Sweep expired entries on each lookup — small, cheap.
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, exp] of m) if (exp < now) m.delete(k);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chopNearestTree(bot) {
|
||||||
|
const blacklist = getBlacklist(bot);
|
||||||
|
// findBlock invokes the matcher for blocks that pass the maxDistance
|
||||||
|
// pre-filter; in dense areas some have a synthetic shape with no
|
||||||
|
// `.position`. Guard or we crash before we even start pathfinding.
|
||||||
|
const log = bot.findBlock({
|
||||||
|
matching: (b) => {
|
||||||
|
if (!b || !b.position || !LOG_NAMES.includes(b.name)) return false;
|
||||||
|
const key = `${b.position.x},${b.position.y},${b.position.z}`;
|
||||||
|
return !blacklist.has(key);
|
||||||
|
},
|
||||||
|
maxDistance: 32,
|
||||||
|
});
|
||||||
|
if (!log) return { ok: false, detail: "no reachable log within 32 blocks" };
|
||||||
|
|
||||||
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForGather(bot);
|
||||||
|
const axe = await equipBestAxe(bot);
|
||||||
|
info(
|
||||||
|
"action",
|
||||||
|
`chop: ${log.name} at ${log.position.x},${log.position.y},${log.position.z} (tool=${axe ?? "fists"})`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
bot.pathfinder.goto(new goals.GoalGetToBlock(log.position.x, log.position.y, log.position.z)),
|
||||||
|
45_000,
|
||||||
|
"pathToLog",
|
||||||
|
);
|
||||||
|
await withTimeout(bot.dig(log), 30_000, "digLog");
|
||||||
|
// Walk over the dropped item briefly (collectblock plugin would do this
|
||||||
|
// for us, but a simple sleep-then-resume is enough for now).
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
return { ok: true, detail: { logType: log.name, at: log.position } };
|
||||||
|
} catch (e) {
|
||||||
|
warn("action", `chop failed: ${e.message}`);
|
||||||
|
const key = `${log.position.x},${log.position.y},${log.position.z}`;
|
||||||
|
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
|
||||||
|
return { ok: false, detail: e.message, blacklisted: log.position };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- exploration -----------------------------------------------------------
|
||||||
|
|
||||||
|
export async function wander(bot, radius = 12) {
|
||||||
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForTravel(bot);
|
||||||
|
// Pick a random offset that's at least 6 blocks away — small enough to be
|
||||||
|
// safe, large enough to not be a no-op when we're stuck on the same block.
|
||||||
|
const here = bot.entity.position;
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const dist = 6 + Math.random() * (radius - 6);
|
||||||
|
const tx = Math.round(here.x + Math.cos(angle) * dist);
|
||||||
|
const tz = Math.round(here.z + Math.sin(angle) * dist);
|
||||||
|
const ty = Math.round(here.y);
|
||||||
|
info("action", `wander: → ${tx},${ty},${tz} (dist=${dist.toFixed(1)})`);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
|
||||||
|
30_000,
|
||||||
|
`wander(${tx},${tz})`,
|
||||||
|
);
|
||||||
|
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
|
||||||
|
} catch (e) {
|
||||||
|
warn("action", `wander failed: ${e.message}`);
|
||||||
|
return { ok: false, detail: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- navigation (for operator come/follow) --------------------------------
|
// ---- navigation (for operator come/follow) --------------------------------
|
||||||
|
|
||||||
export async function goTo(bot, x, y, z, minDistance = 2) {
|
export async function goTo(bot, x, y, z, minDistance = 2) {
|
||||||
ensurePathfinder(bot);
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForTravel(bot);
|
||||||
info("action", `goTo: ${x},${y},${z} (min ${minDistance})`);
|
info("action", `goTo: ${x},${y},${z} (min ${minDistance})`);
|
||||||
try {
|
try {
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
|
|||||||
@@ -233,6 +233,33 @@ function maybeFileProposal(label) {
|
|||||||
appendDiary(`proposal filed: ${filename} (${summary})`);
|
appendDiary(`proposal filed: ${filename} (${summary})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- player chat replies (non-operator) ------------------------------------
|
||||||
|
|
||||||
|
// Rate-limited light replies to ordinary players. Spam guard is intentional:
|
||||||
|
// the chat rate limit catches outbound flooding; this one prevents replying
|
||||||
|
// to every greeting in a busy room.
|
||||||
|
let lastPlayerReplyAt = 0;
|
||||||
|
const PLAYER_REPLY_COOLDOWN_MS = 30_000;
|
||||||
|
|
||||||
|
const GREETING_RE = /\b(hi|hello|hey|yo|sup|hola|привет|здаров|здарова|здорова|здравствуй|здравствуйте|салам)\b/i;
|
||||||
|
|
||||||
|
function maybeReplyToPlayer(username, text) {
|
||||||
|
if (!bot) return;
|
||||||
|
const lower = text.trim().toLowerCase();
|
||||||
|
const botname = bot.username.toLowerCase();
|
||||||
|
const addressed = lower.includes(botname);
|
||||||
|
if (!addressed && !GREETING_RE.test(lower)) return;
|
||||||
|
const since = Date.now() - lastPlayerReplyAt;
|
||||||
|
if (since < PLAYER_REPLY_COOLDOWN_MS) return;
|
||||||
|
lastPlayerReplyAt = Date.now();
|
||||||
|
|
||||||
|
// Pick a small canned response. Non-operator chat is dialog-only by design
|
||||||
|
// — we don't act on player verbs, we just acknowledge presence.
|
||||||
|
const replies = ["yo", "hey", "hi", "привет"];
|
||||||
|
const reply = replies[Math.floor(Math.random() * replies.length)];
|
||||||
|
botChat(`${username}: ${reply}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- operator chat commands ------------------------------------------------
|
// ---- operator chat commands ------------------------------------------------
|
||||||
|
|
||||||
function isOperator(username) {
|
function isOperator(username) {
|
||||||
@@ -333,6 +360,12 @@ function connect() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn("operator", `handler threw: ${e.message}`);
|
warn("operator", `handler threw: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
maybeReplyToPlayer(username, message);
|
||||||
|
} catch (e) {
|
||||||
|
warn("chat", `player reply handler threw: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,6 +449,10 @@ function tick() {
|
|||||||
if (bot && bot.entity) {
|
if (bot && bot.entity) {
|
||||||
lastSnapshot = buildSnapshot(bot);
|
lastSnapshot = buildSnapshot(bot);
|
||||||
lastSnapshot.pendingProposals = listProposals().length;
|
lastSnapshot.pendingProposals = listProposals().length;
|
||||||
|
lastSnapshot.lastReflex = reflexCtx.lastReflex ?? null;
|
||||||
|
lastSnapshot.busy = reflexCtx.busy
|
||||||
|
? { label: reflexCtx.currentActionLabel ?? "?" }
|
||||||
|
: null;
|
||||||
reflexCtx.snapshot = lastSnapshot;
|
reflexCtx.snapshot = lastSnapshot;
|
||||||
if (!reflexPaused) {
|
if (!reflexPaused) {
|
||||||
const result = runTick(reflexCtx);
|
const result = runTick(reflexCtx);
|
||||||
|
|||||||
+86
-15
@@ -7,7 +7,7 @@
|
|||||||
// escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS).
|
// escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS).
|
||||||
|
|
||||||
import { info, warn } from "./log.js";
|
import { info, warn } from "./log.js";
|
||||||
import { attackNearest, fleeFrom, eatBestFood, sleepInBed, goTo } from "./actions.js";
|
import { attackNearest, fleeFrom, eatBestFood, sleepInBed, goTo, chopNearestTree, wander } from "./actions.js";
|
||||||
|
|
||||||
const REFLEX_LOG = "reflex";
|
const REFLEX_LOG = "reflex";
|
||||||
|
|
||||||
@@ -54,9 +54,13 @@ function defendReflex(ctx) {
|
|||||||
const dist = s.closestHostile.distance;
|
const dist = s.closestHostile.distance;
|
||||||
const lowHp = (s.health ?? 20) <= 8;
|
const lowHp = (s.health ?? 20) <= 8;
|
||||||
|
|
||||||
// Two regimes:
|
// Three regimes — tightened to avoid the "82 distant hostiles → constant
|
||||||
|
// flee" pathology observed at this spawn:
|
||||||
// - within 4m: melee attack
|
// - within 4m: melee attack
|
||||||
// - 4-12m and either low HP or many hostiles: flee
|
// - 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 (dist <= 4) {
|
||||||
ctx.dispatch(
|
ctx.dispatch(
|
||||||
() => attackNearest(ctx.bot, s.closestHostile.name),
|
() => attackNearest(ctx.bot, s.closestHostile.name),
|
||||||
@@ -64,17 +68,28 @@ function defendReflex(ctx) {
|
|||||||
);
|
);
|
||||||
return { action: "dispatched", kind: "defend-attack", label: s.closestHostile.name };
|
return { action: "dispatched", kind: "defend-attack", label: s.closestHostile.name };
|
||||||
}
|
}
|
||||||
if (dist <= 12 && (lowHp || (s.hostileCount ?? 0) >= 3)) {
|
const shouldFlee = (dist <= 8) || (lowHp && dist <= 12);
|
||||||
const fromEntity = Object.values(ctx.bot.entities).find(
|
if (!shouldFlee) return { action: "noop" };
|
||||||
(e) => e.name === s.closestHostile.name && e.position && Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5,
|
|
||||||
);
|
// Cooldown — if we just fled from this same mob type and it didn't work
|
||||||
ctx.dispatch(
|
// (timed out), don't immediately re-fire. Let other reflexes run.
|
||||||
() => fleeFrom(ctx.bot, fromEntity, 16),
|
const lastFlee = ctx.lastFleeAttempt;
|
||||||
`flee from ${s.closestHostile.name}`,
|
if (lastFlee && lastFlee.name === s.closestHostile.name && Date.now() - lastFlee.ts < 60_000) {
|
||||||
);
|
return { action: "noop" };
|
||||||
return { action: "dispatched", kind: "defend-flee", label: s.closestHostile.name };
|
|
||||||
}
|
}
|
||||||
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 -------------------------------------------------------------------
|
||||||
@@ -107,9 +122,10 @@ function sleepReflex(ctx) {
|
|||||||
if (!s.connected) return { action: "noop" };
|
if (!s.connected) return { action: "noop" };
|
||||||
if (s.isDay) return { action: "noop" };
|
if (s.isDay) return { action: "noop" };
|
||||||
if (s.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe
|
if (s.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe
|
||||||
// Cooldown — don't retry sleep more than once per 30s if it failed.
|
// Longer cooldown after a failure — if there's no bed nearby, retrying
|
||||||
|
// every 30s blocks autonomous behaviour without ever succeeding.
|
||||||
const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0);
|
const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0);
|
||||||
if (since < 30_000) return { action: "noop" };
|
if (since < 5 * 60_000) return { action: "noop" };
|
||||||
|
|
||||||
ctx.lastSleepAttemptAt = Date.now();
|
ctx.lastSleepAttemptAt = Date.now();
|
||||||
ctx.dispatch(
|
ctx.dispatch(
|
||||||
@@ -125,6 +141,59 @@ function sleepReflex(ctx) {
|
|||||||
return { action: "dispatched", kind: "sleep", label: "night" };
|
return { action: "dispatched", kind: "sleep", label: "night" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- autonomous "live your best life" --------------------------------------
|
||||||
|
|
||||||
|
// Triggered when no reactive reflex (operator/defend/eat/sleep) wants to act.
|
||||||
|
// Picks ONE small proactive action and runs it. Cooldown so we don't fire on
|
||||||
|
// every 3s tick — actions take 15-45s themselves and we want some breathing
|
||||||
|
// room between them.
|
||||||
|
const AUTONOMOUS_COOLDOWN_MS = 10_000;
|
||||||
|
|
||||||
|
function autonomousReflex(ctx) {
|
||||||
|
const s = ctx.snapshot;
|
||||||
|
if (!s.connected) return { action: "noop" };
|
||||||
|
|
||||||
|
// Only suppress autonomous work at night when a hostile is in actual reach
|
||||||
|
// (within 16m). Distant mobs the perception layer happens to enumerate
|
||||||
|
// don't count — at this spawn there can be 80+ mobs visible but irrelevant
|
||||||
|
// to local action.
|
||||||
|
const nightClose = !s.isDay && s.closestHostile && s.closestHostile.distance <= 16;
|
||||||
|
if (nightClose) return { action: "noop" };
|
||||||
|
|
||||||
|
const since = Date.now() - (ctx.lastAutonomousAt ?? 0);
|
||||||
|
if (since < AUTONOMOUS_COOLDOWN_MS) return { action: "noop" };
|
||||||
|
ctx.lastAutonomousAt = Date.now();
|
||||||
|
|
||||||
|
// What to do: gather wood until we have a small stockpile, then wander a
|
||||||
|
// bit to find new chunks. If a recent chop attempt reported "no reachable
|
||||||
|
// log", switch to wander for the next 60s — chopping the same not-found
|
||||||
|
// position over and over is what the user observed live.
|
||||||
|
const inv = s.inventory ?? {};
|
||||||
|
const logCount = Object.entries(inv)
|
||||||
|
.filter(([name]) => name.endsWith("_log"))
|
||||||
|
.reduce((sum, [, n]) => sum + n, 0);
|
||||||
|
|
||||||
|
const noTreesRecently = ctx.noTreesUntil && Date.now() < ctx.noTreesUntil;
|
||||||
|
const wantChop = logCount < 16 && !noTreesRecently;
|
||||||
|
if (wantChop) {
|
||||||
|
ctx.dispatch(() => chopNearestTree(ctx.bot), "chop tree", {
|
||||||
|
onComplete: (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
info(REFLEX_LOG, `chopped ${res.detail?.logType ?? "log"}`);
|
||||||
|
ctx.noTreesUntil = 0; // success ⇒ trees exist around us
|
||||||
|
} else if (typeof res.detail === "string" && res.detail.includes("no reachable")) {
|
||||||
|
// No log within 32 blocks of the current position. Don't try
|
||||||
|
// again for 60s — wander first to find a new biome / chunk.
|
||||||
|
ctx.noTreesUntil = Date.now() + 60_000;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { action: "dispatched", kind: "autonomous-chop", label: "chop tree" };
|
||||||
|
}
|
||||||
|
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
|
||||||
|
return { action: "dispatched", kind: "autonomous-wander", label: "wander" };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- idle ------------------------------------------------------------------
|
// ---- idle ------------------------------------------------------------------
|
||||||
|
|
||||||
function idleReflex(ctx) {
|
function idleReflex(ctx) {
|
||||||
@@ -144,6 +213,7 @@ const REFLEXES = [
|
|||||||
{ name: "defend", fn: defendReflex },
|
{ name: "defend", fn: defendReflex },
|
||||||
{ name: "eat", fn: eatReflex },
|
{ name: "eat", fn: eatReflex },
|
||||||
{ name: "sleep", fn: sleepReflex },
|
{ name: "sleep", fn: sleepReflex },
|
||||||
|
{ name: "autonomous", fn: autonomousReflex },
|
||||||
{ name: "idle", fn: idleReflex },
|
{ name: "idle", fn: idleReflex },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -161,6 +231,7 @@ export function runTick(ctx) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!outcome || outcome.action === "noop") continue;
|
if (!outcome || outcome.action === "noop") continue;
|
||||||
|
ctx.lastReflex = { name: reflex.name, label: outcome.label ?? outcome.kind, ts: Date.now() };
|
||||||
return { reflex: reflex.name, ...outcome };
|
return { reflex: reflex.name, ...outcome };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+20
@@ -123,10 +123,30 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
|
|||||||
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
|
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
|
||||||
{snapshot.pendingProposals ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
|
{snapshot.pendingProposals ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{snapshot.busy ? (
|
||||||
|
<Text color="cyan">▸ busy: {snapshot.busy.label}</Text>
|
||||||
|
) : snapshot.lastReflex ? (
|
||||||
|
<Text dimColor>
|
||||||
|
last reflex: {snapshot.lastReflex.name}
|
||||||
|
{snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "}
|
||||||
|
{snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>no reflex action yet</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAge(tsMs: number): string {
|
||||||
|
const ageMs = Date.now() - tsMs;
|
||||||
|
if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`;
|
||||||
|
if (ageMs < 3600_000) return `${Math.floor(ageMs / 60_000)}m ago`;
|
||||||
|
return `${Math.floor(ageMs / 3600_000)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
function ProposalPanel({
|
function ProposalPanel({
|
||||||
proposal,
|
proposal,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
Reference in New Issue
Block a user