[rust] T5-1 farm_basic: Travel->Fight->Heal->Repeat example (#17) #36

Merged
sleepy merged 7 commits from task/17-farm-basic into main 2026-09-24 11:40:07 +02:00
Owner

Closes #17. New examples/farm_basic crate: full farming loop over on_tick_async — Travel (waypoint legs, new_map retry), Fight (target acquisition, cooldown handling, kill ledger), Heal (potion gate at 40% hp), death -> auto_respawn -> re-plan. 10-min acceptance run: 26 crab kills, level 9->16 (7 level-ups), 38 potions, 0 deaths, exit 0. Live test (90s, Clinker, #[ignore]): green.

Closes #17. New examples/farm_basic crate: full farming loop over on_tick_async — Travel (waypoint legs, new_map retry), Fight (target acquisition, cooldown handling, kill ledger), Heal (potion gate at 40% hp), death -> auto_respawn -> re-plan. 10-min acceptance run: 26 crab kills, level 9->16 (7 level-ups), 38 potions, 0 deaths, exit 0. Live test (90s, Clinker, #[ignore]): green.
[rust] T5-1 farm_basic: Travel->Fight->Heal->Repeat over on_tick_async (#17)
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
bee4e88e17
The first user crate that scales past `hello`: a program that depends on
`al-sdk` and `tokio` (the process runtime) and nothing else — no
`al-client`, no `al-protocol`, no frame in `src/` or `tests/`.

`src/lib.rs` is the whole automation, `src/main.rs` the runtime wrapper, so
the offline suite drives the state machine as data:

* `decide(&mut Machine, &View) -> Action` — pure, no socket. Rules in order:
  dead → wait for auto-respawn; hp < 40% → Heal until 60%; a farmable mob in
  reach → Attack; out of reach → one 108px leg at it; nothing visible →
  Travel the walkability-verified route, then sweep the zone.
* Targets are re-picked from every frame (`View::of` → `Mob::candidate` →
  `View::pick`), so a mob that died, left the vision table or got engaged by
  somebody else stops being the target on the next tick instead of being
  chased from a stale id.
* Refusals are data. `can_move_to_guard` before a leg, and the server's own
  `on_cooldown` / `too_far` / `move_ignored` after: one throttled log line,
  `refused=` in the summary, retry on the next tick. Nothing is fatal, and
  nothing unwraps outside the tests.
* Death is normal: `on_death` logs, `auto_respawn` gets us up, the machine
  re-plans from the fresh frame — `Travel` on respawn, never the pre-death
  picture.
* One parseable line at the end, and an exit-code table that keeps a demo
  from failing on a hiccup (`Disconnected` is a partial farm, not an error).

Two findings from the live runs, both in the code:

* The leg clamp had to move. `zone.clamp(step_toward(our, mob, LEG))` —
  clamp the leg, then the boundary — was a wedge: stand east of the beach,
  aim at a crab in the middle, and the 108px point is still east of the
  farm, so clamping pulls it onto the boundary several hundred px away, past
  `MAX_LEG_DISTANCE`, which our own `can_move_to_guard` refuses forever
  (`refused=89`, `legs=0` in a captured run). Clamping the destination before
  cutting the leg keeps a leg ≤ `LEG` by construction and still cannot be
  led off the map. `offline.rs` tests that geometry directly.
* `FARM_ZONE` names a kind and a boundary; the four (`crab`, `squig`,
  `tortoise`, `frog`) are the mobs the design data can rank, and
  `target*`/cute mobs are excluded by `Mob::candidate` — a training dummy is
  not a farm.

Offline: 39 tests, no dev-dependencies, no socket — phase ladder, target
re-pick, refusal paths, route geometry, the summary line's parse, the exit
table, and the xp/swing arithmetic regenerated from `design/levels.js`.
Live: one `#[ignore]`d 90 s crab farm that spawns the built binary and
asserts exit 0 and `kills > 0` off the summary line's own fields.

`bash rust/dev/check.sh`: green. The 90 s live test just banked 6 crabs,
level 5 -> 9, 3 level-ups, exit 0.
Author
Owner

Review: CHANGES_REQUESTED. Verified good: layering (no crates/* change), cooldown/heal-gate/targeting correct, routes+spawns+mob stats independently confirmed, 39 offline tests + 1 correctly-gated live test, no secrets in logs.

Blocking:

  1. Post-death travel can jail the character (lib.rs:781-786, 812). After BLIND_TICKS the verified polyline is discarded and a straight 120px leg is cut at zone.center(). Replayed from DEATH_SPAWN [-87,673] against the server walkability table (node/precomputed_map_data.js, the smap_data move is checked against, node/server.js:10579-10608): legs (-527,347)-(-629,284) (cell 2) and (-629,284)-(-732,222) (undefined) land on exactly the cells the server reads as a Line violation, which does defeat_player + transport to jail. The SDK cannot catch it (no walkability data). From SPAWN the line is clean, so fresh login looks fine and every death-originated travel is at risk. This reproduced live (a test character ended in jail). Fix: re-centre by re-entering the nearest verified waypoint, or clamp the destination to a walk-verified point - never a straight line to the centre. Add an offline regression test replaying the post-respawn travel (from DEATH_SPAWN, no mob in vision, blind re-centre firing) and asserting every emitted leg is walkable per the server table.
  2. Disconnect masquerades as a voluntary stop. send_failure (lib.rs:1782-1790) turns a closed socket into game.request_stop(), so a mid-run drop reports outcome=stopped; and exit_code_for (lib.rs:1842-1844) maps Disconnected to 0. So a server dying 5s in yields exit 0, kills=0, no restart, and passes an exit-0 acceptance gate (crates/al-runtime/src/exit.rs:14,37). Fix: let a closed socket reach StopReason::Disconnected (not Stopped), and give Disconnected a non-zero/distinct exit code. Update the docs table (lib.rs:127-137) and any test asserting the old behaviour.
  3. Loot/gold half of the loop is absent (brief acceptance: levels up / gains gold). Gold only enters via chest_opened (node/server.js:10714); crab/squig/tortoise/frog drop no chests, so gains gold is unachievable for this crate and not evidenced (Summary prints terminal gold= only, not A to B). Orchestrator decision: narrow acceptance to levels up and document that gold is chest-only and out of scope for farm_basic. Do NOT add chest handling.
  4. Kills-vs-levels do not reconcile. 9 to 16 needs 15,400 xp (design/levels.js) but 26 kills x 500 = 13,000; the run is only reconcilable via mob xp growth (monster.xp increases with mob level, node/server.js:12452). Fix the fixed-500-xp assumption in docs/tests to reflect growth, and carry levelups=7 and xp= in the PR body/evidence.

Non-blocking: delete never-called Zone::from_env (lib.rs:296-310; misleading doc at lib.rs:968); the Event::GameLog auto-respawn arm (lib.rs:1581-1583) can never fire (the SDK sends that line only to the sink, never as an Event) - remove it and fix the two-doors doc at lib.rs:1576-1579; rebase onto current main (it advanced to ba5cea3), which fixes the PLAN.md diff that un-ticks #17 and reverts #16's in-progress marker.

Review: CHANGES_REQUESTED. Verified good: layering (no crates/* change), cooldown/heal-gate/targeting correct, routes+spawns+mob stats independently confirmed, 39 offline tests + 1 correctly-gated live test, no secrets in logs. **Blocking:** 1. **Post-death travel can jail the character** (lib.rs:781-786, 812). After BLIND_TICKS the verified polyline is discarded and a straight 120px leg is cut at zone.center(). Replayed from DEATH_SPAWN [-87,673] against the server walkability table (node/precomputed_map_data.js, the smap_data move is checked against, node/server.js:10579-10608): legs (-527,347)-(-629,284) (cell 2) and (-629,284)-(-732,222) (undefined) land on exactly the cells the server reads as a Line violation, which does defeat_player + transport to jail. The SDK cannot catch it (no walkability data). From SPAWN the line is clean, so fresh login looks fine and every death-originated travel is at risk. This reproduced live (a test character ended in jail). Fix: re-centre by re-entering the nearest verified waypoint, or clamp the destination to a walk-verified point - never a straight line to the centre. Add an offline regression test replaying the post-respawn travel (from DEATH_SPAWN, no mob in vision, blind re-centre firing) and asserting every emitted leg is walkable per the server table. 2. **Disconnect masquerades as a voluntary stop.** send_failure (lib.rs:1782-1790) turns a closed socket into game.request_stop(), so a mid-run drop reports outcome=stopped; and exit_code_for (lib.rs:1842-1844) maps Disconnected to 0. So a server dying 5s in yields exit 0, kills=0, no restart, and passes an exit-0 acceptance gate (crates/al-runtime/src/exit.rs:14,37). Fix: let a closed socket reach StopReason::Disconnected (not Stopped), and give Disconnected a non-zero/distinct exit code. Update the docs table (lib.rs:127-137) and any test asserting the old behaviour. 3. **Loot/gold half of the loop is absent** (brief acceptance: levels up / gains gold). Gold only enters via chest_opened (node/server.js:10714); crab/squig/tortoise/frog drop no chests, so gains gold is unachievable for this crate and not evidenced (Summary prints terminal gold= only, not A to B). Orchestrator decision: narrow acceptance to levels up and document that gold is chest-only and out of scope for farm_basic. Do NOT add chest handling. 4. **Kills-vs-levels do not reconcile.** 9 to 16 needs 15,400 xp (design/levels.js) but 26 kills x 500 = 13,000; the run is only reconcilable via mob xp growth (monster.xp increases with mob level, node/server.js:12452). Fix the fixed-500-xp assumption in docs/tests to reflect growth, and carry levelups=7 and xp= in the PR body/evidence. **Non-blocking:** delete never-called Zone::from_env (lib.rs:296-310; misleading doc at lib.rs:968); the Event::GameLog auto-respawn arm (lib.rs:1581-1583) can never fire (the SDK sends that line only to the sink, never as an Event) - remove it and fix the two-doors doc at lib.rs:1576-1579; rebase onto current main (it advanced to ba5cea3), which fixes the PLAN.md diff that un-ticks #17 and reverts #16's in-progress marker.
sleepy force-pushed task/17-farm-basic from bee4e88e17
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
to 698f49cf24
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
2026-09-22 16:50:45 +02:00
Compare
rust(farm_basic): never emit a leg the server's grid rejects (#36)
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
f98fd23a42
Review item 1. Post-death travel could send a straight line from `DEATH_SPAWN`
toward `zone.center()`, and the server answers a leg whose endpoints miss its
walkability table with `Line violation detected`, `defeat_player` and
`transport_player_to(player, "jail")` (`node/server.js:10579`) — three of those
and the socket is dropped (`node/server.js:14315`). This is the one refusal in
the loop that is not a log line and a retry, and `al-sdk` does not check it: it
validates how *far* a leg is, never where it *lands*.

The crate now answers the server's question itself:

* `Zone::verified_ground` / `ground_gap` — a point is standing ground if it is
  within `GROUND_SLACK` of a hand-verified polyline or inside a farm boundary,
  the two coordinate sets this crate has actually proven walkable.
* `step_on_verified_ground` — the crate's only producer of a destination, for
  both phases. Shortens a leg to the last point still on that ground, and says
  `None` when there is no leg: a held tick (`Action::Wait`), which costs 500 ms,
  rather than a guessed one, which costs a life and a cell.
* `Zone::corridor_head` — outside the boundary the head is the far end of the
  polyline *segment* under us, asked fresh every frame. Nearest-waypoint made a
  corner behind us outrank one ahead, and let the two crab routes that share
  their last stretch be joined mid-walk into a loop.
* `Zone::nearest_verified` — off verified ground (a transport, a knockback) the
  destination is the nearest corridor or boundary point. `BLIND_TICKS` now
  re-centres on the centre only *inside* the boundary, where all 1140/2124/3680
  cells are open and a chord cannot leave it.

No table at runtime: a user crate depends on `al-sdk` and nothing else, so the
13 MB grid stays out of the crate and in the suite. `rust/dev/gen_walkability.mjs`
digests it to 12 KB (`data/main_walkable_rows.txt`, byte-identical to what the
script generates), and five new tests use it — the subset audit over ~380k
points, the JS `smap_round` tie-cases, and replays from spawn, the death spawn
and mid-corridor that assert no emitted leg has an endpoint or a crossed cell
the server does not know. They fail on the shipped decision (12 refused legs on
the crab route alone, first one leaving the corridor at `(-78.6, 547.1)`) and
pass on this one. `cargo fmt`/`clippy -D warnings`/`test --workspace` are clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review item 2. `send_failure` turned a closed socket into
`game.request_stop()`, so a mid-run drop reported `outcome=stopped`, and
`exit_code_for` gave `Disconnected` a `0` — a server dying 5 s into a 600 s run
yielded exit 0, `kills=0`, and no restart, because `al-runtime` reads `0` as
"the user chose to stop" and never restarts a clean child
(`crates/al-runtime/src/exit.rs`: `clean` *is* `code == Some(0)`).

The crate now observes the drop and re-reports it:

* `Ledger::note_feed_died` records the fact once; `send_failure` calls it
  **before** asking for the stop, so the stop can no longer be mistaken for the
  reason the run ended.
* `run_tick` asks `Game::is_connected` before deciding anything — that catches a
  server going away *between* actions, which never reaches `send_failure`.
* `honest_outcome(facts, feed_died)` upgrades a `finished`/`stopped` verdict to
  `disconnected` when the crate saw the socket die. This rung is not decoration:
  probing the live stack showed `Game::close` does not close the loop's inbound
  channel (the pump keeps its sender alive), so a dropped run keeps ticking
  against a server that is not there and answers `Finished` — `Stopped` and
  `Finished` are precisely the two verdicts that cannot be trusted here. An
  `Error` verdict keeps its own reason and code.
* `verdict` reads the settled rendering, so `outcome=` on the summary line and
  the exit status are the same one string and cannot drift.
* `EXIT_CLEAN` / `EXIT_ERROR` / `EXIT_DISCONNECTED` name the table; the docs
  table at the head of the crate says `2` and why.

The new test asserts all three rungs: the code, `honest_outcome`'s four cases
plus the rendered `outcome=disconnected` line, and — since the ordering lives
only in the source — that `note_feed_died` precedes `request_stop` in
`send_failure`, that the tick's `is_connected` check precedes the decision, and
that the summary and the status read one string. Reordering the two calls in
`send_failure` does fail it. `cargo fmt`/`clippy -D warnings`/
`test --workspace` are green.
Two live findings from re-running the acceptance farm, and the review's
non-blocking list.

**Ground truth, not geometry.** `Zone::verified_ground` was a geometric
predicate over hand-verified corridors and the farm margin, and it was too
narrow: a character standing at (24.7, 196.5) — in the middle of town, on
ground the server walks on every day — logged `off verified ground, and the
way back is further than one leg` and sat there. The new `ground` module reads
the digest of the table the server actually consults
(`node/precomputed_map_data.js`'s `smap_data.main`, via
`data/main_walkable_rows.txt`), cell for cell, with `smap_round`'s own
truncation spelled out. Walking is a lattice question, so it is answered off
the lattice: `walkable_path` is a bounded breadth-first search over walkable
cells and `travel` now distinguishes *ground* ("may we stand here") from
*road* ("does standing here say where to step next"), and routes to the road
instead of reporting itself stranded. The 691,896-point audit compares every
read of the crate's predicate against an independently parsed copy of the
table and reports 0 disagreements; the direction that can cost a jail cell —
the crate accepting a point the server rejects — fails the test at its first
instance.

**A bar we cannot pay with is a bar that needs a drink.** A level-16 warrior
pays 3 mp a swing and the server refuses an unaffordable one outright
(`no_mp`, `node/server.js:3076`), so a run with an empty bar banks
`attacks=0 kills=0` and exits 0. `decide` now opens the `Heal` gate on mp as
well as hp, and does so on the server's own `no_mp` as well as on a ratio —
the ratio floors are the SDK ladder's two mp rungs (0.5 / 0.75) rather than
numbers this crate invented, because the floor does not choose the potion, the
ladder does, and a gate that wants a drink below a line the ladder does not
drink below sits in `Heal` listening to itself answer `Full`.

**Dead at login never wakes up.** `Game::auto_respawn` is armed by a death
*edge*; a character already dead when the window opens produces none, and the
crate spent a real 90 s in `Wait("dead — waiting for auto-respawn")`, refused
every action as `disabled`, and exited 0. After the server's own 12 s
`rip_time` grace the crate asks for the respawn itself, on a tick budget so
`decide` stays pure, and counts the death it never saw so the summary cannot
read `deaths=0 respawns=1`.

Review nits: `Zone::from_env` is gone (reading the environment is
`RuntimeEnv`'s job, once), and the `Event::GameLog` respawn arm is gone — the
SDK's "auto-respawn" line goes to the log sink and never becomes an event, so
that door could not fire.

Offline: 50 tests. `bash rust/dev/check.sh` green.
rust(farm_basic): a fight is committed to one mob, and the summary says why (#36)
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
7470d42f2e
The acceptance farm was printing `attacks=39 kills=0`, and every accepted
swing in it. The loop was working; the *pick* was not.

`View::pick` answered "which mob should we hunt" as "the nearest one", and
re-derived that answer from every frame. On a beach of eight respawning
crabs that is a damage distributor: the crab we had spent twenty swings on
and the crab that happened to shuffle two px closer trade places between
frames, so the swings landed on eight animals. One crab is now 3400 hp — a
probe of the live beach read every one of them there, against a design
default of 400, because `level_monster` (node/server.js:12444) grows a mob
while it lives — so 39 swings spread over eight crabs is not a slow farm,
it is a farm that cannot kill, and each of its 39 swings logged as a
success.

Two rules now, in order: keep the mob we are already hunting while it is
still a candidate, and when choosing fresh, take the one nearest death
(`monster_to_client` at node/server.js:902-917 sends `hp` only when it
differs from the design default, so "no hp field" *is* "youngest and
cheapest" — the ranking needs no design table). Distance is the last
criterion: it says what the walk costs, hp says whether the walk is worth
taking. The commitment is re-checked against every frame, so it cannot
outlive the kill or chase an id that despawned.

Also here, because it is what found the bug: `refused_by=` on the summary
line, refusals grouped by the word that refused them. Until it existed the
total was parseable and the reason was not — `refused=384` with the
breakdown only in `chatter`-throttled log lines, i.e. in a sample, in the
one report written to be read. My first diagnosis of a wedged run was wrong
because of that gap (it blamed `already_moving` and a too-short window;
`refused_by=on_cooldown:121,already_moving:11` said otherwise) and
`FARM_SECONDS`' comment now says so, rather than shipping the wrong story.
Same window, same server, `kills=0` -> `kills=2 level 16->18 levelups=1`,
exit 0.
rust(farm_basic): say where gold actually moves, and stop calling 500 xp a crab (#36)
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
4cf8bde1cc
The review's third item is right that the loot/gold half of the loop is
absent, and `rust/PLAN.md` #17 does ask for "gains gold", so the answer is
to say so plainly rather than to imply the brief asked for less. It is a
scope call, and the crate's module docs now carry it as one:

* A monster kill does not pay the killer. `drop_something`
  (node/server.js:2116) computes what a mob is worth and puts it in a chest
  on the ground (`chests[drop_id]`, drop.gold at node/server.js:2143-2147).
  Nothing touches `player.gold`.
* The purse moves in one handler: `open_chest` at node/server.js:10714. And
  `open_chest` is not in the SDK — `al-protocol::Outbound` models auth,
  loaded, send_updates, move, attack, use, say — so an example whose whole
  subject is "depend on al-sdk, never name a raw frame" has no door to a
  chest. The honest order for the gold half is `open_chest` into the SDK,
  then a chest-walking example on top.

The xp section needed the same treatment: it claimed "a crab is worth 500
xp", true of a fresh crab only. `level_monster` (node/server.js:12444-12453)
adds `G.monsters[type].xp` *again* per mob level alongside `hp / 2` of max
hp, so the 3400-hp crab a live run actually fought was level 16 and paid
~8000. A new test derives both from the committed `G` rather than from a
comment, including the ~33 swings / ~56 s figure that `FARM_SECONDS` leans
on — and says the quiet part: 56 s of pure hitting nearly fills the 90 s
window this test used to run with, which is why the window is longer, while
the reason `kills=0` printed was the target spreading, not the window.
sleepy force-pushed task/17-farm-basic from 4cf8bde1cc
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
to ecb43a600a
Some checks failed
Code Quality / prettier (push) Has been cancelled
Code Quality / prettier (pull_request) Has been cancelled
2026-09-24 11:38:11 +02:00
Compare
sleepy merged commit 19977d60de into main 2026-09-24 11:40:07 +02:00
sleepy referenced this pull request from a commit 2026-09-24 11:42:50 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sleepy/adventureland_mongodb!36
No description provided.