v0.4.0 vNext — closed-loop world model + settlement contract (#29)
* feat(v0.4.0): vNext — closed-loop world model + settlement contract
Implements the vNext architecture from the research doc: demote the noisy
multi-rail planner in favour of a closed loop (world truth → invariant check)
plus a single utility-driven goal authority.
L1 services (fix no_drop / silent pathfinder hang first):
- InventoryLedger: diff-based "did I actually get it" verifier; acquire-food
now confirms via ledger.gainedSince instead of the unreliable count/event.
- MotionService.gotoSafe: wall-clock timeout + progress watchdog +
path_update(noPath/timeout) → structured {reached|stuck|timeout|nopath}.
L3 plan — unify the three competing rails (curriculum/manifesto/storyline):
- Settlement Contract: ordered M0–M9 milestones, each invariant-checked
against an authoritative world view (early steps delegate to the proven
curriculum; late game adds farming).
- InvariantChecker + predicate library; GoalManager selects the lowest unmet
milestone via utility argmax (food-urgency preempts, DEPS-style).
- Wired into the scheduler: bot.js precomputes snapshot.contract; reflex.js
consumes it in place of the storyline rail. Manifesto L0 still preempts.
Eval + robustness:
- Village Score (single 0..1 metric) on the snapshot + TUI "build" line.
- survive.dig-in skill + dusk_dig_in mode (exposed at night, no bed → cover).
- approach_block helper (GoalNear + lookAt, avoids GoalLookAtBlock #341).
+28 new tests (450 total green). LLM remains entirely off the tick path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(v0.4.0): finish vNext plan — anti-loop, skill-graph, worldDelta diff, flee→motion
Completes the remaining v0.4.0 plan items and one fix motivated by a live
in-game observation (flee hanging 30s against a persistent zombie).
- flee → MotionService.gotoSafe: structured {stuck|timeout|nopath} in ~4s with
a blind-retreat fallback, instead of the observed 30s pathfinder hang + 3
watchdog replans. Movements setup guarded so it is unit-testable.
- QW5 anti-loop (runtime/anti-loop.js): same skill failing >=3x in 5min →
30min blacklist (reflex shouldSkip) + one-shot improvement_request
(bot.js drainFired -> writeProposal).
- 4.1 closed-loop worldDelta: runSkill snapshots inventory before execute and
attaches the real delta (_invObserved) to every successful result; opt-in
skill.expectGain asserts the claimed gain or returns world_unchanged.
- 3.6 skill-graph (Plan4MC): declarative requires/produces for ~20 skills;
prerequisitesMet/canRun/runnableFrontier; GoalManager annotates suggestions
with blockedBy when prereqs are unmet.
+22 tests (472 total green). Live smoke confirmed dig-in works and no new
errors; flee loop is what this commit's flee migration addresses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// Invariant predicate library (L3).
|
||||
//
|
||||
// The research's core diagnosis: the bot picks plausible tasks but never
|
||||
// asserts whether the world actually moved toward a settlement, so "progress"
|
||||
// is replaced by noise. The fix is a typed contract whose milestones each
|
||||
// carry INVARIANTS — boolean predicates over an authoritative world view — so
|
||||
// "are we done with this milestone" is a fact about the world, not a guess.
|
||||
//
|
||||
// A predicate is a plain object: { id, describe, met(world) -> boolean }.
|
||||
// `world` is the normalised view produced by worldFromSnapshot(): it exposes
|
||||
// inventory (name->count), locations, health/food, daylight and an optional
|
||||
// InventoryLedger. Predicates are PURE — they never touch the bot or disk.
|
||||
|
||||
// ---- world view ------------------------------------------------------------
|
||||
|
||||
export function worldFromSnapshot(snapshot, extra = {}) {
|
||||
const s = snapshot ?? {};
|
||||
return {
|
||||
snapshot: s,
|
||||
inventory: s.inventory ?? {},
|
||||
locations: s.locations ?? {},
|
||||
health: s.health ?? 20,
|
||||
food: s.food ?? 20,
|
||||
hasFood: !!s.hasFood,
|
||||
isDay: s.isDay !== false,
|
||||
position: s.position ?? null,
|
||||
nearbyBlocks: s.nearbyBlocks ?? {},
|
||||
nearbyEntities: s.nearbyEntities ?? {},
|
||||
closestHostile: s.closestHostile ?? null,
|
||||
ledger: extra.ledger ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- inventory helpers (shared with the contract) --------------------------
|
||||
|
||||
export function totalMatching(inv, matcher) {
|
||||
const f =
|
||||
typeof matcher === "function"
|
||||
? matcher
|
||||
: matcher instanceof RegExp
|
||||
? (k) => matcher.test(k)
|
||||
: (k) => k === matcher;
|
||||
let sum = 0;
|
||||
for (const [k, n] of Object.entries(inv ?? {})) if (f(k)) sum += n;
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function totalLogs(inv) {
|
||||
return totalMatching(inv, (k) => k.endsWith("_log") || k.endsWith("_stem"));
|
||||
}
|
||||
export function totalPlanks(inv) {
|
||||
return totalMatching(inv, (k) => k.endsWith("_planks"));
|
||||
}
|
||||
export function totalCobble(inv) {
|
||||
return (inv?.cobblestone ?? 0) + (inv?.cobbled_deepslate ?? 0);
|
||||
}
|
||||
export function totalWool(inv) {
|
||||
return totalMatching(inv, (k) => k.endsWith("_wool"));
|
||||
}
|
||||
export function maxSingleColourWool(inv) {
|
||||
let best = 0;
|
||||
for (const [k, n] of Object.entries(inv ?? {})) {
|
||||
if (k.endsWith("_wool") && n > best) best = n;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export function hasAnyBed(inv) {
|
||||
return totalMatching(inv, (k) => k.endsWith("_bed")) > 0;
|
||||
}
|
||||
export function has(inv, name, n = 1) {
|
||||
return (inv?.[name] ?? 0) >= n;
|
||||
}
|
||||
|
||||
export const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
|
||||
export const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"];
|
||||
export const COOKED_FOODS = [
|
||||
"bread", "cooked_beef", "cooked_chicken", "cooked_porkchop",
|
||||
"cooked_mutton", "cooked_rabbit", "baked_potato", "apple",
|
||||
"carrot", "potato", "cooked_cod", "cooked_salmon",
|
||||
];
|
||||
|
||||
// ---- predicate builders ----------------------------------------------------
|
||||
|
||||
export function alive() {
|
||||
return { id: "alive", describe: "health > 0", met: (w) => w.health > 0 };
|
||||
}
|
||||
|
||||
export function healthAtLeast(n) {
|
||||
return { id: `health>=${n}`, describe: `health at least ${n}`, met: (w) => w.health >= n };
|
||||
}
|
||||
|
||||
export function foodAtLeast(n) {
|
||||
return { id: `food>=${n}`, describe: `hunger at least ${n}`, met: (w) => w.food >= n };
|
||||
}
|
||||
|
||||
// "Food security": carrying edible food, or well-fed, or holding a cooked
|
||||
// staple. We cannot introspect chest contents from the snapshot, so this is
|
||||
// the observable proxy for research M1's `food_stock>=5_in_chest`.
|
||||
export function foodSecure() {
|
||||
return {
|
||||
id: "food_secure",
|
||||
describe: "carrying edible food or well-fed",
|
||||
met: (w) => w.hasFood || w.food >= 18 || COOKED_FOODS.some((n) => has(w.inventory, n)),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAllItems(names) {
|
||||
return {
|
||||
id: `has_all:${names.join(",")}`,
|
||||
describe: `carrying all of ${names.join(", ")}`,
|
||||
met: (w) => names.every((n) => has(w.inventory, n)),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasItem(matcher, n = 1, label = null) {
|
||||
return {
|
||||
id: `has:${label ?? String(matcher)}>=${n}`,
|
||||
describe: `at least ${n}× ${label ?? String(matcher)}`,
|
||||
met: (w) => totalMatching(w.inventory, matcher) >= n,
|
||||
};
|
||||
}
|
||||
|
||||
export function woodenTier() {
|
||||
return {
|
||||
id: "wooden_tier",
|
||||
describe: "wooden axe + pickaxe + sword",
|
||||
met: (w) => WOODEN_TOOLS.every((n) => has(w.inventory, n)),
|
||||
};
|
||||
}
|
||||
|
||||
export function stoneTier() {
|
||||
return {
|
||||
id: "stone_tier",
|
||||
describe: "stone axe + pickaxe + sword + furnace",
|
||||
met: (w) => STONE_TOOLS.every((n) => has(w.inventory, n)) && has(w.inventory, "furnace"),
|
||||
};
|
||||
}
|
||||
|
||||
export function bedSecured() {
|
||||
return {
|
||||
id: "bed",
|
||||
describe: "a bed on hand or a placed bed location",
|
||||
met: (w) => hasAnyBed(w.inventory) || !!w.locations.bed,
|
||||
};
|
||||
}
|
||||
|
||||
// A named location exists in locations.json (set by the skill that builds it:
|
||||
// village.choose-base → base, build-shelter → shelter, place-chest → chest).
|
||||
export function locationExists(kind) {
|
||||
return {
|
||||
id: `loc:${kind}`,
|
||||
describe: `a known ${kind} location`,
|
||||
met: (w) => !!w.locations?.[kind],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- checker ---------------------------------------------------------------
|
||||
|
||||
// Evaluate every invariant of a milestone against the world. Returns
|
||||
// { met, unmet: [ids], evidence: { [id]: bool } } so the GoalManager can pick
|
||||
// the lowest unmet milestone and the TUI can show *which* invariant is open.
|
||||
export function checkInvariants(milestone, world) {
|
||||
const invs = milestone?.invariants ?? [];
|
||||
const evidence = {};
|
||||
const unmet = [];
|
||||
for (const inv of invs) {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = !!inv.met(world);
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
evidence[inv.id] = ok;
|
||||
if (!ok) unmet.push(inv.id);
|
||||
}
|
||||
return { met: unmet.length === 0, unmet, evidence };
|
||||
}
|
||||
Reference in New Issue
Block a user