v0.3.0-rc.3: event-driven awareness + skill pre-emption
Adds a reactive layer on top of the polling reflex. The bot now
notices environmental shocks (forced moves, HP plunges, hostile
spawns) within ~100ms instead of waiting for the next DISPATCH tick,
and the in-flight skill is preempted so the next reflex cycle can
re-plan against the current world state.
This is the rc that wires the "rc.1 plumbing + rc.2 manifesto" into
a feedback loop:
- awareness fires preempt → dispatch aborts
- reflex tick re-evaluates → manifesto walks the ladder
- new dispatch picks the right skill for the new world state
Pieces:
- runtime/awareness/events.js (new) — bot.on listeners:
- move: single-tick Δposition ≥ 5 blocks → forced_move flag + preempt
- health: HP drop ≥ 2 → health_plunge flag + preempt
- entitySpawn: hostile mob within 12 blocks → hostile_added + preempt
- blockUpdate: nearby block change → env_changed flag (no preempt,
throttled 800ms; otherwise gather skills would self-preempt
every dig)
- runtime/skills/index.js — RUNNER_CODES.PREEMPTED + raceWithAbort()
wraps every execute() against ctx.abortSignal. Existing skills get
preemption for free; they don't have to check the signal manually.
- runtime/bot.js:
- dispatchAction creates a fresh AbortController per dispatch and
stores it on reflexCtx.currentAbort
- attachAwareness fires controller.abort() when something disrupts
the active skill; runSkill returns code: "preempted" and the
reflex moves on
- reflexCtx.lastPreempt records the most recent shock
Tests: 332 green (was 315 on rc.2, +17 new):
- runtime/awareness/events.test.js — 12 tests (each event type +
thresholds + throttling + passive-mob filter)
- runtime/skills/contract.test.js — 3 abortSignal tests
(mid-flight, pre-armed, clean signal)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { attachAwareness, createAwarenessState, __testing } from "./events.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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeBot(pos = vec(0, 64, 0), hp = 20) {
|
||||
const bot = new EventEmitter();
|
||||
bot.entity = { position: pos };
|
||||
bot.health = hp;
|
||||
return bot;
|
||||
}
|
||||
|
||||
test("attachAwareness: throws when bot has no on()", () => {
|
||||
assert.throws(() => attachAwareness({}), /bot\.on missing/);
|
||||
});
|
||||
|
||||
test("createAwarenessState: starts with null flags, consume resets", () => {
|
||||
const s = createAwarenessState();
|
||||
assert.equal(s.flags.forcedMove, null);
|
||||
s.flags.forcedMove = { at: 1, from: {}, to: {}, distance: 7 };
|
||||
assert.equal(s.hasPreempting(), true);
|
||||
const out = s.consume();
|
||||
assert.equal(out.forcedMove.distance, 7);
|
||||
assert.equal(s.flags.forcedMove, null);
|
||||
});
|
||||
|
||||
test("forcedMove: jump > threshold flags + preempts", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot(vec(0, 64, 0));
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
// move within threshold — no flag
|
||||
bot.entity.position = vec(1, 64, 0);
|
||||
bot.emit("move");
|
||||
assert.equal(state.flags.forcedMove, null);
|
||||
assert.equal(calls.length, 0);
|
||||
// teleport / fall — far jump
|
||||
bot.entity.position = vec(20, 64, 0);
|
||||
bot.emit("move");
|
||||
assert.ok(state.flags.forcedMove, "forcedMove flag set");
|
||||
assert.ok(state.flags.forcedMove.distance >= 18);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "forced_move");
|
||||
});
|
||||
|
||||
test("healthPlunge: HP drop ≥ delta flags + preempts", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot(vec(0, 64, 0), 20);
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
// trivial HP change does NOT flag
|
||||
bot.health = 19;
|
||||
bot.emit("health");
|
||||
assert.equal(state.flags.healthPlunge, null);
|
||||
// big drop
|
||||
bot.health = 12;
|
||||
bot.emit("health");
|
||||
assert.ok(state.flags.healthPlunge);
|
||||
assert.equal(state.flags.healthPlunge.from, 19);
|
||||
assert.equal(state.flags.healthPlunge.to, 12);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "health_plunge");
|
||||
});
|
||||
|
||||
test("hostileAdded: zombie nearby triggers preempt", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
const zombie = { name: "zombie", position: vec(2, 64, 0) };
|
||||
bot.emit("entitySpawn", zombie);
|
||||
assert.ok(state.flags.hostileAdded);
|
||||
assert.equal(state.flags.hostileAdded.name, "zombie");
|
||||
assert.equal(state.flags.hostileAdded.distance, 2);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "hostile_added");
|
||||
});
|
||||
|
||||
test("hostileAdded: far hostile ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const far = { name: "creeper", position: vec(50, 64, 0) };
|
||||
bot.emit("entitySpawn", far);
|
||||
assert.equal(state.flags.hostileAdded, null);
|
||||
});
|
||||
|
||||
test("hostileAdded: passive mob ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const cow = { name: "cow", position: vec(2, 64, 0) };
|
||||
bot.emit("entitySpawn", cow);
|
||||
assert.equal(state.flags.hostileAdded, null);
|
||||
});
|
||||
|
||||
test("envChanged: nearby blockUpdate flags but does NOT preempt", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
const newBlock = { name: "cobblestone", position: vec(1, 64, 0) };
|
||||
bot.emit("blockUpdate", null, newBlock);
|
||||
assert.ok(state.flags.envChanged);
|
||||
assert.equal(state.flags.envChanged.blockName, "cobblestone");
|
||||
assert.equal(calls.length, 0, "env changes are observational, not preempting");
|
||||
});
|
||||
|
||||
test("envChanged: throttled", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const near = { name: "stone", position: vec(2, 64, 0) };
|
||||
bot.emit("blockUpdate", null, near);
|
||||
const firstAt = state.flags.envChanged.at;
|
||||
bot.emit("blockUpdate", null, near);
|
||||
// second one within throttle window keeps the first timestamp
|
||||
assert.equal(state.flags.envChanged.at, firstAt);
|
||||
});
|
||||
|
||||
test("envChanged: far blockUpdate ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const far = { name: "stone", position: vec(20, 64, 0) };
|
||||
bot.emit("blockUpdate", null, far);
|
||||
assert.equal(state.flags.envChanged, null);
|
||||
});
|
||||
|
||||
test("hasPreempting: true only for forcedMove/healthPlunge/hostileAdded", () => {
|
||||
const s = createAwarenessState();
|
||||
assert.equal(s.hasPreempting(), false);
|
||||
s.flags.envChanged = { at: 1, blockName: "stone", distance: 2 };
|
||||
assert.equal(s.hasPreempting(), false, "envChanged alone does not preempt");
|
||||
s.flags.hostileAdded = { at: 1, name: "creeper", distance: 5 };
|
||||
assert.equal(s.hasPreempting(), true);
|
||||
});
|
||||
|
||||
test("thresholds: constants are sane", () => {
|
||||
assert.ok(__testing.FORCED_MOVE_BLOCKS >= 3 && __testing.FORCED_MOVE_BLOCKS <= 10);
|
||||
assert.ok(__testing.HEALTH_PLUNGE_DELTA >= 1 && __testing.HEALTH_PLUNGE_DELTA <= 5);
|
||||
assert.ok(__testing.HOSTILE_CLOSE_BLOCKS >= 8);
|
||||
});
|
||||
Reference in New Issue
Block a user