diff --git a/runtime/reflex.js b/runtime/reflex.js index e386615..b8c4c39 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -540,16 +540,29 @@ function curriculumReflex(ctx) { return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - // Pick what to dispatch. Order: manifesto > storyline > curriculum. - // Manifesto is highest because L0 alive emergencies (lava, low-HP + - // hostile) must override any narrative aspiration. Storyline beats - // curriculum because it expresses a concrete operational subgoal, - // not just "next milestone". - let skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; - let skillSource = manifestoSkillId - ? `manifesto:${activeNeed.need.id}` - : storySkillId ? `storyline:${storyStep.step.id}` - : "curriculum"; + // Pick what to dispatch. Order: + // 1. manifesto L0 (alive emergencies: low HP near hostile, lava + // under foot, food=0) — absolute priority; do NOT let + // storyline overrule a "you're dying" signal. + // 2. storyline — concrete narrative subgoal ("collect 8 logs", + // "craft wooden pickaxe"). Beats manifesto L1+ because the + // ladder needs operational direction, not just "you need food + // → dispatch acquire-food forever". + // 3. manifesto L1+ — fallback when storyline has no concrete + // pursue (e.g. armor levels with pursue=null). + // 4. curriculum plan — legacy fallback. + const manifestoEmergency = activeNeed?.need?.level === 0; + let skillId, skillSource; + if (manifestoEmergency) { + skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; + skillSource = `manifesto:${activeNeed.need.id}`; + } else if (storySkillId) { + skillId = storySkillId; + skillSource = `storyline:${storyStep.step.id}`; + } else { + skillId = manifestoSkillId ?? plan.skillId; + skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum"; + } // v0.3.0 fast-advisor: if a fresh recommendation is sitting on ctx // (the result of a previous tick's async advise() call), use it. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 2fb8838..321cc65 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -242,9 +242,10 @@ test("curriculum dispatches suggested skill by id", () => { assert.ok(typeof dispatches[0].opts.onComplete === "function"); }); -test("manifesto: hungry bot with no food drives survive.acquire-food (overrides curriculum plan)", () => { +test("manifesto: hungry bot with no food drives survive.acquire-food (manifesto fallback when storyline disabled)", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, + disableStoryline: true, snapshot: { connected: true, health: 20, @@ -264,6 +265,62 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (overrides assert.equal(ctx.activeNeed?.need?.id, "food"); }); +test("L0 manifesto emergency: defend/modes layer catches the threat BEFORE curriculum", () => { + // HP=4 + creeper@3m fires `modes` (self_preservation) or defendReflex + // before curriculum even runs — which is the right outcome: alive + // emergencies don't reach the storyline/manifesto branch at all. + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + bot: { entities: { z1: { name: "creeper", height: 1.7, position: { x: 3, y: 64, z: 0, distanceTo: () => 3 } } }, entity: { position: { x: 0, y: 64, z: 0 } } }, + snapshot: { + connected: true, + health: 4, + food: 12, + hasFood: false, + inventory: { oak_log: 10 }, + equipment: {}, + nearbyBlocks: { logs: 4 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + closestHostile: { name: "creeper", distance: 3 }, + threats: [{ name: "creeper", distance: 3, position: { x: 3, y: 64, z: 0 } }], + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.notEqual(out?.kind, "curriculum-skill", "an upstream reflex caught the emergency before curriculum"); +}); + +test("storyline beats manifesto L1+ when both have suggestions", () => { + // Snapshot: well-fed (food=20 + 8 bread → manifesto L1 satisfied, L2 + // tools_wood unmet) AND storyline orient_self complete (HP=20, blocks + // visible). Storyline should drive a wood-tier crafting step, not + // manifesto's gather.logs (which would also be valid but less concrete). + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + snapshot: { + connected: true, + health: 20, + food: 20, + hasFood: true, + inventory: { bread: 8, oak_log: 10 }, // logs done, no planks + equipment: {}, + nearbyBlocks: { logs: 3 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + _sessionMs: 60_000, + curriculum: { plan: { skillId: "explore.far" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + // Storyline step crafting_basics suggests craft.planks (10 logs, no planks yet). + assert.equal(dispatches[0].label, "craft.planks"); + assert.equal(ctx.storyStep?.step?.id, "crafting_basics"); +}); + test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false,