chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements)
Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this state. Subsequent commits in this branch layer the knowledge base, post-mortem coach, and persona narration on top. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ pi /login # OAuth flow — ChatGPT Pro / Claude Max
|
|||||||
# Terminal 1: the daemon (logs in stdout, persists state under state/<host>/)
|
# Terminal 1: the daemon (logs in stdout, persists state under state/<host>/)
|
||||||
npm run bot
|
npm run bot
|
||||||
|
|
||||||
# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/q.
|
# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/k/v/!/y/q.
|
||||||
npm run tui
|
npm run tui
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,6 +105,9 @@ The TUI auto-reconnects to the bot if you restart it. Press `q` to leave the TUI
|
|||||||
| `r` | Force a fresh status snapshot. |
|
| `r` | Force a fresh status snapshot. |
|
||||||
| `c` | Send a chat message into MC. |
|
| `c` | Send a chat message into MC. |
|
||||||
| `a` | Ask Pi (one-shot subprocess). |
|
| `a` | Ask Pi (one-shot subprocess). |
|
||||||
|
| `k` | Run one registered skill with optional JSON args. |
|
||||||
|
| `v` | Capture a viewer screenshot for debugging. |
|
||||||
|
| `!` | Force a critic-backed incident/proposal. |
|
||||||
| `y` | Open the latest pending proposal (badge appears in status bar). In the panel: `y` approve, `n`/Esc close. |
|
| `y` | Open the latest pending proposal (badge appears in status bar). In the panel: `y` approve, `n`/Esc close. |
|
||||||
| `q` | Quit the TUI — bot keeps running. |
|
| `q` | Quit the TUI — bot keeps running. |
|
||||||
|
|
||||||
@@ -213,7 +216,7 @@ These are mirrored in `AGENTS.md` and re-stated at the top of any system prompt
|
|||||||
|
|
||||||
🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour.
|
🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour.
|
||||||
|
|
||||||
🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored). Phase 0 (chat-control cleanup, version auto-detect) is done; Phase 1+ (observability, skill substrate, survival curriculum, base/village loop) is the next focus.
|
🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). The hybrid runtime now has enriched perception, priority modes, a skill-driven curriculum, food acquisition, bed/sleep, base/chest/shelter/farm skills, persistent skill metrics, scenario memory, and a scoped auto-patch loop with `npm test` smoke gating. Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored).
|
||||||
|
|
||||||
Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Memory layout: [`docs/memory-model.md`](./docs/memory-model.md). Day-to-day judgement: "Operating principles" in [`AGENTS.md`](./AGENTS.md).
|
Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Memory layout: [`docs/memory-model.md`](./docs/memory-model.md). Day-to-day judgement: "Operating principles" in [`AGENTS.md`](./AGENTS.md).
|
||||||
|
|
||||||
|
|||||||
+59
-35
@@ -96,6 +96,9 @@ TUI; the bot is unaffected.
|
|||||||
| `r` | Force-broadcast a status snapshot now. |
|
| `r` | Force-broadcast a status snapshot now. |
|
||||||
| `c` | Enter **chat mode** — type a message, Enter sends it into MC chat. |
|
| `c` | Enter **chat mode** — type a message, Enter sends it into MC chat. |
|
||||||
| `a` | Enter **ask-Pi mode** — type a prompt, Enter spawns `pi -p` and streams output into the Pi panel. |
|
| `a` | Enter **ask-Pi mode** — type a prompt, Enter spawns `pi -p` and streams output into the Pi panel. |
|
||||||
|
| `k` | Enter **run-skill mode** — type `skill.id` plus optional JSON args; dispatches once through `cmd:run-skill`. |
|
||||||
|
| `v` | Capture a headless viewer screenshot into `state/<host>/screenshots/` when viewer support is available. |
|
||||||
|
| `!` | Force a stuck incident proposal through the critic/auto-improve path for diagnostics. |
|
||||||
| `y` | Open the latest pending proposal. In the proposal panel: `y` approves, `n`/Esc closes. |
|
| `y` | Open the latest pending proposal. In the proposal panel: `y` approves, `n`/Esc closes. |
|
||||||
| `q` | Quit TUI only. Bot keeps running. |
|
| `q` | Quit TUI only. Bot keeps running. |
|
||||||
|
|
||||||
@@ -106,19 +109,20 @@ TUI; the bot is unaffected.
|
|||||||
The chain (highest priority first), wired and dispatching real
|
The chain (highest priority first), wired and dispatching real
|
||||||
Mineflayer actions:
|
Mineflayer actions:
|
||||||
|
|
||||||
1. **`defendReflex`** — closest hostile within 4 m → `attackNearest`
|
1. **Modes** — Mindcraft-style interrupts run first:
|
||||||
(equips best melee). Within 12 m + low HP or ≥3 hostiles → `fleeFrom`
|
`self_preservation`, `hunger`, `night_shelter`. They dispatch
|
||||||
along the away-vector.
|
registered skills (`survive.flee`, `survive.eat`, `survive.sleep`)
|
||||||
2. **`eatReflex`** — food < 16 → `eatBestFood` (picks from
|
and therefore feed metrics, scenario memory and current-task state.
|
||||||
FOOD_PRIORITY list, equip + consume). 5 s cooldown.
|
2. **`defendReflex`** — closest hostile within 4 m → `attackNearest`
|
||||||
3. **`sleepReflex`** — night + no hostile within 8 m → `sleepInBed`
|
(equips best melee). Low HP / close hostile → flee.
|
||||||
(finds nearest placed bed within 16 blocks, paths there, sleeps).
|
3. **`eatReflex`** — food < 16 and edible item is carried →
|
||||||
5 min cooldown on failures.
|
`eatBestFood`. No-food states fall through to the curriculum instead
|
||||||
4. **`techTreeReflex`** — deterministic crafting progression
|
of eat-spamming.
|
||||||
(planks → sticks → wooden axe → pickaxe → sword) when prerequisites
|
4. **`sleepReflex`** — night + no hostile within 8 m + bed available →
|
||||||
are in inventory.
|
`sleepInBed`. Impossible states are skipped before dispatch.
|
||||||
5. **`autonomousReflex`** — when nothing reactive fires: chop trees until
|
5. **`curriculumReflex`** — dispatches `snapshot.curriculum.plan.skillId`
|
||||||
~16 logs, then wander to discover new chunks.
|
through `runSkill`. Inventory pressure can insert
|
||||||
|
`village.deposit-surplus` before continuing.
|
||||||
6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos).
|
6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos).
|
||||||
|
|
||||||
There is **no operator-goal reflex anymore.** MC chat does not create
|
There is **no operator-goal reflex anymore.** MC chat does not create
|
||||||
@@ -199,10 +203,14 @@ from `bot.registry`, so a version-sensitive item that doesn't exist on
|
|||||||
the connected server simply doesn't appear in the set and skills
|
the connected server simply doesn't appear in the set and skills
|
||||||
return `code: "unsupported_version"` instead of crashing.
|
return `code: "unsupported_version"` instead of crashing.
|
||||||
|
|
||||||
Reference skills shipped today: `gather.logs`, `survive.eat`,
|
Reference skills shipped today include `gather.logs`, `gather.stone`,
|
||||||
`explore.wander`. The reflex loop still calls the older
|
`gather.wool`, `survive.eat`, `survive.flee`, `survive.sleep`,
|
||||||
`runtime/actions.js` primitives directly — porting more behaviours to
|
`survive.acquire-food`, `explore.wander`, `explore.far`,
|
||||||
skills lands in later phases.
|
`village.choose-base`, `village.place-chest`,
|
||||||
|
`village.deposit-surplus`, `village.build-shelter`, `farm.wheat`, and
|
||||||
|
the `craft.*` progression. Some low-level action primitives still live
|
||||||
|
in `runtime/actions.js`, but they are increasingly called behind skill
|
||||||
|
contracts so metrics and self-improvement see them.
|
||||||
|
|
||||||
Run the contract + groups + curriculum tests:
|
Run the contract + groups + curriculum tests:
|
||||||
|
|
||||||
@@ -216,7 +224,8 @@ npm test
|
|||||||
|
|
||||||
```
|
```
|
||||||
wood.16 → wood.planks-and-sticks → wood.tools →
|
wood.16 → wood.planks-and-sticks → wood.tools →
|
||||||
stone.32 → stone.tools → food.basic → storage.chest → shelter.torch
|
survive.bed → stone.32 → stone.tools → food.basic →
|
||||||
|
storage.chest → shelter.torch → village.base-site → village.shelter
|
||||||
```
|
```
|
||||||
|
|
||||||
Each milestone has an `isDone(inventory, snapshot)` predicate and a
|
Each milestone has an `isDone(inventory, snapshot)` predicate and a
|
||||||
@@ -229,14 +238,13 @@ planks).
|
|||||||
The current curriculum result is on every snapshot as
|
The current curriculum result is on every snapshot as
|
||||||
`snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI
|
`snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI
|
||||||
can show what the bot is working on and which skill should drive it.
|
can show what the bot is working on and which skill should drive it.
|
||||||
Wiring the scheduler to actually call `runSkill(plan.skillId, …)` in
|
The scheduler now calls `runSkill(plan.skillId, …)` directly from
|
||||||
the reflex loop is a Phase 4 task; today the reflex still uses
|
`curriculumReflex`; no LLM is in the hot path.
|
||||||
`actions.js` directly.
|
|
||||||
|
|
||||||
Inventory pressure: `isInventoryFull(snapshot)` is exposed on every
|
Inventory pressure: `isInventoryFull(snapshot)` is exposed on every
|
||||||
curriculum result; the TUI surfaces `[inventory full]` next to the
|
curriculum result; the TUI surfaces `[inventory full]`, and when a
|
||||||
milestone label so the operator can see when a deposit step is needed
|
known/nearby chest exists the scheduler tries `village.deposit-surplus`
|
||||||
before progress continues.
|
before the next milestone.
|
||||||
|
|
||||||
### Optional: prismarine-viewer
|
### Optional: prismarine-viewer
|
||||||
|
|
||||||
@@ -259,10 +267,15 @@ Two persistent stores under `state/<host>/`:
|
|||||||
Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})`
|
Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})`
|
||||||
returns the cardinal quadrant the bot has explored LEAST — used by
|
returns the cardinal quadrant the bot has explored LEAST — used by
|
||||||
`explore.far` to circle rather than retread the same patch.
|
`explore.far` to circle rather than retread the same patch.
|
||||||
|
- **`skill-metrics.json`** — persistent per-skill ok/fail counters,
|
||||||
|
last code and duration. `snapshot.skillMetrics` exposes the loaded
|
||||||
|
totals so proposals learn from previous runs, not only the current
|
||||||
|
process lifetime.
|
||||||
- **`scenarios.jsonl`** — sliding window of `(skillId, situationHash,
|
- **`scenarios.jsonl`** — sliding window of `(skillId, situationHash,
|
||||||
code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint
|
code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint
|
||||||
of where + how the bot was (16-cell + 8y bucket, day/night, food
|
of where + how the bot was (16-cell + 8y bucket, day/night, food
|
||||||
bucket, hp bucket, inventory key set, closest hostile name). The
|
bucket, hp bucket, biome, nearby block groups, inventory key set,
|
||||||
|
closest hostile name). The
|
||||||
curriculum reflex calls `memory.shouldSkip({skillId, situation})` —
|
curriculum reflex calls `memory.shouldSkip({skillId, situation})` —
|
||||||
≥3 failures of the same `(skill, situation)` within 30 min and the
|
≥3 failures of the same `(skill, situation)` within 30 min and the
|
||||||
reflex auto-converts into a wander hint instead of re-dispatching the
|
reflex auto-converts into a wander hint instead of re-dispatching the
|
||||||
@@ -276,6 +289,7 @@ based on what's actually been tried, not just one snapshot.
|
|||||||
### Scheduler driven by the curriculum (2026-05-26)
|
### Scheduler driven by the curriculum (2026-05-26)
|
||||||
|
|
||||||
The reflex chain is now: `defend → eat → sleep → curriculum → idle`.
|
The reflex chain is now: `defend → eat → sleep → curriculum → idle`.
|
||||||
|
Modes run before that chain and dispatch their own skills immediately.
|
||||||
|
|
||||||
`curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated
|
`curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated
|
||||||
by `runtime/curriculum.js` each tick) and dispatches it via
|
by `runtime/curriculum.js` each tick) and dispatches it via
|
||||||
@@ -286,7 +300,8 @@ by `runtime/curriculum.js` each tick) and dispatches it via
|
|||||||
`gather.logs` returns `code: "no_target"` because there's no tree
|
`gather.logs` returns `code: "no_target"` because there's no tree
|
||||||
within 32m), the curriculum reflex swaps to `wander` for ~60 s.
|
within 32m), the curriculum reflex swaps to `wander` for ~60 s.
|
||||||
- If the result code is `missing_tool` / `missing_material` /
|
- If the result code is `missing_tool` / `missing_material` /
|
||||||
`no_target` / `no_food_source` / `unsupported_version`, that
|
`no_target` / `no_food_source` / `unsupported_version` / storage
|
||||||
|
blockers, that
|
||||||
specific skill backs off for 60 s instead of retrying every tick.
|
specific skill backs off for 60 s instead of retrying every tick.
|
||||||
- Unknown `skillId` (curriculum suggested something that isn't
|
- Unknown `skillId` (curriculum suggested something that isn't
|
||||||
registered yet) falls through to `wander` — useful while we wire
|
registered yet) falls through to `wander` — useful while we wire
|
||||||
@@ -360,13 +375,16 @@ Two classes of proposals now land in `state/<host>/proposals/`:
|
|||||||
|
|
||||||
Both kinds now persist an **`editScope`** in their frontmatter — an
|
Both kinds now persist an **`editScope`** in their frontmatter — an
|
||||||
array of repo-relative path prefixes the auto-patcher is allowed to
|
array of repo-relative path prefixes the auto-patcher is allowed to
|
||||||
modify. `state-store.readProposalEditScope(filename)` reads it back;
|
modify. `scripts/auto-patch.js` enforces that scope, runs the cheap
|
||||||
hooking `scripts/auto-patch.js` to refuse cherry-picks that touch
|
`scripts/lint-patch.js` gate, then runs `npm test` before cherry-pick.
|
||||||
other areas is the remaining follow-up.
|
|
||||||
|
|
||||||
Per-skill metrics live in memory only (best-effort) but are surfaced
|
Learning speed is configurable:
|
||||||
on `snapshot.skillMetrics = { [skillId]: { ok, fail, lastTs } }` so
|
|
||||||
the TUI can show which skills are reliable and which keep failing.
|
- `PEPA_LEARNING_MODE=fast` or `dev` lowers stuck detection and
|
||||||
|
auto-improve cooldowns for active training sessions.
|
||||||
|
- `PEPA_STUCK_THRESHOLD_SECONDS`, `PEPA_STUCK_COOLDOWN_SECONDS`,
|
||||||
|
`PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS`,
|
||||||
|
`PEPA_AUTO_IMPROVE_MAX_PER_HOUR` override those defaults directly.
|
||||||
|
|
||||||
### Social layer (Phase 5)
|
### Social layer (Phase 5)
|
||||||
|
|
||||||
@@ -442,6 +460,9 @@ shutdown). Framing: one JSON object per line.
|
|||||||
| `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). |
|
| `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). |
|
||||||
| `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p "<prompt>"`. |
|
| `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p "<prompt>"`. |
|
||||||
| `cmd:snapshot` | `{}` | Force a `status` event now. |
|
| `cmd:snapshot` | `{}` | Force a `status` event now. |
|
||||||
|
| `cmd:run-skill` | `{ skillId, args? }` | Pauses the reflex loop long enough to dispatch one registered skill. |
|
||||||
|
| `cmd:screenshot` | `{ reason?, frames? }` | Captures a headless viewer screenshot for visual debugging. |
|
||||||
|
| `cmd:force-incident` | `{ kind?, reason? }` | Forces a critic-backed proposal, useful for testing self-improvement. |
|
||||||
|
|
||||||
The protocol is intentionally tiny — anyone can write a second client
|
The protocol is intentionally tiny — anyone can write a second client
|
||||||
(a Telegram bridge, a web UI, a one-shot CLI) by reading
|
(a Telegram bridge, a web UI, a one-shot CLI) by reading
|
||||||
@@ -470,7 +491,8 @@ if they break things. The flow:
|
|||||||
- moves proposal pending → approved/ (audit trail)
|
- moves proposal pending → approved/ (audit trail)
|
||||||
- creates branch auto/<slug> off main
|
- creates branch auto/<slug> off main
|
||||||
- runs `pi -p "<patch prompt>"` with 10-min timeout
|
- runs `pi -p "<patch prompt>"` with 10-min timeout
|
||||||
- if Pi committed AND only touched runtime/ → cherry-pick onto main
|
- if Pi committed AND changed only the proposal editScope
|
||||||
|
(+ runtime/**/*.test.js) AND lint/npm-test pass → cherry-pick onto main
|
||||||
- else → discard branch, exit non-zero
|
- else → discard branch, exit non-zero
|
||||||
6. supervisor's runtime/*.js watcher fires the moment the cherry-pick
|
6. supervisor's runtime/*.js watcher fires the moment the cherry-pick
|
||||||
lands → child restarts on the new code
|
lands → child restarts on the new code
|
||||||
@@ -483,8 +505,10 @@ if they break things. The flow:
|
|||||||
### Rate limits
|
### Rate limits
|
||||||
|
|
||||||
- **Proposal cooldown**: 30 min between proposal files of any kind.
|
- **Proposal cooldown**: 30 min between proposal files of any kind.
|
||||||
- **Auto-improve cooldown**: 15 min between finished `auto-patch.js` runs.
|
- **Auto-improve cooldown**: default 15 min between finished
|
||||||
- **Hourly cap**: max 4 auto-patches per hour, even if cooldown allows.
|
`auto-patch.js` runs; `PEPA_LEARNING_MODE=fast|dev` lowers this to
|
||||||
|
5 min unless overridden.
|
||||||
|
- **Hourly cap**: default max 4 auto-patches per hour; fast/dev default is 8.
|
||||||
- **Rollback cap**: 3 rollbacks per supervisor lifetime; after that the
|
- **Rollback cap**: 3 rollbacks per supervisor lifetime; after that the
|
||||||
supervisor exits and waits for human review.
|
supervisor exits and waits for human review.
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ function forceStopCollectBlock(bot) {
|
|||||||
try { bot.pathfinder?.stop?.(); } catch {}
|
try { bot.pathfinder?.stop?.(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPathfinderGoal(bot) {
|
||||||
|
try { bot.pathfinder?.setGoal?.(null); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Each action that uses pathfinder should set its own Movements profile
|
// Each action that uses pathfinder should set its own Movements profile
|
||||||
// before calling goto — otherwise it inherits whatever the previous caller
|
// before calling goto — otherwise it inherits whatever the previous caller
|
||||||
// left set, which has caused live regressions (e.g. flee setting canDig=false,
|
// left set, which has caused live regressions (e.g. flee setting canDig=false,
|
||||||
@@ -476,6 +480,7 @@ export async function wander(bot, radius = 12) {
|
|||||||
return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } };
|
return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`);
|
warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`);
|
||||||
|
clearPathfinderGoal(bot);
|
||||||
const beforeBlind = clonePos(bot.entity.position);
|
const beforeBlind = clonePos(bot.entity.position);
|
||||||
try { await bot.look(best.yaw, 0, true); } catch {}
|
try { await bot.look(best.yaw, 0, true); } catch {}
|
||||||
bot.setControlState("forward", true);
|
bot.setControlState("forward", true);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { stateDir } from "./config.js";
|
import { config, stateDir } from "./config.js";
|
||||||
import { info, warn } from "./log.js";
|
import { info, warn } from "./log.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -25,8 +25,8 @@ const PATCH_SCRIPT = path.join(REPO_ROOT, "scripts", "auto-patch.js");
|
|||||||
const PROPOSALS_DIR = path.join(stateDir, "proposals");
|
const PROPOSALS_DIR = path.join(stateDir, "proposals");
|
||||||
|
|
||||||
const DEBOUNCE_MS = 10_000;
|
const DEBOUNCE_MS = 10_000;
|
||||||
const COOLDOWN_MS = 15 * 60 * 1000;
|
const COOLDOWN_MS = config.autoImproveCooldownMs;
|
||||||
const MAX_TOTAL_PER_HOUR = 4;
|
const MAX_TOTAL_PER_HOUR = config.autoImproveMaxPerHour;
|
||||||
|
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
let lastFinishedAt = 0;
|
let lastFinishedAt = 0;
|
||||||
|
|||||||
+14
-3
@@ -56,6 +56,7 @@ import { requestCritique } from "./critic.js";
|
|||||||
import { createSkillMetrics } from "./skill-metrics.js";
|
import { createSkillMetrics } from "./skill-metrics.js";
|
||||||
import { createWorldJournal } from "./world-journal.js";
|
import { createWorldJournal } from "./world-journal.js";
|
||||||
import { createScenarioMemory, situationHash } from "./scenario-memory.js";
|
import { createScenarioMemory, situationHash } from "./scenario-memory.js";
|
||||||
|
import { createOwnedBlocksLedger } from "./owned-blocks.js";
|
||||||
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||||
@@ -81,10 +82,14 @@ let lastEscalationAt = 0;
|
|||||||
// future Telegram/diary surfaces) can answer "what is the bot doing and why
|
// future Telegram/diary surfaces) can answer "what is the bot doing and why
|
||||||
// isn't it doing more?" without parsing the log stream.
|
// isn't it doing more?" without parsing the log stream.
|
||||||
const noProgress = createNoProgressDetector();
|
const noProgress = createNoProgressDetector();
|
||||||
const stuckIncident = createStuckIncidentDetector();
|
const stuckIncident = createStuckIncidentDetector({
|
||||||
|
thresholdMs: config.stuckThresholdMs,
|
||||||
|
cooldownMs: config.stuckCooldownMs,
|
||||||
|
});
|
||||||
const skillMetrics = createSkillMetrics();
|
const skillMetrics = createSkillMetrics();
|
||||||
const worldJournal = createWorldJournal();
|
const worldJournal = createWorldJournal();
|
||||||
const scenarioMemory = createScenarioMemory();
|
const scenarioMemory = createScenarioMemory();
|
||||||
|
const ownedBlocks = createOwnedBlocksLedger();
|
||||||
let lastResult = null; // { label, ok, code, detail, ts }
|
let lastResult = null; // { label, ok, code, detail, ts }
|
||||||
let lastFailureAt = 0;
|
let lastFailureAt = 0;
|
||||||
let lastPlanReadAt = 0;
|
let lastPlanReadAt = 0;
|
||||||
@@ -109,6 +114,8 @@ const reflexCtx = {
|
|||||||
dispatch: dispatchAction,
|
dispatch: dispatchAction,
|
||||||
journal: worldJournal,
|
journal: worldJournal,
|
||||||
memory: scenarioMemory,
|
memory: scenarioMemory,
|
||||||
|
metrics: skillMetrics,
|
||||||
|
owned: ownedBlocks,
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatTimestamps = [];
|
let chatTimestamps = [];
|
||||||
@@ -186,6 +193,9 @@ function recordWorldDeltaToJournal(label, res, snapshot) {
|
|||||||
if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt });
|
if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt });
|
||||||
if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt });
|
if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt });
|
||||||
if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt });
|
if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt });
|
||||||
|
if (wd.chestAt) worldJournal.append({ kind: "chest", name: "storage", at: wd.chestAt });
|
||||||
|
if (wd.fledTo) worldJournal.append({ kind: "retreat", name: label, at: wd.fledTo });
|
||||||
|
if (wd.acquiredFood && snapshot?.position) worldJournal.append({ kind: "food", name: wd.source ?? "food", at: snapshot.position });
|
||||||
if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt });
|
if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt });
|
||||||
if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt });
|
if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt });
|
||||||
if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt });
|
if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt });
|
||||||
@@ -218,6 +228,7 @@ function dispatchAction(fn, label, opts = {}) {
|
|||||||
}
|
}
|
||||||
reflexCtx.busy = true;
|
reflexCtx.busy = true;
|
||||||
reflexCtx.currentActionLabel = label;
|
reflexCtx.currentActionLabel = label;
|
||||||
|
const startedAt = Date.now();
|
||||||
// Capture the situation hash BEFORE the action runs so a failure is
|
// Capture the situation hash BEFORE the action runs so a failure is
|
||||||
// attributable to the state at dispatch time, not the state after the
|
// attributable to the state at dispatch time, not the state after the
|
||||||
// (partial) effect.
|
// (partial) effect.
|
||||||
@@ -243,7 +254,7 @@ function dispatchAction(fn, label, opts = {}) {
|
|||||||
detail: res?.detail,
|
detail: res?.detail,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
};
|
};
|
||||||
skillMetrics.record(label, ok);
|
skillMetrics.record(label, ok, { code: lastResult.code, durationMs: Date.now() - startedAt });
|
||||||
scenarioMemory.record({
|
scenarioMemory.record({
|
||||||
skillId: label,
|
skillId: label,
|
||||||
situation: startSituation,
|
situation: startSituation,
|
||||||
@@ -278,7 +289,7 @@ function dispatchAction(fn, label, opts = {}) {
|
|||||||
detail: String(e?.message ?? e),
|
detail: String(e?.message ?? e),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
};
|
};
|
||||||
skillMetrics.record(label, false);
|
skillMetrics.record(label, false, { code: "threw", durationMs: Date.now() - startedAt });
|
||||||
scenarioMemory.record({
|
scenarioMemory.record({
|
||||||
skillId: label,
|
skillId: label,
|
||||||
situation: startSituation,
|
situation: startSituation,
|
||||||
|
|||||||
@@ -18,9 +18,16 @@ function opt(name, fallback = "") {
|
|||||||
return process.env[name]?.trim() || fallback;
|
return process.env[name]?.trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optInt(name, fallback) {
|
||||||
|
const raw = Number.parseInt(opt(name, String(fallback)), 10);
|
||||||
|
return Number.isFinite(raw) ? raw : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
const host = req("MC_HOST");
|
const host = req("MC_HOST");
|
||||||
const port = Number.parseInt(opt("MC_PORT", "25565"), 10);
|
const port = Number.parseInt(opt("MC_PORT", "25565"), 10);
|
||||||
const username = req("MC_USERNAME");
|
const username = req("MC_USERNAME");
|
||||||
|
const learningMode = opt("PEPA_LEARNING_MODE", "normal").toLowerCase();
|
||||||
|
const fastLearning = learningMode === "fast" || learningMode === "dev";
|
||||||
|
|
||||||
// MC_VERSION: "auto" (or empty) lets mineflayer auto-detect from the server
|
// MC_VERSION: "auto" (or empty) lets mineflayer auto-detect from the server
|
||||||
// handshake — the right default per the survival-bot PRD (no hard-coded modern
|
// handshake — the right default per the survival-bot PRD (no hard-coded modern
|
||||||
@@ -44,6 +51,11 @@ export const config = Object.freeze({
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
|
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
|
||||||
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
|
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
|
||||||
|
learningMode,
|
||||||
|
stuckThresholdMs: Math.max(15, optInt("PEPA_STUCK_THRESHOLD_SECONDS", fastLearning ? 60 : 300)) * 1000,
|
||||||
|
stuckCooldownMs: Math.max(60, optInt("PEPA_STUCK_COOLDOWN_SECONDS", fastLearning ? 600 : 1800)) * 1000,
|
||||||
|
autoImproveCooldownMs: Math.max(60, optInt("PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS", fastLearning ? 300 : 900)) * 1000,
|
||||||
|
autoImproveMaxPerHour: Math.max(1, optInt("PEPA_AUTO_IMPROVE_MAX_PER_HOUR", fastLearning ? 8 : 4)),
|
||||||
// Optional prismarine-viewer port for local visual debugging. 0/empty = off.
|
// Optional prismarine-viewer port for local visual debugging. 0/empty = off.
|
||||||
viewerPort: (() => {
|
viewerPort: (() => {
|
||||||
const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10);
|
const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10);
|
||||||
|
|||||||
@@ -157,13 +157,16 @@ const MILESTONES = [
|
|||||||
);
|
);
|
||||||
return carrying || (snap?.food ?? 20) >= 18;
|
return carrying || (snap?.food ?? 20) >= 18;
|
||||||
},
|
},
|
||||||
suggest: () => ({ skillId: "survive.eat" }), // best-effort; richer "find food" skill lands later
|
suggest: () => ({ skillId: "survive.acquire-food" }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "storage.chest",
|
id: "storage.chest",
|
||||||
title: "Place a personal chest",
|
title: "Place a personal chest",
|
||||||
isDone: (inv) => has(inv, "chest"),
|
isDone: (_inv, snap) => !!snap?.locations?.chest,
|
||||||
suggest: () => ({ skillId: "craft.chest" }),
|
suggest: (inv) => {
|
||||||
|
if (!has(inv, "chest")) return { skillId: "craft.chest" };
|
||||||
|
return { skillId: "village.place-chest" };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "shelter.torch",
|
id: "shelter.torch",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ test("all done → null", () => {
|
|||||||
nextMilestone(snap(inv, {
|
nextMilestone(snap(inv, {
|
||||||
food: 20,
|
food: 20,
|
||||||
locations: {
|
locations: {
|
||||||
|
chest: { x: 1, y: 64, z: 0 },
|
||||||
base: { x: 0, y: 64, z: 0 },
|
base: { x: 0, y: 64, z: 0 },
|
||||||
shelter: { x: 0, y: 64, z: 0 },
|
shelter: { x: 0, y: 64, z: 0 },
|
||||||
},
|
},
|
||||||
@@ -189,3 +190,19 @@ test("listMilestones exposes ordered ids for diary/TUI", () => {
|
|||||||
assert.equal(typeof m.title, "string");
|
assert.equal(typeof m.title, "string");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("food.basic with no carried food suggests acquire-food", () => {
|
||||||
|
const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 }));
|
||||||
|
assert.equal(got.milestone.id, "food.basic");
|
||||||
|
assert.equal(got.plan.skillId, "survive.acquire-food");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage.chest crafts first, then places carried chest", () => {
|
||||||
|
const needCraft = nextMilestone(snapAfter("food.basic", { chest: 0 }));
|
||||||
|
assert.equal(needCraft.milestone.id, "storage.chest");
|
||||||
|
assert.equal(needCraft.plan.skillId, "craft.chest");
|
||||||
|
|
||||||
|
const needPlace = nextMilestone(snapAfter("food.basic", { chest: 1 }));
|
||||||
|
assert.equal(needPlace.milestone.id, "storage.chest");
|
||||||
|
assert.equal(needPlace.plan.skillId, "village.place-chest");
|
||||||
|
});
|
||||||
|
|||||||
+20
-6
@@ -81,15 +81,23 @@ registerMode({
|
|||||||
const snap = ctx?.snapshot;
|
const snap = ctx?.snapshot;
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
const hp = snap.health ?? 20;
|
const hp = snap.health ?? 20;
|
||||||
const food = snap.food ?? 20;
|
const hasFood = !!snap.hasFood;
|
||||||
|
if (ctx.modeCooldown?.self_preservation && Date.now() < ctx.modeCooldown.self_preservation) return null;
|
||||||
// HP critically low + we have food → eat NOW
|
// HP critically low + we have food → eat NOW
|
||||||
if (hp < 6 && food > 0 && snap.hasFood) {
|
if (hp < 6 && hasFood) {
|
||||||
return { action: { skillId: "eat" }, detail: { reason: "hp<6", hp } };
|
ctx.modeCooldown = ctx.modeCooldown ?? {};
|
||||||
|
ctx.modeCooldown.self_preservation = Date.now() + 5_000;
|
||||||
|
return { action: { skillId: "survive.eat" }, detail: { reason: "hp<6", hp } };
|
||||||
}
|
}
|
||||||
// Hostile within reach and HP low → flee
|
// Hostile within reach and HP low → flee
|
||||||
const ch = snap.closestHostile;
|
const ch = snap.closestHostile;
|
||||||
if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) {
|
if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) {
|
||||||
return { action: { skillId: "explore.far" }, detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance } };
|
ctx.modeCooldown = ctx.modeCooldown ?? {};
|
||||||
|
ctx.modeCooldown.self_preservation = Date.now() + 8_000;
|
||||||
|
return {
|
||||||
|
action: { skillId: "survive.flee", args: { hostileName: ch.name } },
|
||||||
|
detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -102,8 +110,11 @@ registerMode({
|
|||||||
update(ctx) {
|
update(ctx) {
|
||||||
const snap = ctx?.snapshot;
|
const snap = ctx?.snapshot;
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
|
if (ctx.modeCooldown?.hunger && Date.now() < ctx.modeCooldown.hunger) return null;
|
||||||
if ((snap.food ?? 20) < 14 && snap.hasFood) {
|
if ((snap.food ?? 20) < 14 && snap.hasFood) {
|
||||||
return { action: { skillId: "eat" }, detail: { reason: "food<14", food: snap.food } };
|
ctx.modeCooldown = ctx.modeCooldown ?? {};
|
||||||
|
ctx.modeCooldown.hunger = Date.now() + 5_000;
|
||||||
|
return { action: { skillId: "survive.eat" }, detail: { reason: "food<14", food: snap.food } };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -116,11 +127,14 @@ registerMode({
|
|||||||
update(ctx) {
|
update(ctx) {
|
||||||
const snap = ctx?.snapshot;
|
const snap = ctx?.snapshot;
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
|
if (ctx.modeCooldown?.night_shelter && Date.now() < ctx.modeCooldown.night_shelter) return null;
|
||||||
// Only at night and only if we actually carry / can place a bed
|
// Only at night and only if we actually carry / can place a bed
|
||||||
if (snap.isDay) return null;
|
if (snap.isDay) return null;
|
||||||
const inv = snap.inventory || {};
|
const inv = snap.inventory || {};
|
||||||
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
|
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
|
||||||
if (!hasBed) return null;
|
if (!hasBed) return null;
|
||||||
return { action: { skillId: "sleep" }, detail: { reason: "night with bed in hand" } };
|
ctx.modeCooldown = ctx.modeCooldown ?? {};
|
||||||
|
ctx.modeCooldown.night_shelter = Date.now() + 30_000;
|
||||||
|
return { action: { skillId: "survive.sleep" }, detail: { reason: "night with bed in hand" } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ test("self_preservation: low-HP + food + hasFood → eat", async () => {
|
|||||||
const mod = await import(`./modes.js?cb=${Date.now() + 1}`);
|
const mod = await import(`./modes.js?cb=${Date.now() + 1}`);
|
||||||
const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } });
|
const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } });
|
||||||
assert.equal(out.mode, "self_preservation");
|
assert.equal(out.mode, "self_preservation");
|
||||||
assert.equal(out.action.skillId, "eat");
|
assert.equal(out.action.skillId, "survive.eat");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hunger: food below 14 with food → eat", async () => {
|
test("hunger: food below 14 with food → eat", async () => {
|
||||||
_resetModes();
|
_resetModes();
|
||||||
const mod = await import(`./modes.js?cb=${Date.now() + 2}`);
|
const mod = await import(`./modes.js?cb=${Date.now() + 2}`);
|
||||||
const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } });
|
const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } });
|
||||||
assert.equal(out.action.skillId, "eat");
|
assert.equal(out.action.skillId, "survive.eat");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("night_shelter: day → null (skip)", async () => {
|
test("night_shelter: day → null (skip)", async () => {
|
||||||
@@ -91,5 +91,5 @@ test("night_shelter: night + bed in hand → sleep", async () => {
|
|||||||
_resetModes();
|
_resetModes();
|
||||||
const mod = await import(`./modes.js?cb=${Date.now() + 4}`);
|
const mod = await import(`./modes.js?cb=${Date.now() + 4}`);
|
||||||
const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } });
|
const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } });
|
||||||
assert.equal(out.action.skillId, "sleep");
|
assert.equal(out.action.skillId, "survive.sleep");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ function hdist(a, b) {
|
|||||||
return Math.hypot(a.x - b.x, a.z - b.z);
|
return Math.hypot(a.x - b.x, a.z - b.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectBlockOwnsPathfinder(bot) {
|
||||||
|
try {
|
||||||
|
const targets = bot.collectBlock?.targets;
|
||||||
|
if (!targets) return false;
|
||||||
|
if (typeof targets.empty === "boolean") return targets.empty === false;
|
||||||
|
if (Array.isArray(targets.targets)) return targets.targets.length > 0;
|
||||||
|
if (typeof targets.size === "number") return targets.size > 0;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createPathfinderWatchdog(bot, {
|
export function createPathfinderWatchdog(bot, {
|
||||||
intervalMs = WATCH_INTERVAL_MS,
|
intervalMs = WATCH_INTERVAL_MS,
|
||||||
windowMs = STUCK_WINDOW_MS,
|
windowMs = STUCK_WINDOW_MS,
|
||||||
@@ -71,6 +84,15 @@ export function createPathfinderWatchdog(bot, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return;
|
if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return;
|
||||||
|
if (collectBlockOwnsPathfinder(bot)) {
|
||||||
|
// mineflayer-collectblock treats pathfinder goal changes as a
|
||||||
|
// hard cancellation ("The goal was changed before it could be
|
||||||
|
// completed"). Let the collect skill's own timeout/blacklist
|
||||||
|
// handle these paths instead of invalidating the current dig.
|
||||||
|
lastSeenPos = hpos(bot.entity?.position);
|
||||||
|
lastSeenAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const here = hpos(bot.entity?.position);
|
const here = hpos(bot.entity?.position);
|
||||||
@@ -81,8 +103,11 @@ export function createPathfinderWatchdog(bot, {
|
|||||||
}
|
}
|
||||||
if (now - lastSeenAt < windowMs) return;
|
if (now - lastSeenAt < windowMs) return;
|
||||||
|
|
||||||
// Stuck. Force a replan — clear the goal, then re-set the same
|
// Stuck. Force a replan by setting the exact same goal object again.
|
||||||
// goal so pathfinder rebuilds the graph against the current world.
|
// mineflayer-pathfinder emits goal_updated on every setGoal() call
|
||||||
|
// and rebuilds the graph, but goto() only rejects as GoalChanged when
|
||||||
|
// the new goal object is different. Clearing to null first breaks the
|
||||||
|
// caller, as observed live with gather.logs/explore.far.
|
||||||
if (replansThisGoal >= maxReplans) {
|
if (replansThisGoal >= maxReplans) {
|
||||||
warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`);
|
warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`);
|
||||||
lastSeenAt = now; // throttle further warnings within this window
|
lastSeenAt = now; // throttle further warnings within this window
|
||||||
@@ -92,15 +117,7 @@ export function createPathfinderWatchdog(bot, {
|
|||||||
info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`);
|
info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`);
|
||||||
try {
|
try {
|
||||||
const goalCopy = goal;
|
const goalCopy = goal;
|
||||||
// setGoal(null) cancels the current pathing without bubbling
|
pf.setGoal(goalCopy);
|
||||||
// an error to the awaiting goto() promise.
|
|
||||||
pf.setGoal(null);
|
|
||||||
// Re-set immediately. mineflayer-pathfinder will compute a
|
|
||||||
// fresh path off the latest world snapshot.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (stopped) return;
|
|
||||||
try { pf.setGoal(goalCopy); } catch (e) { warn("pathfinder", `replan setGoal failed: ${e.message}`); }
|
|
||||||
}, 250);
|
|
||||||
lastSeenAt = now; // reset window
|
lastSeenAt = now; // reset window
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn("pathfinder", `replan failed: ${e.message}`);
|
warn("pathfinder", `replan failed: ${e.message}`);
|
||||||
@@ -118,4 +135,4 @@ export function createPathfinderWatchdog(bot, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pure helpers for tests.
|
// Pure helpers for tests.
|
||||||
export const _internal = { hpos, hdist };
|
export const _internal = { hpos, hdist, collectBlockOwnsPathfinder };
|
||||||
|
|||||||
@@ -55,10 +55,21 @@ test("replan fires after stuck window elapses", async () => {
|
|||||||
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 });
|
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 });
|
||||||
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
|
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
|
||||||
wd.stop();
|
wd.stop();
|
||||||
// At least one setGoal(null) call.
|
// At least one same-object setGoal(goal) call.
|
||||||
assert.ok(bot.setGoalCalls.length >= 1, `expected ≥1 setGoal call, got ${bot.setGoalCalls.length}`);
|
assert.ok(bot.setGoalCalls.length >= 1, `expected ≥1 setGoal call, got ${bot.setGoalCalls.length}`);
|
||||||
// First call is setGoal(null).
|
assert.equal(bot.setGoalCalls[0], goal);
|
||||||
assert.equal(bot.setGoalCalls[0], null);
|
});
|
||||||
|
|
||||||
|
test("does not replan while collectBlock owns pathfinder", async () => {
|
||||||
|
const goal = { id: "g1" };
|
||||||
|
const bot = makeBot({ goalRef: goal, pos: { x: 0, y: 64, z: 0 } });
|
||||||
|
bot.pathfinder._owner = bot;
|
||||||
|
bot.collectBlock = { targets: { empty: false, targets: [{}] } };
|
||||||
|
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 });
|
||||||
|
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
|
||||||
|
wd.stop();
|
||||||
|
assert.equal(_internal.collectBlockOwnsPathfinder(bot), true);
|
||||||
|
assert.equal(bot.setGoalCalls.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("respects maxReplans cap", async () => {
|
test("respects maxReplans cap", async () => {
|
||||||
@@ -68,8 +79,7 @@ test("respects maxReplans cap", async () => {
|
|||||||
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 });
|
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 });
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
wd.stop();
|
wd.stop();
|
||||||
// Each replan = setGoal(null) + delayed setGoal(goal). So 2 replans ≤ 4 calls.
|
assert.ok(bot.setGoalCalls.length <= 2, `expected ≤2 setGoal calls, got ${bot.setGoalCalls.length}`);
|
||||||
assert.ok(bot.setGoalCalls.length <= 4, `expected ≤4 setGoal calls, got ${bot.setGoalCalls.length}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resets counters when goal changes", async () => {
|
test("resets counters when goal changes", async () => {
|
||||||
|
|||||||
+124
-4
@@ -1,6 +1,9 @@
|
|||||||
// Build a compact, JSON-safe snapshot of the world around the bot. Used both
|
// Build a compact, JSON-safe snapshot of the world around the bot. Used both
|
||||||
// for reflex decisions and for periodic IPC STATUS events.
|
// for reflex decisions and for periodic IPC STATUS events.
|
||||||
|
|
||||||
|
import { foods } from "./skills/groups.js";
|
||||||
|
import { findBlocksByName } from "./perception.js";
|
||||||
|
|
||||||
function vec3ToObj(v) {
|
function vec3ToObj(v) {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 };
|
return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 };
|
||||||
@@ -29,6 +32,95 @@ const HOSTILE = new Set([
|
|||||||
"bogged",
|
"bogged",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const PASSIVE = new Set([
|
||||||
|
"cow",
|
||||||
|
"pig",
|
||||||
|
"chicken",
|
||||||
|
"sheep",
|
||||||
|
"rabbit",
|
||||||
|
"mooshroom",
|
||||||
|
"cod",
|
||||||
|
"salmon",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INTERESTING_BLOCK_GROUPS = Object.freeze({
|
||||||
|
logs: ["oak_log", "dark_oak_log", "spruce_log", "birch_log", "jungle_log", "acacia_log", "mangrove_log", "cherry_log", "pale_oak_log"],
|
||||||
|
stone: ["stone", "cobblestone", "deepslate", "cobbled_deepslate", "andesite", "diorite", "granite"],
|
||||||
|
water: ["water"],
|
||||||
|
lava: ["lava", "fire", "soul_fire"],
|
||||||
|
beds: ["white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed"],
|
||||||
|
storage: ["chest", "trapped_chest", "barrel"],
|
||||||
|
wool: ["white_wool", "orange_wool", "magenta_wool", "light_blue_wool", "yellow_wool", "lime_wool", "pink_wool", "gray_wool", "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", "brown_wool", "green_wool", "red_wool", "black_wool"],
|
||||||
|
crops: ["wheat", "carrots", "potatoes", "beetroots", "sweet_berry_bush"],
|
||||||
|
coal: ["coal_ore", "deepslate_coal_ore"],
|
||||||
|
});
|
||||||
|
|
||||||
|
function inventoryCounts(bot) {
|
||||||
|
return (bot.inventory?.items?.() ?? []).reduce((acc, item) => {
|
||||||
|
acc[item.name] = (acc[item.name] ?? 0) + item.count;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countInterestingBlocks(bot, pos, radius = 16) {
|
||||||
|
const out = {};
|
||||||
|
for (const [kind, names] of Object.entries(INTERESTING_BLOCK_GROUPS)) {
|
||||||
|
let positions = [];
|
||||||
|
try {
|
||||||
|
positions = findBlocksByName(bot, names, { maxDistance: radius, count: 16 });
|
||||||
|
} catch {
|
||||||
|
positions = [];
|
||||||
|
}
|
||||||
|
if (positions.length === 0) continue;
|
||||||
|
const nearest = positions
|
||||||
|
.map((p) => ({ position: vec3ToObj(p), distance: Math.round(Math.hypot(p.x - pos.x, p.z - pos.z) * 10) / 10 }))
|
||||||
|
.sort((a, b) => a.distance - b.distance)[0];
|
||||||
|
out[kind] = { count: positions.length, nearest };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function carriedEquipment(bot) {
|
||||||
|
const slots = bot.inventory?.slots ?? [];
|
||||||
|
return {
|
||||||
|
hand: bot.heldItem?.name ?? null,
|
||||||
|
head: slots[5]?.name ?? null,
|
||||||
|
torso: slots[6]?.name ?? null,
|
||||||
|
legs: slots[7]?.name ?? null,
|
||||||
|
feet: slots[8]?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEdibleFood(bot, inventory) {
|
||||||
|
const allowed = foods(bot);
|
||||||
|
return Object.keys(inventory ?? {}).some((name) => allowed.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityDistance(e, pos) {
|
||||||
|
try {
|
||||||
|
return Math.round(e.position.distanceTo(pos) * 10) / 10;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitySnapshot(e, pos) {
|
||||||
|
return {
|
||||||
|
name: e.username ?? e.name ?? e.displayName ?? "?",
|
||||||
|
type: e.type ?? null,
|
||||||
|
distance: entityDistance(e, pos),
|
||||||
|
position: vec3ToObj(e.position),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockNameAt(bot, pos) {
|
||||||
|
try {
|
||||||
|
return bot.blockAt(pos)?.name ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function snapshot(bot) {
|
export function snapshot(bot) {
|
||||||
if (!bot || !bot.entity) {
|
if (!bot || !bot.entity) {
|
||||||
return { connected: false };
|
return { connected: false };
|
||||||
@@ -42,10 +134,29 @@ export function snapshot(bot) {
|
|||||||
return !best || d < best.d ? { d, e } : best;
|
return !best || d < best.d ? { d, e } : best;
|
||||||
}, null);
|
}, null);
|
||||||
|
|
||||||
const inventory = (bot.inventory?.items?.() ?? []).reduce((acc, item) => {
|
const inventory = inventoryCounts(bot);
|
||||||
acc[item.name] = (acc[item.name] ?? 0) + item.count;
|
const nearbyBlocks = countInterestingBlocks(bot, pos);
|
||||||
return acc;
|
const droppedItems = entities
|
||||||
}, {});
|
.filter((e) => e.type === "object" || e.name === "item")
|
||||||
|
.filter((e) => e.position && e.position.distanceTo(pos) <= 24)
|
||||||
|
.map((e) => entitySnapshot(e, pos))
|
||||||
|
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity))
|
||||||
|
.slice(0, 12);
|
||||||
|
const passives = entities
|
||||||
|
.filter((e) => PASSIVE.has((e.name || "").toLowerCase()) && e.position)
|
||||||
|
.map((e) => entitySnapshot(e, pos))
|
||||||
|
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity))
|
||||||
|
.slice(0, 12);
|
||||||
|
const footBlock = blockNameAt(bot, pos);
|
||||||
|
const belowBlock = blockNameAt(bot, pos.offset(0, -1, 0));
|
||||||
|
const headBlock = blockNameAt(bot, pos.offset(0, 1, 0));
|
||||||
|
const hazards = {
|
||||||
|
lavaNearby: !!nearbyBlocks.lava,
|
||||||
|
inFluid: footBlock === "water" || footBlock === "lava",
|
||||||
|
footBlock,
|
||||||
|
belowBlock,
|
||||||
|
headBlock,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
@@ -60,6 +171,15 @@ export function snapshot(bot) {
|
|||||||
weather: { rain: bot.isRaining, thunder: bot.thundering },
|
weather: { rain: bot.isRaining, thunder: bot.thundering },
|
||||||
dimension: bot.game?.dimension,
|
dimension: bot.game?.dimension,
|
||||||
inventory,
|
inventory,
|
||||||
|
hasFood: hasEdibleFood(bot, inventory),
|
||||||
|
equipment: carriedEquipment(bot),
|
||||||
|
nearbyBlocks,
|
||||||
|
nearbyEntities: {
|
||||||
|
passives,
|
||||||
|
droppedItems,
|
||||||
|
},
|
||||||
|
hazards,
|
||||||
|
biome: bot.blockAt?.(pos)?.biome?.name ?? bot.blockAt?.(pos)?.biome ?? null,
|
||||||
players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })),
|
players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })),
|
||||||
hostileCount: hostiles.length,
|
hostileCount: hostiles.length,
|
||||||
closestHostile: closestHostile
|
closestHostile: closestHostile
|
||||||
|
|||||||
+76
-3
@@ -311,6 +311,45 @@ function sleepReflex(ctx) {
|
|||||||
|
|
||||||
const CURRICULUM_COOLDOWN_MS = 4_000;
|
const CURRICULUM_COOLDOWN_MS = 4_000;
|
||||||
const SKILL_BACKOFF_MS = 60_000;
|
const SKILL_BACKOFF_MS = 60_000;
|
||||||
|
const METRIC_BACKOFF_MS = 10 * 60_000;
|
||||||
|
const METRIC_BAD_CODES = new Set(["timeout", "failed", "wedged", "silent_dig_failure", "validation_failed"]);
|
||||||
|
|
||||||
|
function isRecentBadMetric(metric, { minFails = 2, maxAgeMs = METRIC_BACKOFF_MS } = {}) {
|
||||||
|
if (!metric) return false;
|
||||||
|
if ((metric.fail ?? 0) < minFails) return false;
|
||||||
|
if (!METRIC_BAD_CODES.has(metric.lastCode)) return false;
|
||||||
|
if ((metric.ok ?? 0) > 0 && metric.lastCode !== "timeout") return false;
|
||||||
|
return Date.now() - (metric.lastTs ?? 0) < maxAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentSuccessAfter(metric, ts) {
|
||||||
|
if (!metric || !ts) return false;
|
||||||
|
if ((metric.ok ?? 0) <= 0) return false;
|
||||||
|
return (metric.lastTs ?? 0) > ts && metric.lastCode === "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricRecoverySkill(ctx, plannedSkillId) {
|
||||||
|
let metrics = null;
|
||||||
|
try {
|
||||||
|
metrics = ctx.metrics?.snapshot?.() ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!metrics) return null;
|
||||||
|
const badExplore = isRecentBadMetric(metrics["explore.far"], { minFails: 1 });
|
||||||
|
const badWander = isRecentBadMetric(metrics.wander, { minFails: 2 });
|
||||||
|
const lastMovementBadTs = Math.max(
|
||||||
|
badExplore ? (metrics["explore.far"]?.lastTs ?? 0) : 0,
|
||||||
|
badWander ? (metrics.wander?.lastTs ?? 0) : 0,
|
||||||
|
);
|
||||||
|
if ((badExplore || badWander) && !recentSuccessAfter(metrics["recovery.tunnel-out"], lastMovementBadTs)) {
|
||||||
|
return { skillId: "recovery.tunnel-out", reason: "recent movement recovery failures" };
|
||||||
|
}
|
||||||
|
if (plannedSkillId && isRecentBadMetric(metrics[plannedSkillId], { minFails: 2 })) {
|
||||||
|
return { skillId: "explore.far", reason: `${plannedSkillId} recently failed repeatedly` };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function curriculumReflex(ctx) {
|
function curriculumReflex(ctx) {
|
||||||
const s = ctx.snapshot;
|
const s = ctx.snapshot;
|
||||||
@@ -323,6 +362,39 @@ function curriculumReflex(ctx) {
|
|||||||
const plan = s.curriculum?.plan;
|
const plan = s.curriculum?.plan;
|
||||||
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
|
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
|
||||||
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
|
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
|
||||||
|
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
|
||||||
|
if (metricRecovery) {
|
||||||
|
ctx.lastCurriculumAt = Date.now();
|
||||||
|
ctx.skillBackoff = ctx.skillBackoff ?? {};
|
||||||
|
if (plan?.skillId) ctx.skillBackoff[plan.skillId] = Date.now() + SKILL_BACKOFF_MS;
|
||||||
|
const args = metricRecovery.skillId === "recovery.tunnel-out"
|
||||||
|
? { reason: metricRecovery.reason, maxSteps: 3 }
|
||||||
|
: {};
|
||||||
|
ctx.dispatch(() => runSkill(metricRecovery.skillId, ctx, args), metricRecovery.skillId, {});
|
||||||
|
return {
|
||||||
|
action: "dispatched",
|
||||||
|
kind: "curriculum-metric-recovery",
|
||||||
|
label: metricRecovery.skillId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (s.curriculum?.inventoryFull) {
|
||||||
|
const depositId = s.locations?.chest || s.nearbyBlocks?.storage ? "village.deposit-surplus" : null;
|
||||||
|
if (depositId) {
|
||||||
|
const depositBackoff = ctx.skillBackoff?.[depositId] ?? 0;
|
||||||
|
if (Date.now() >= depositBackoff) {
|
||||||
|
ctx.lastCurriculumAt = Date.now();
|
||||||
|
ctx.dispatch(() => runSkill(depositId, ctx), depositId, {
|
||||||
|
onComplete: (res) => {
|
||||||
|
if (!res?.ok) {
|
||||||
|
ctx.skillBackoff = ctx.skillBackoff ?? {};
|
||||||
|
ctx.skillBackoff[depositId] = Date.now() + SKILL_BACKOFF_MS;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { action: "dispatched", kind: "curriculum-deposit", label: depositId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No skill plan from curriculum OR a recent skill asked us to wander.
|
// No skill plan from curriculum OR a recent skill asked us to wander.
|
||||||
// First hint → small wander (might just be 32-block reach issue).
|
// First hint → small wander (might just be 32-block reach issue).
|
||||||
@@ -382,7 +454,7 @@ function curriculumReflex(ctx) {
|
|||||||
if (!res?.ok) {
|
if (!res?.ok) {
|
||||||
// missing_tool / missing_material / no_target shouldn't be
|
// missing_tool / missing_material / no_target shouldn't be
|
||||||
// retried on the very next tick. Hold for SKILL_BACKOFF_MS.
|
// retried on the very next tick. Hold for SKILL_BACKOFF_MS.
|
||||||
const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version"]);
|
const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version", "no_chest", "no_space", "nothing_to_deposit"]);
|
||||||
if (cooldownCodes.has(res?.code)) {
|
if (cooldownCodes.has(res?.code)) {
|
||||||
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
|
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
|
||||||
}
|
}
|
||||||
@@ -432,11 +504,12 @@ export function runTick(ctx) {
|
|||||||
if (modeHit?.action?.skillId) {
|
if (modeHit?.action?.skillId) {
|
||||||
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
|
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
|
||||||
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
|
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
|
||||||
|
ctx.dispatch(fn, modeHit.action.skillId, {});
|
||||||
return {
|
return {
|
||||||
reflex: `mode:${modeHit.mode}`,
|
reflex: `mode:${modeHit.mode}`,
|
||||||
action: "dispatch",
|
action: "dispatched",
|
||||||
|
kind: `mode:${modeHit.mode}`,
|
||||||
label: modeHit.action.skillId,
|
label: modeHit.action.skillId,
|
||||||
fn,
|
|
||||||
detail: modeHit.detail,
|
detail: modeHit.detail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function makeCtx({
|
|||||||
lastEatAt = 0,
|
lastEatAt = 0,
|
||||||
lastSleepAttemptAt = 0,
|
lastSleepAttemptAt = 0,
|
||||||
lastCurriculumAt = 0,
|
lastCurriculumAt = 0,
|
||||||
|
metrics,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const dispatches = [];
|
const dispatches = [];
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -56,6 +57,7 @@ function makeCtx({
|
|||||||
lastSleepAttemptAt,
|
lastSleepAttemptAt,
|
||||||
lastCurriculumAt,
|
lastCurriculumAt,
|
||||||
skillBackoff,
|
skillBackoff,
|
||||||
|
metrics,
|
||||||
dispatch(fn, label, opts = {}) {
|
dispatch(fn, label, opts = {}) {
|
||||||
dispatches.push({ fn, label, opts });
|
dispatches.push({ fn, label, opts });
|
||||||
},
|
},
|
||||||
@@ -82,6 +84,23 @@ test("disconnected snapshot → no dispatch", () => {
|
|||||||
assert.equal(dispatches.length, 0);
|
assert.equal(dispatches.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mode hit dispatches the returned skill immediately", () => {
|
||||||
|
const { ctx, dispatches } = makeCtx({
|
||||||
|
snapshot: {
|
||||||
|
connected: true,
|
||||||
|
health: 4,
|
||||||
|
food: 10,
|
||||||
|
hasFood: true,
|
||||||
|
inventory: { bread: 1 },
|
||||||
|
curriculum: { plan: { skillId: "gather.logs" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = runTick(ctx);
|
||||||
|
assert.equal(out.reflex, "mode:self_preservation");
|
||||||
|
assert.equal(out.action, "dispatched");
|
||||||
|
assert.equal(dispatches[0].label, "survive.eat");
|
||||||
|
});
|
||||||
|
|
||||||
test("defend wins over curriculum when hostile in melee", () => {
|
test("defend wins over curriculum when hostile in melee", () => {
|
||||||
const { ctx, dispatches } = makeCtx({
|
const { ctx, dispatches } = makeCtx({
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -279,6 +298,74 @@ test("wander-hint backoff swaps skill for wander on the next tick", () => {
|
|||||||
assert.equal(dispatches[0].label, "wander");
|
assert.equal(dispatches[0].label, "wander");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recent repeated skill timeouts trigger metric recovery", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { ctx, dispatches } = makeCtx({
|
||||||
|
snapshot: {
|
||||||
|
connected: true,
|
||||||
|
health: 20,
|
||||||
|
food: 20,
|
||||||
|
isDay: true,
|
||||||
|
curriculum: { plan: { skillId: "gather.logs" } },
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
snapshot: () => ({
|
||||||
|
"gather.logs": { ok: 0, fail: 3, lastTs: now, lastCode: "timeout" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = runTick(ctx);
|
||||||
|
assert.equal(out.reflex, "curriculum");
|
||||||
|
assert.equal(out.kind, "curriculum-metric-recovery");
|
||||||
|
assert.equal(dispatches[0].label, "explore.far");
|
||||||
|
assert.ok((ctx.skillBackoff?.["gather.logs"] ?? 0) > Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recent movement timeouts trigger tunnel recovery", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { ctx, dispatches } = makeCtx({
|
||||||
|
snapshot: {
|
||||||
|
connected: true,
|
||||||
|
health: 20,
|
||||||
|
food: 20,
|
||||||
|
isDay: true,
|
||||||
|
curriculum: { plan: { skillId: "gather.logs" } },
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
snapshot: () => ({
|
||||||
|
"explore.far": { ok: 0, fail: 1, lastTs: now, lastCode: "timeout" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = runTick(ctx);
|
||||||
|
assert.equal(out.reflex, "curriculum");
|
||||||
|
assert.equal(out.kind, "curriculum-metric-recovery");
|
||||||
|
assert.equal(dispatches[0].label, "recovery.tunnel-out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("successful tunnel recovery clears movement-timeout trigger", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { ctx, dispatches } = makeCtx({
|
||||||
|
snapshot: {
|
||||||
|
connected: true,
|
||||||
|
health: 20,
|
||||||
|
food: 20,
|
||||||
|
isDay: true,
|
||||||
|
curriculum: { plan: { skillId: "gather.logs" } },
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
snapshot: () => ({
|
||||||
|
"explore.far": { ok: 0, fail: 1, lastTs: now - 5_000, lastCode: "timeout" },
|
||||||
|
"recovery.tunnel-out": { ok: 1, fail: 0, lastTs: now, lastCode: "done" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = runTick(ctx);
|
||||||
|
assert.equal(out.reflex, "curriculum");
|
||||||
|
assert.equal(out.kind, "curriculum-skill");
|
||||||
|
assert.equal(dispatches[0].label, "gather.logs");
|
||||||
|
});
|
||||||
|
|
||||||
test("onComplete sets wander hint when skill recovery says so", () => {
|
test("onComplete sets wander hint when skill recovery says so", () => {
|
||||||
const { ctx, dispatches } = makeCtx({
|
const { ctx, dispatches } = makeCtx({
|
||||||
snapshot: {
|
snapshot: {
|
||||||
|
|||||||
@@ -45,13 +45,18 @@ export function situationHash(snapshot) {
|
|||||||
const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24
|
const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24
|
||||||
? snapshot.closestHostile.name
|
? snapshot.closestHostile.name
|
||||||
: "-";
|
: "-";
|
||||||
|
const biome = snapshot.biome ?? "-";
|
||||||
|
const blocks = Object.keys(snapshot.nearbyBlocks ?? {})
|
||||||
|
.sort()
|
||||||
|
.slice(0, 8)
|
||||||
|
.join(",") || "-";
|
||||||
// Inventory keys, sorted — we lose counts but keep "what kind of stuff do
|
// Inventory keys, sorted — we lose counts but keep "what kind of stuff do
|
||||||
// I have". Limited to first 10 names for hash stability.
|
// I have". Limited to first 10 names for hash stability.
|
||||||
const invKeys = Object.keys(snapshot.inventory ?? {})
|
const invKeys = Object.keys(snapshot.inventory ?? {})
|
||||||
.sort()
|
.sort()
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.join(",") || "-";
|
.join(",") || "-";
|
||||||
return `${cx},${cy},${cz}|${day}|${food}|${hp}|host:${hostile}|inv:${invKeys}`;
|
return `${cx},${cy},${cz}|${day}|${food}|${hp}|bio:${biome}|blocks:${blocks}|host:${hostile}|inv:${invKeys}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadScenarios() {
|
function loadScenarios() {
|
||||||
|
|||||||
@@ -42,11 +42,14 @@ function extractHeaderComment(src) {
|
|||||||
|
|
||||||
function skillFilePath(id) {
|
function skillFilePath(id) {
|
||||||
const slug = id.replace(/\./g, "-");
|
const slug = id.replace(/\./g, "-");
|
||||||
|
const tail = id.split(".").slice(1).join("-");
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(__dirname, "skills", `${slug}.js`),
|
path.join(__dirname, "skills", `${slug}.js`),
|
||||||
path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`),
|
path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`),
|
||||||
|
tail ? path.join(__dirname, "skills", `${tail}.js`) : null,
|
||||||
|
tail ? path.join(__dirname, "skills", `${tail.replace(/-/g, "_")}.js`) : null,
|
||||||
];
|
];
|
||||||
for (const p of candidates) if (fs.existsSync(p)) return p;
|
for (const p of candidates) if (p && fs.existsSync(p)) return p;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,75 @@
|
|||||||
// Per-skill ok/fail counters. Aggregated for the lifetime of the bot
|
// Per-skill ok/fail counters. Persisted under state/<host>/ so the bot's
|
||||||
// process (best-effort persistence is left for a future iteration —
|
// next run and auto-improvement prompts can learn from prior attempts,
|
||||||
// today's counters reset on restart, which keeps the data store
|
// not only the current process lifetime.
|
||||||
// simple while still being useful for incident bodies and the TUI).
|
|
||||||
|
|
||||||
export function createSkillMetrics() {
|
import fs from "node:fs";
|
||||||
const counts = new Map(); // id → { ok, fail, lastTs }
|
import path from "node:path";
|
||||||
|
import { stateDir } from "./config.js";
|
||||||
|
|
||||||
function record(id, ok) {
|
const METRICS_PATH = path.join(stateDir, "skill-metrics.json");
|
||||||
const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0 };
|
|
||||||
|
function loadMetrics() {
|
||||||
|
const counts = new Map();
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(METRICS_PATH, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
for (const [id, m] of Object.entries(parsed ?? {})) {
|
||||||
|
counts.set(id, {
|
||||||
|
ok: Number(m.ok ?? 0),
|
||||||
|
fail: Number(m.fail ?? 0),
|
||||||
|
lastTs: Number(m.lastTs ?? 0),
|
||||||
|
lastCode: m.lastCode ?? null,
|
||||||
|
lastDurationMs: Number(m.lastDurationMs ?? 0),
|
||||||
|
totalDurationMs: Number(m.totalDurationMs ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMetrics(counts) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
|
const out = {};
|
||||||
|
for (const [id, m] of counts) out[id] = { ...m };
|
||||||
|
const tmp = `${METRICS_PATH}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(out, null, 2));
|
||||||
|
fs.renameSync(tmp, METRICS_PATH);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSkillMetrics({ persist = true } = {}) {
|
||||||
|
const counts = persist ? loadMetrics() : new Map(); // id → { ok, fail, lastTs }
|
||||||
|
|
||||||
|
function record(id, ok, { code = null, durationMs = 0 } = {}) {
|
||||||
|
const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0, lastCode: null, lastDurationMs: 0, totalDurationMs: 0 };
|
||||||
if (ok) cur.ok++;
|
if (ok) cur.ok++;
|
||||||
else cur.fail++;
|
else cur.fail++;
|
||||||
cur.lastTs = Date.now();
|
cur.lastTs = Date.now();
|
||||||
|
cur.lastCode = code ?? cur.lastCode ?? null;
|
||||||
|
cur.lastDurationMs = Math.max(0, Math.round(durationMs || 0));
|
||||||
|
cur.totalDurationMs += cur.lastDurationMs;
|
||||||
counts.set(id, cur);
|
counts.set(id, cur);
|
||||||
|
if (persist) saveMetrics(counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [id, m] of counts) out[id] = { ...m };
|
for (const [id, m] of counts) {
|
||||||
|
const total = (m.ok ?? 0) + (m.fail ?? 0);
|
||||||
|
out[id] = {
|
||||||
|
...m,
|
||||||
|
avgDurationMs: total > 0 ? Math.round((m.totalDurationMs ?? 0) / total) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
counts.clear();
|
counts.clear();
|
||||||
|
if (persist) {
|
||||||
|
try { fs.unlinkSync(METRICS_PATH); } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { record, snapshot, reset };
|
return { record, snapshot, reset };
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// survive.acquire-food — turn "hungry and no edible item" into a concrete
|
||||||
|
// world action. The first implementation is intentionally conservative:
|
||||||
|
// pick up nearby drops if they are already visible, otherwise hunt a nearby
|
||||||
|
// passive animal. It does not harvest player-looking crops.
|
||||||
|
|
||||||
|
import pathfinderPkg from "mineflayer-pathfinder";
|
||||||
|
const { pathfinder, goals, Movements } = pathfinderPkg;
|
||||||
|
|
||||||
|
import { info, warn } from "../log.js";
|
||||||
|
import { foods } from "./groups.js";
|
||||||
|
|
||||||
|
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
|
||||||
|
|
||||||
|
let pluginLoaded = new WeakSet();
|
||||||
|
function ensurePathfinder(bot) {
|
||||||
|
if (pluginLoaded.has(bot)) return;
|
||||||
|
bot.loadPlugin(pathfinder);
|
||||||
|
pluginLoaded.add(bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMovementsForTravel(bot) {
|
||||||
|
const m = new Movements(bot);
|
||||||
|
m.canDig = true;
|
||||||
|
m.allow1by1towers = false;
|
||||||
|
bot.pathfinder.setMovements(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function foodCount(bot) {
|
||||||
|
const allowed = foods(bot);
|
||||||
|
return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestPassiveFoodMob(bot, maxDistance = 32) {
|
||||||
|
const here = bot?.entity?.position;
|
||||||
|
if (!here) return null;
|
||||||
|
let best = null;
|
||||||
|
for (const e of Object.values(bot.entities ?? {})) {
|
||||||
|
if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue;
|
||||||
|
const d = e.position.distanceTo(here);
|
||||||
|
if (d > maxDistance) continue;
|
||||||
|
if (!best || d < best.distance) best = { entity: e, distance: d };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearbyDroppedItems(bot, maxDistance = 8) {
|
||||||
|
const here = bot?.entity?.position;
|
||||||
|
if (!here) return [];
|
||||||
|
return Object.values(bot.entities ?? {})
|
||||||
|
.filter((e) => e?.position && (e.type === "object" || e.name === "item"))
|
||||||
|
.map((e) => ({ entity: e, distance: e.position.distanceTo(here) }))
|
||||||
|
.filter((e) => e.distance <= maxDistance)
|
||||||
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickupNearbyDrops(bot) {
|
||||||
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForTravel(bot);
|
||||||
|
let picked = 0;
|
||||||
|
for (const { entity } of nearbyDroppedItems(bot, 8).slice(0, 6)) {
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
bot.pathfinder.goto(new goals.GoalNear(entity.position.x, entity.position.y, entity.position.z, 1)),
|
||||||
|
8_000,
|
||||||
|
"gotoDrop",
|
||||||
|
);
|
||||||
|
picked++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (picked > 0) await new Promise((r) => setTimeout(r, 600));
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skill = Object.freeze({
|
||||||
|
id: "survive.acquire-food",
|
||||||
|
title: "Acquire a basic food item",
|
||||||
|
timeoutMs: 75_000,
|
||||||
|
preconditions(ctx) {
|
||||||
|
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||||
|
if (foodCount(ctx.bot) > 0) return { ok: false, code: "already_have", detail: "already carrying edible food" };
|
||||||
|
if (nearestPassiveFoodMob(ctx.bot) || nearbyDroppedItems(ctx.bot, 8).length > 0) return { ok: true };
|
||||||
|
return { ok: false, code: "no_target", detail: "no nearby food drops or passive food mobs" };
|
||||||
|
},
|
||||||
|
async execute(ctx) {
|
||||||
|
const bot = ctx.bot;
|
||||||
|
const before = foodCount(bot);
|
||||||
|
|
||||||
|
const picked = await pickupNearbyDrops(bot);
|
||||||
|
if (foodCount(bot) > before) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: { source: "drop", picked },
|
||||||
|
worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = nearestPassiveFoodMob(bot);
|
||||||
|
if (!target) return { ok: false, code: "no_target", detail: "no passive food mob visible", worldDelta: null };
|
||||||
|
|
||||||
|
ensurePathfinder(bot);
|
||||||
|
setMovementsForTravel(bot);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)),
|
||||||
|
30_000,
|
||||||
|
"pathToFoodMob",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, code: "no_path", detail: e.message, worldDelta: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id);
|
||||||
|
if (!current) break;
|
||||||
|
if (current.position.distanceTo(bot.entity.position) > 4) {
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
bot.pathfinder.goto(new goals.GoalFollow(current, 2)),
|
||||||
|
8_000,
|
||||||
|
"repathFoodMob",
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
bot.attack(current);
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1_000));
|
||||||
|
await pickupNearbyDrops(bot);
|
||||||
|
const after = foodCount(bot);
|
||||||
|
if (after <= before) {
|
||||||
|
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: { source: "hunt", mob: target.entity.name, gained: after - before },
|
||||||
|
worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
warn("action", `survive.acquire-food failed: ${e.message}`);
|
||||||
|
return { ok: false, code: "failed", detail: e.message, worldDelta: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recover(ctx, result) {
|
||||||
|
if (result.code === "no_target" || result.code === "no_path") {
|
||||||
|
return { hint: "wander", reason: "need to search for passive food mobs" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const _internal = { foodCount, nearestPassiveFoodMob };
|
||||||
@@ -121,6 +121,7 @@ export const skill = Object.freeze({
|
|||||||
},
|
},
|
||||||
async execute(ctx, { owned } = {}) {
|
async execute(ctx, { owned } = {}) {
|
||||||
const bot = ctx.bot;
|
const bot = ctx.bot;
|
||||||
|
const ownedLedger = owned ?? ctx.owned;
|
||||||
const planks = pickBuildPlanks(bot);
|
const planks = pickBuildPlanks(bot);
|
||||||
const base = getLocation("base") ?? getLocation(SHELTER_NAME);
|
const base = getLocation("base") ?? getLocation(SHELTER_NAME);
|
||||||
const center = { x: base.x, y: base.y, z: base.z };
|
const center = { x: base.x, y: base.y, z: base.z };
|
||||||
@@ -158,8 +159,8 @@ export const skill = Object.freeze({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock");
|
await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock");
|
||||||
if (owned?.markPlaced) {
|
if (ownedLedger?.markPlaced) {
|
||||||
owned.markPlaced({
|
ownedLedger.markPlaced({
|
||||||
x: target.x, y: target.y, z: target.z,
|
x: target.x, y: target.y, z: target.z,
|
||||||
blockType: planks.name,
|
blockType: planks.name,
|
||||||
skill: "village.build-shelter",
|
skill: "village.build-shelter",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const skill = Object.freeze({
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
async execute(ctx) {
|
async execute(ctx) {
|
||||||
const result = scoreCurrentPosition(ctx.bot);
|
const result = scoreCurrentPosition(ctx.bot, { isOwned: ctx.owned?.isOwned });
|
||||||
if (!result?.position) {
|
if (!result?.position) {
|
||||||
return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null };
|
return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// patch.
|
// patch.
|
||||||
|
|
||||||
import pathfinderPkg from "mineflayer-pathfinder";
|
import pathfinderPkg from "mineflayer-pathfinder";
|
||||||
const { pathfinder, goals, Movements } = pathfinderPkg;
|
const { pathfinder, Movements } = pathfinderPkg;
|
||||||
|
|
||||||
import { info, warn } from "../log.js";
|
import { info, warn } from "../log.js";
|
||||||
import { digEscapeTunnel } from "./recovery-tunnel-out.js";
|
import { digEscapeTunnel } from "./recovery-tunnel-out.js";
|
||||||
@@ -69,6 +69,7 @@ export const skill = Object.freeze({
|
|||||||
// this server mean we can't trust GoalNear; cardinal probing
|
// this server mean we can't trust GoalNear; cardinal probing
|
||||||
// gives us a free-direction signal cheaply.
|
// gives us a free-direction signal cheaply.
|
||||||
const dist = Math.max(24, args.distance ?? 48);
|
const dist = Math.max(24, args.distance ?? 48);
|
||||||
|
const beforeProbe = clonePos(bot.entity.position);
|
||||||
const trials = await probeCardinalStep(bot, 800);
|
const trials = await probeCardinalStep(bot, 800);
|
||||||
const movable = trials.filter((t) => t.dist > 0.5);
|
const movable = trials.filter((t) => t.dist > 0.5);
|
||||||
|
|
||||||
@@ -89,6 +90,16 @@ export const skill = Object.freeze({
|
|||||||
}
|
}
|
||||||
info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`);
|
info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`);
|
||||||
|
|
||||||
|
const probeMoved = horizontalDistance(beforeProbe, bot.entity.position);
|
||||||
|
if (probeMoved >= 2) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: { mode: "probe-moved", dir: best.name, moved: probeMoved },
|
||||||
|
worldDelta: { movedTo: clonePos(bot.entity.position) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (best.dist < 0.5) {
|
if (best.dist < 0.5) {
|
||||||
// All cardinals blocked. Try the cheap vertical escape first; if it
|
// All cardinals blocked. Try the cheap vertical escape first; if it
|
||||||
// does not actually move us, carve a short horizontal tunnel. The
|
// does not actually move us, carve a short horizontal tunnel. The
|
||||||
@@ -107,33 +118,19 @@ export const skill = Object.freeze({
|
|||||||
const tx = Math.round(here.x + Math.sin(-best.yaw) * dist);
|
const tx = Math.round(here.x + Math.sin(-best.yaw) * dist);
|
||||||
const tz = Math.round(here.z + Math.cos(-best.yaw) * dist);
|
const tz = Math.round(here.z + Math.cos(-best.yaw) * dist);
|
||||||
const ty = Math.round(here.y);
|
const ty = Math.round(here.y);
|
||||||
info("action", `explore.far: walking ${best.name} → ${tx},${ty},${tz}`);
|
info("action", `explore.far: blind-walking ${best.name} toward ${tx},${ty},${tz}`);
|
||||||
|
|
||||||
try {
|
|
||||||
await withTimeout(
|
|
||||||
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 4)),
|
|
||||||
45_000,
|
|
||||||
`explore.far(${tx},${tz})`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
ok: true, code: "done",
|
|
||||||
detail: { to: { x: tx, y: ty, z: tz }, dir: best.name },
|
|
||||||
worldDelta: { movedTo: { x: tx, y: ty, z: tz } },
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
warn("action", `explore.far pathfinder failed: ${e.message} — continuing blind`);
|
|
||||||
return blindWalkOrTunnelOut(bot, {
|
return blindWalkOrTunnelOut(bot, {
|
||||||
yaw: best.yaw,
|
yaw: best.yaw,
|
||||||
dirName: best.name,
|
dirName: best.name,
|
||||||
blindMs: args.blindMs ?? 7_000,
|
blindMs: args.blindMs ?? 7_000,
|
||||||
tunnelPushMs: args.tunnelPushMs,
|
tunnelPushMs: args.tunnelPushMs,
|
||||||
reason: `explore.far blind ${best.name}`,
|
reason: `explore.far blind ${best.name}`,
|
||||||
|
intended: { x: tx, y: ty, z: tz },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback" } = {}) {
|
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) {
|
||||||
const before = clonePos(bot.entity.position);
|
const before = clonePos(bot.entity.position);
|
||||||
try { await bot.look(yaw, 0, true); } catch {}
|
try { await bot.look(yaw, 0, true); } catch {}
|
||||||
bot.setControlState("forward", true);
|
bot.setControlState("forward", true);
|
||||||
@@ -150,7 +147,7 @@ async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMov
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
code: "done",
|
code: "done",
|
||||||
detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved },
|
detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved, intended },
|
||||||
worldDelta: { movedTo: clonePos(bot.entity.position) },
|
worldDelta: { movedTo: clonePos(bot.entity.position) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// survive.flee — emergency retreat from the nearest hostile. Unlike
|
||||||
|
// explore.far, this skill explicitly moves away from the hostile entity
|
||||||
|
// that triggered the mode.
|
||||||
|
|
||||||
|
import { fleeFrom } from "../actions.js";
|
||||||
|
|
||||||
|
const HOSTILE = new Set([
|
||||||
|
"zombie", "skeleton", "creeper", "spider", "witch", "pillager",
|
||||||
|
"vindicator", "husk", "stray", "drowned", "phantom", "enderman",
|
||||||
|
"slime", "magma_cube", "hoglin", "piglin_brute", "ravager", "warden",
|
||||||
|
"breeze", "bogged",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function nearestHostile(bot, { hostileName } = {}) {
|
||||||
|
const here = bot?.entity?.position;
|
||||||
|
if (!here) return null;
|
||||||
|
let best = null;
|
||||||
|
for (const e of Object.values(bot.entities ?? {})) {
|
||||||
|
if (!e?.position) continue;
|
||||||
|
const name = (e.name || "").toLowerCase();
|
||||||
|
if (hostileName && name !== String(hostileName).toLowerCase()) continue;
|
||||||
|
if (!hostileName && !HOSTILE.has(name)) continue;
|
||||||
|
const d = e.position.distanceTo(here);
|
||||||
|
if (!best || d < best.distance) best = { entity: e, distance: d };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skill = Object.freeze({
|
||||||
|
id: "survive.flee",
|
||||||
|
title: "Retreat from the nearest hostile",
|
||||||
|
timeoutMs: 40_000,
|
||||||
|
preconditions(ctx, args = {}) {
|
||||||
|
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||||
|
const hit = nearestHostile(ctx.bot, args);
|
||||||
|
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile entity" };
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async execute(ctx, args = {}) {
|
||||||
|
const hit = nearestHostile(ctx.bot, args);
|
||||||
|
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null };
|
||||||
|
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16);
|
||||||
|
if (res.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: { ...res.detail, from: hit.entity.name, distance: Math.round(hit.distance * 10) / 10 },
|
||||||
|
worldDelta: { fledTo: res.detail?.to ?? null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const msg = String(res.detail ?? "");
|
||||||
|
const code = msg.includes("timed out") ? "timeout" : "failed";
|
||||||
|
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||||
|
},
|
||||||
|
recover(ctx, result) {
|
||||||
|
if (result.code === "timeout") return { hint: "tunnel-out", reason: "flee path timed out" };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const _internal = { nearestHostile };
|
||||||
@@ -26,13 +26,17 @@ import { skill as chopLogs } from "./chop-logs.js";
|
|||||||
import { skill as eat } from "./eat.js";
|
import { skill as eat } from "./eat.js";
|
||||||
import { skill as wander } from "./wander.js";
|
import { skill as wander } from "./wander.js";
|
||||||
import { skill as exploreFar } from "./explore-far.js";
|
import { skill as exploreFar } from "./explore-far.js";
|
||||||
|
import { skill as flee } from "./flee.js";
|
||||||
|
import { skill as sleep } from "./sleep.js";
|
||||||
import { skill as tunnelOut } from "./recovery-tunnel-out.js";
|
import { skill as tunnelOut } from "./recovery-tunnel-out.js";
|
||||||
import { skill as diagPhysics } from "./diagnose-physics.js";
|
import { skill as diagPhysics } from "./diagnose-physics.js";
|
||||||
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
|
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
|
||||||
import { skill as gatherStone } from "./gather-stone.js";
|
import { skill as gatherStone } from "./gather-stone.js";
|
||||||
import { skill as gatherWool } from "./gather-wool.js";
|
import { skill as gatherWool } from "./gather-wool.js";
|
||||||
|
import { skill as acquireFood } from "./acquire-food.js";
|
||||||
import { skill as chooseBase } from "./choose-base.js";
|
import { skill as chooseBase } from "./choose-base.js";
|
||||||
import { skill as buildShelter } from "./build-shelter.js";
|
import { skill as buildShelter } from "./build-shelter.js";
|
||||||
|
import { skill as placeChest } from "./place-chest.js";
|
||||||
import { skill as depositSurplus } from "./deposit-surplus.js";
|
import { skill as depositSurplus } from "./deposit-surplus.js";
|
||||||
import { skill as farmWheat } from "./farm-wheat.js";
|
import { skill as farmWheat } from "./farm-wheat.js";
|
||||||
import {
|
import {
|
||||||
@@ -65,14 +69,18 @@ register(chopLogs);
|
|||||||
register(eat);
|
register(eat);
|
||||||
register(wander);
|
register(wander);
|
||||||
register(exploreFar);
|
register(exploreFar);
|
||||||
|
register(flee);
|
||||||
|
register(sleep);
|
||||||
register(tunnelOut);
|
register(tunnelOut);
|
||||||
register(diagPhysics);
|
register(diagPhysics);
|
||||||
register(diagScan);
|
register(diagScan);
|
||||||
register(diagMatch);
|
register(diagMatch);
|
||||||
register(gatherStone);
|
register(gatherStone);
|
||||||
register(gatherWool);
|
register(gatherWool);
|
||||||
|
register(acquireFood);
|
||||||
register(chooseBase);
|
register(chooseBase);
|
||||||
register(buildShelter);
|
register(buildShelter);
|
||||||
|
register(placeChest);
|
||||||
register(depositSurplus);
|
register(depositSurplus);
|
||||||
register(farmWheat);
|
register(farmWheat);
|
||||||
register(craftPlanksSkill);
|
register(craftPlanksSkill);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// village.place-chest — place the carried chest near the base/current
|
||||||
|
// footing and register it as "chest" in locations.json. This turns the
|
||||||
|
// storage milestone from "I crafted a chest item" into "I have a usable
|
||||||
|
// storage location".
|
||||||
|
|
||||||
|
import { setLocation, getLocation } from "../locations.js";
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function carriedChest(bot) {
|
||||||
|
return bot.inventory.items().find((i) => i.name === "chest" || i.name === "trapped_chest");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(block) {
|
||||||
|
return !block || block.boundingBox === "empty" || block.name === "air" || block.name === "cave_air" || block.name === "void_air";
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementCandidate(bot) {
|
||||||
|
const here = bot.entity.position.floored ? bot.entity.position.floored() : bot.entity.position;
|
||||||
|
const offsets = [
|
||||||
|
{ x: 1, z: 0 },
|
||||||
|
{ x: -1, z: 0 },
|
||||||
|
{ x: 0, z: 1 },
|
||||||
|
{ x: 0, z: -1 },
|
||||||
|
{ x: 2, z: 0 },
|
||||||
|
{ x: 0, z: 2 },
|
||||||
|
];
|
||||||
|
for (const off of offsets) {
|
||||||
|
const ref = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y - 1), z: Math.round(here.z + off.z) });
|
||||||
|
const target = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y), z: Math.round(here.z + off.z) });
|
||||||
|
if (ref?.boundingBox === "block" && isEmpty(target)) {
|
||||||
|
return { ref, face: { x: 0, y: 1, z: 0 }, at: { x: ref.position.x, y: ref.position.y + 1, z: ref.position.z } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skill = Object.freeze({
|
||||||
|
id: "village.place-chest",
|
||||||
|
title: "Place a personal chest",
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
preconditions(ctx) {
|
||||||
|
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||||
|
if (getLocation("chest")) return { ok: false, code: "already_have", detail: "chest location already exists" };
|
||||||
|
if (!carriedChest(ctx.bot)) return { ok: false, code: "missing_material", detail: "no chest item in inventory" };
|
||||||
|
if (!placementCandidate(ctx.bot)) return { ok: false, code: "no_space", detail: "no adjacent placeable slot" };
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async execute(ctx) {
|
||||||
|
const bot = ctx.bot;
|
||||||
|
const item = carriedChest(bot);
|
||||||
|
if (!item) return { ok: false, code: "missing_material", detail: "no chest item after precondition", worldDelta: null };
|
||||||
|
const place = placementCandidate(bot);
|
||||||
|
if (!place) return { ok: false, code: "no_space", detail: "no adjacent placeable slot", worldDelta: null };
|
||||||
|
try {
|
||||||
|
await withTimeout(bot.equip(item, "hand"), 3_000, "equip chest");
|
||||||
|
await withTimeout(bot.placeBlock(place.ref, place.face), 5_000, "place chest");
|
||||||
|
const loc = setLocation("chest", {
|
||||||
|
x: place.at.x,
|
||||||
|
y: place.at.y,
|
||||||
|
z: place.at.z,
|
||||||
|
dimension: ctx.snapshot?.dimension ?? "overworld",
|
||||||
|
radius: 2,
|
||||||
|
note: "auto-placed storage chest",
|
||||||
|
});
|
||||||
|
ctx.owned?.markPlaced?.({
|
||||||
|
x: loc.x,
|
||||||
|
y: loc.y,
|
||||||
|
z: loc.z,
|
||||||
|
dimension: loc.dimension,
|
||||||
|
blockType: item.name,
|
||||||
|
skill: "village.place-chest",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: { location: loc, item: item.name },
|
||||||
|
worldDelta: { chestAt: { x: loc.x, y: loc.y, z: loc.z }, placedType: item.name },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e?.message ?? "");
|
||||||
|
const code = msg.includes("timed out") ? "timeout" : "failed";
|
||||||
|
return { ok: false, code, detail: e.message, worldDelta: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const _internal = { placementCandidate };
|
||||||
@@ -280,7 +280,8 @@ export function rankTunnelDirections(bot, maxSteps = 3) {
|
|||||||
|
|
||||||
async function digOne(bot, block) {
|
async function digOne(bot, block) {
|
||||||
if (isPassableBlock(block)) return false;
|
if (isPassableBlock(block)) return false;
|
||||||
await equipLikelyTool(bot, block.name);
|
const tool = await equipLikelyTool(bot, block.name);
|
||||||
|
const timeoutMs = digTimeoutMs(block.name, tool);
|
||||||
try {
|
try {
|
||||||
if (typeof bot.lookAt === "function") {
|
if (typeof bot.lookAt === "function") {
|
||||||
await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`);
|
await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`);
|
||||||
@@ -288,7 +289,7 @@ async function digOne(bot, block) {
|
|||||||
} catch {
|
} catch {
|
||||||
// Dig may still work; do not abort on look jitter.
|
// Dig may still work; do not abort on look jitter.
|
||||||
}
|
}
|
||||||
await withTimeout(bot.dig(block), 10_000, `dig(${block.name})`);
|
await withTimeout(bot.dig(block), timeoutMs, `dig(${block.name})`);
|
||||||
const after = bot.blockAt(block.position);
|
const after = bot.blockAt(block.position);
|
||||||
if (after && !isPassableBlock(after) && after.name === block.name) {
|
if (after && !isPassableBlock(after) && after.name === block.name) {
|
||||||
throw new Error(`block still present after dig: ${block.name}`);
|
throw new Error(`block still present after dig: ${block.name}`);
|
||||||
@@ -296,6 +297,16 @@ async function digOne(bot, block) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function digTimeoutMs(blockName, equippedTool) {
|
||||||
|
const kind = toolKindFor(blockName);
|
||||||
|
if (!kind) return 12_000;
|
||||||
|
if (equippedTool?.includes(kind)) return 12_000;
|
||||||
|
if (kind === "pickaxe") return 25_000;
|
||||||
|
if (kind === "axe") return 18_000;
|
||||||
|
if (kind === "shovel") return 15_000;
|
||||||
|
return 12_000;
|
||||||
|
}
|
||||||
|
|
||||||
async function pushForward(bot, yaw, ms) {
|
async function pushForward(bot, yaw, ms) {
|
||||||
try { await bot.look(yaw, 0, true); } catch {}
|
try { await bot.look(yaw, 0, true); } catch {}
|
||||||
bot.setControlState("forward", true);
|
bot.setControlState("forward", true);
|
||||||
@@ -329,8 +340,16 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
|
|||||||
const before = posClone(bot.entity.position);
|
const before = posClone(bot.entity.position);
|
||||||
info("action", `tunnel-out: ${reason} → ${dir.name} (${dir.digTargets.length} blocks to clear)`);
|
info("action", `tunnel-out: ${reason} → ${dir.name} (${dir.digTargets.length} blocks to clear)`);
|
||||||
try {
|
try {
|
||||||
for (const target of dir.digTargets) {
|
let dug = 0;
|
||||||
await digOne(bot, target.block);
|
let lastStep = 0;
|
||||||
|
const byStep = [...dir.digTargets]
|
||||||
|
.sort((a, b) => (a.step - b.step) || (a.kind === "feet" ? -1 : 1));
|
||||||
|
for (const target of byStep) {
|
||||||
|
if (target.step !== lastStep && lastStep > 0) {
|
||||||
|
await pushForward(bot, dir.yaw, Math.min(pushMs, 900));
|
||||||
|
}
|
||||||
|
lastStep = target.step;
|
||||||
|
if (await digOne(bot, target.block)) dug++;
|
||||||
}
|
}
|
||||||
await pushForward(bot, dir.yaw, pushMs);
|
await pushForward(bot, dir.yaw, pushMs);
|
||||||
const moved = horizontalDistance(before, bot.entity.position);
|
const moved = horizontalDistance(before, bot.entity.position);
|
||||||
@@ -340,7 +359,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
code: "done",
|
code: "done",
|
||||||
detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug: dir.digTargets.length },
|
detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug },
|
||||||
worldDelta: { mode: "tunnel-out", movedTo },
|
worldDelta: { mode: "tunnel-out", movedTo },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -363,7 +382,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
|
|||||||
export const skill = Object.freeze({
|
export const skill = Object.freeze({
|
||||||
id: "recovery.tunnel-out",
|
id: "recovery.tunnel-out",
|
||||||
title: "Tunnel out of a wedged 1x1 hole",
|
title: "Tunnel out of a wedged 1x1 hole",
|
||||||
timeoutMs: 45_000,
|
timeoutMs: 120_000,
|
||||||
preconditions(ctx) {
|
preconditions(ctx) {
|
||||||
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ test("tunnel-out does not count jumping in place as escape", async () => {
|
|||||||
assert.match(res.detail.error, /moved only 0\.00 horizontally/);
|
assert.match(res.detail.error, /moved only 0\.00 horizontally/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tunnel-out digs one reachable layer at a time", async () => {
|
||||||
|
const blocks = {};
|
||||||
|
for (let step = 1; step <= 3; step++) {
|
||||||
|
blocks[`${step},64,0`] = "stone";
|
||||||
|
blocks[`${step},65,0`] = "stone";
|
||||||
|
blocks[`${step},63,0`] = "stone";
|
||||||
|
}
|
||||||
|
blocks["0,64,-1"] = "oak_planks";
|
||||||
|
blocks["0,64,1"] = "oak_planks";
|
||||||
|
blocks["-1,64,0"] = "oak_planks";
|
||||||
|
|
||||||
|
const bot = makeBot(blocks);
|
||||||
|
bot.look = async () => {};
|
||||||
|
bot.lookAt = async () => {};
|
||||||
|
bot.dig = async (block) => {
|
||||||
|
const dist = Math.hypot(block.position.x - bot.entity.position.x, block.position.z - bot.entity.position.z);
|
||||||
|
if (dist > 1.5) throw new Error(`too far: ${dist.toFixed(1)}`);
|
||||||
|
blocks[`${block.position.x},${block.position.y},${block.position.z}`] = "air";
|
||||||
|
};
|
||||||
|
bot.setControlState = (control, on) => {
|
||||||
|
if (control === "forward" && !on) {
|
||||||
|
bot.entity.position = makePos(bot.entity.position.x + 1, bot.entity.position.y, bot.entity.position.z);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await digEscapeTunnel(bot, { maxSteps: 3, minMove: 0.75, pushMs: 0 });
|
||||||
|
assert.equal(res.ok, true);
|
||||||
|
assert.equal(res.detail.dir, "E");
|
||||||
|
assert.equal(res.detail.dug, 6);
|
||||||
|
assert.equal(Math.round(bot.entity.position.x), 3);
|
||||||
|
});
|
||||||
|
|
||||||
test("explore.far blind fallback does not report done when position is unchanged", async () => {
|
test("explore.far blind fallback does not report done when position is unchanged", async () => {
|
||||||
const blocks = {};
|
const blocks = {};
|
||||||
const bot = makeBot(blocks);
|
const bot = makeBot(blocks);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// survive.sleep — use the action-layer bed primitive through the skill
|
||||||
|
// contract so priority modes can sleep without bypassing metrics,
|
||||||
|
// scenario-memory, current-task, and self-improvement evidence.
|
||||||
|
|
||||||
|
import { sleepInBed } from "../actions.js";
|
||||||
|
|
||||||
|
export const skill = Object.freeze({
|
||||||
|
id: "survive.sleep",
|
||||||
|
title: "Sleep in or place a carried bed",
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
preconditions(ctx) {
|
||||||
|
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||||
|
if (ctx.snapshot?.isDay) return { ok: false, code: "daytime", detail: "not night" };
|
||||||
|
const inv = ctx.snapshot?.inventory ?? {};
|
||||||
|
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
|
||||||
|
const knownBed = ctx.snapshot?.locations?.shelter || ctx.snapshot?.locations?.base;
|
||||||
|
if (!hasBed && !knownBed) {
|
||||||
|
return { ok: false, code: "missing_bed", detail: "no bed in inventory or known shelter" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async execute(ctx) {
|
||||||
|
const res = await sleepInBed(ctx.bot);
|
||||||
|
if (res.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: "done",
|
||||||
|
detail: res.detail,
|
||||||
|
worldDelta: { sleptAt: res.detail?.bedAt ?? ctx.snapshot?.position ?? null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const msg = String(res.detail ?? "");
|
||||||
|
const code = msg.includes("no bed")
|
||||||
|
? "missing_bed"
|
||||||
|
: msg.includes("timed out")
|
||||||
|
? "timeout"
|
||||||
|
: "failed";
|
||||||
|
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||||
|
},
|
||||||
|
recover(ctx, result) {
|
||||||
|
if (result.code === "missing_bed") return { hint: "curriculum", reason: "need bed milestone" };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -74,7 +74,7 @@ test("cooldown prevents back-to-back firings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("skill metrics record ok/fail and expose snapshot", () => {
|
test("skill metrics record ok/fail and expose snapshot", () => {
|
||||||
const m = createSkillMetrics();
|
const m = createSkillMetrics({ persist: false });
|
||||||
m.record("gather.logs", true);
|
m.record("gather.logs", true);
|
||||||
m.record("gather.logs", true);
|
m.record("gather.logs", true);
|
||||||
m.record("gather.logs", false);
|
m.record("gather.logs", false);
|
||||||
|
|||||||
+26
-4
@@ -275,7 +275,7 @@ function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = "idle" | "chat" | "ask-pi";
|
type Mode = "idle" | "chat" | "ask-pi" | "run-skill" | "incident";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
@@ -366,6 +366,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
if (input === "c") setMode("chat");
|
if (input === "c") setMode("chat");
|
||||||
if (input === "a") setMode("ask-pi");
|
if (input === "a") setMode("ask-pi");
|
||||||
|
if (input === "k") setMode("run-skill");
|
||||||
|
if (input === "v") client.send(COMMAND_TYPES.SCREENSHOT, { reason: "tui", frames: 1 });
|
||||||
|
if (input === "!") setMode("incident");
|
||||||
if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {});
|
if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,14 +380,33 @@ function App() {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text });
|
if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text });
|
||||||
else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text });
|
else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text });
|
||||||
|
else if (m === "run-skill") {
|
||||||
|
const [skillId, ...rest] = text.split(/\s+/);
|
||||||
|
let args = {};
|
||||||
|
const json = rest.join(" ").trim();
|
||||||
|
if (json) {
|
||||||
|
try { args = JSON.parse(json); }
|
||||||
|
catch {
|
||||||
|
client.send(COMMAND_TYPES.ASK_PI, { prompt: `Parse this run-skill argument JSON for ${skillId}: ${json}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.send(COMMAND_TYPES.RUN_SKILL, { skillId, args });
|
||||||
|
} else if (m === "incident") {
|
||||||
|
client.send(COMMAND_TYPES.FORCE_INCIDENT, { reason: text, kind: "operator-forced" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotkeyHint =
|
const hotkeyHint =
|
||||||
mode === "idle"
|
mode === "idle"
|
||||||
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit"
|
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [k] skill [v] screenshot [!] incident [y] proposals [q]uit"
|
||||||
: mode === "chat"
|
: mode === "chat"
|
||||||
? "chat → MC (Enter to send, Esc to cancel)"
|
? "chat → MC (Enter to send, Esc to cancel)"
|
||||||
: "ask-pi → spawn pi -p (Enter to send)";
|
: mode === "ask-pi"
|
||||||
|
? "ask-pi → spawn pi -p (Enter to send)"
|
||||||
|
: mode === "run-skill"
|
||||||
|
? 'run-skill → skill.id {"arg":true}'
|
||||||
|
: "incident → reason for critic/auto-improve proposal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
@@ -412,7 +434,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
|
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
|
||||||
{mode === "chat" ? "chat> " : "pi> "}
|
{mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
|
|||||||
Reference in New Issue
Block a user