T6-1: al_sdk::code — a starter harness + combat verb layer (CODE reads like JS CODE) #42

Merged
sleepy merged 1 commit from task/37-code-verb-layer into main 2026-09-24 21:53:10 +02:00
Owner

What this is

Issue #37 (T6-1). Rust CODE read like a graphics driver: examples/hello spent ~150 lines on connect_env → sink → on_death → auto_respawn → on_tick_async → run_for → exit table before a single tick ran, and the predicates made a user hand-build RangeInput / AttackInput / MonsterArgs. The JS starter (htmls/contents/codes/default_code.js) spends one line on scaffold (setInterval(fn, 250)) and its body is verbs.

This PR adds al_sdk::code — a starter harness and a combat verb layer — and ports both example crates onto it.

The new shape

// examples/starter/src/main.rs — 57 lines total, ~25 of them the body
Harness::new()
    .prefix("starter")
    .window_env("STARTER_SECONDS", None)   // None = keep running (the JS's shape)
    .run(|game, tick| Box::pin(async move {
        let _ = game.use_hp_or_mp().await;   // JS: use_hp_or_mp()
        let _ = game.loot().await;           // JS: loot()
        let Some(target) = game.pick_target(&MonsterArgs::none().max_att(120)).await else {
            game.set_message("No Monsters", None);
            return;
        };
        match game.engage(&target).await {   // JS: is_in_range / can_attack ladder
            Engage::Swung(_) => game.set_message("attacking", None),
            Engage::Walked(_) => game.set_message("walking", None),
            waiting => game.set_message(format!("waiting: {}", waiting.reason().unwrap_or("?")), None),
        };
    }))
    .await

code::Harness — the scaffold, once

Owns all eight: connect_env (with a plain-language failure on stderr), the stdout sink ({prefix}: {message}, one line per log line — what al run streams verbatim and what the browser's log panel shows), on_death (log + count), auto_respawn, on_tick_async at your interval, the run loop, Ctrl-C / SIGTERM, and the summary + exit code.

Two decisions worth a reviewer's eye:

  • The window is opt-in. With none, the loop is Game::run — the JS's "keep running" semantics — and only a signal or your own StopToken ends it. The signal watch is therefore on by default: it turns Ctrl-C / SIGTERM into request_stop, so a run ends as a decision (exit 0) rather than a signal death, which is the one distinction al-runtime's restart budget keys on.
  • The exit table is a knob, not a straitjacket. Default: chosen stop or elapsed window 0, feed died mid-run 2, refused login or a loop that never started 1. 2 for a drop is deliberate (0 would tell the supervisor "this program decided to stop"). A crate whose own docs promise otherwise maps it with Harness::exit — examples/hello does exactly that, and keeps its documented "a mid-demo drop is a clean-ish end" promise.

Summary and Outcome are separate types so a crate can render its own line and map its own codes; window_env names a typo or a 0 on stderr rather than turning it into a hang or a silent zero-length run.

code::verbs — the JS surface, one call each

engage (the whole if (!is_in_range) move(half-way) else if (can_attack) attack ladder, with every refusal named instead of silent), pick_target, change_target / clear_target, walk_to (one honest clamped leg), loot / loot_chest / chests over a drop-fed chest table carrying the JS's two safety rails (LOOT_MIN_INTERVAL, LOOT_MAX_PER_CALL, LOOT_REACH), attack_when_ready, and borrowed twins on Snapshot (targeted_monster, midpoint, step_toward).

verbs.rs opens with the JS-name → Rust-call table. Nothing invents a frame: each verb wraps an existing SDK primitive, or one of the two wire frames Outbound does not model yet (target, open_chest) through send_event, with the server-side line reference beside it. Banking / crafting / shops / duels are explicitly out of scope, as the brief scopes them.

Also in this PR

  • examples/hello ported onto the harness (acceptance), demo and contract intact: same tick, same walk, same summary fields, same exit table — now through Harness::exit. Its step_toward is replaced by Snapshot::step_toward. Its offline test now also asserts the credentials hint is absent on a refused handshake (that hint explains a missing trio, not a dead socket).
  • al serve: ALLOWED_CRATES gains examples/starter and DEFAULT_CRATE moves to it, so Compile → Run starts on the crate whose body is the loop. Both pinned in the existing defaults test.
  • rust/PLAN.md / rust/ARCHITECTURE.md note the new module and crate.

Verification

gate result
bash rust/dev/check.sh green (fmt, clippy -D warnings workspace --all-targets, all tests, 28 doctests)
new unit tests 15 harness + 21 verbs (exit table, window-knob rules, the drop / chest_opened / new_map / start chest fold, leg clamp + heading, the rate limit, every refusal naming itself)
cargo test -p starter 6 offline process tests green (missing trio, refused socket, window knob honoured / typo'd / zero'd)
cargo test -p starter -- --ignored 2 live tests green: a 20 s windowed run logged attacking 12× and ended outcome=finished exit 0; an open-ended run sent SIGTERM and printed outcome=stopped, exit 0
cargo test -p hello -- --ignored green (Clinker moved=240.0 legs=2 arrived=true, exit 0)
cargo test -p al-sdk --test live_game -- --ignored (AL_TEST_REQUIRE=1) 3 green
cargo test -p al-tool --test live_serve --test live_cli -- --ignored green (the browser sequence still drives hello; al build + al run still work)
al run examples/starter --creds dev/.local --once --build summary ticks=23 outcome=finished deaths=0 reconnects=0 → [al] exit 0: the child exited cleanly (0 restarts)
al run examples/hello --creds dev/.local --once summary … arrived=true moved=240.0 legs=2 → exit 0
al serve --crate examples/starter /options lists starter first with default_crate: examples/starter; /source?crate=examples/starter returns starter's real text

Layering rule held: both user crates depend on al-sdk only, plus tokio as the process runtime (same exception hello documented). No new crate, no al-client / al-protocol name in either crate's source. No unwrap / expect outside tests, no todo!, library errors stay thiserror.

Notes for the reviewer

  • The chest table is drained lazily from its own broadcast subscriber on each read rather than by a spawned task, because a Game is borrowed by every tick handler so nothing wanting &Game for its whole life can be tokio::spawned — the same reason on_tick_async exists. Game::chests is deliberately sync for that reason.
  • engage's refusals are values (Engage::Guarded / Nowhere / Failed), not log lines, so a farm can count them; the starter prints the reason as its status line.
  • Two live tests in one file run on parallel cargo threads, and two logins as one character is a server-side Failed: exception, so both take a process-wide "one live run at a time" lock (poison-tolerant: a failing live test must not wedge its sibling).

Co-Authored-By: Claude noreply@anthropic.com

## What this is Issue #37 (T6-1). Rust CODE read like a graphics driver: `examples/hello` spent ~150 lines on `connect_env` → sink → `on_death` → `auto_respawn` → `on_tick_async` → `run_for` → exit table before a single tick ran, and the predicates made a user hand-build `RangeInput` / `AttackInput` / `MonsterArgs`. The JS starter (`htmls/contents/codes/default_code.js`) spends **one line** on scaffold (`setInterval(fn, 250)`) and its body is verbs. This PR adds `al_sdk::code` — a **starter harness** and a **combat verb layer** — and ports both example crates onto it. ## The new shape ```rust // examples/starter/src/main.rs — 57 lines total, ~25 of them the body Harness::new() .prefix("starter") .window_env("STARTER_SECONDS", None) // None = keep running (the JS's shape) .run(|game, tick| Box::pin(async move { let _ = game.use_hp_or_mp().await; // JS: use_hp_or_mp() let _ = game.loot().await; // JS: loot() let Some(target) = game.pick_target(&MonsterArgs::none().max_att(120)).await else { game.set_message("No Monsters", None); return; }; match game.engage(&target).await { // JS: is_in_range / can_attack ladder Engage::Swung(_) => game.set_message("attacking", None), Engage::Walked(_) => game.set_message("walking", None), waiting => game.set_message(format!("waiting: {}", waiting.reason().unwrap_or("?")), None), }; })) .await ``` ## `code::Harness` — the scaffold, once Owns all eight: `connect_env` (with a plain-language failure on stderr), the stdout sink (`{prefix}: {message}`, one line per log line — what `al run` streams verbatim and what the browser's log panel shows), `on_death` (log + count), `auto_respawn`, `on_tick_async` at your interval, the run loop, Ctrl-C / SIGTERM, and the summary + exit code. Two decisions worth a reviewer's eye: - **The window is opt-in.** With none, the loop is `Game::run` — the JS's "keep running" semantics — and only a signal or your own `StopToken` ends it. The signal watch is therefore on by default: it turns Ctrl-C / SIGTERM into `request_stop`, so a run ends as a **decision** (exit `0`) rather than a signal death, which is the one distinction `al-runtime`'s restart budget keys on. - **The exit table is a knob, not a straitjacket.** Default: chosen stop or elapsed window `0`, feed died mid-run `2`, refused login or a loop that never started `1`. `2` for a drop is deliberate (`0` would tell the supervisor "this program decided to stop"). A crate whose own docs promise otherwise maps it with `Harness::exit` — `examples/hello` does exactly that, and keeps its documented "a mid-demo drop is a clean-ish end" promise. `Summary` and `Outcome` are separate types so a crate can render its own line and map its own codes; `window_env` names a typo or a `0` on stderr rather than turning it into a hang or a silent zero-length run. ## `code::verbs` — the JS surface, one call each `engage` (the whole `if (!is_in_range) move(half-way) else if (can_attack) attack` ladder, with every refusal **named** instead of silent), `pick_target`, `change_target` / `clear_target`, `walk_to` (one honest clamped leg), `loot` / `loot_chest` / `chests` over a `drop`-fed chest table carrying the JS's two safety rails (`LOOT_MIN_INTERVAL`, `LOOT_MAX_PER_CALL`, `LOOT_REACH`), `attack_when_ready`, and borrowed twins on `Snapshot` (`targeted_monster`, `midpoint`, `step_toward`). `verbs.rs` opens with the JS-name → Rust-call table. Nothing invents a frame: each verb wraps an existing SDK primitive, or one of the two wire frames `Outbound` does not model yet (`target`, `open_chest`) through `send_event`, with the server-side line reference beside it. Banking / crafting / shops / duels are explicitly out of scope, as the brief scopes them. ## Also in this PR - **`examples/hello` ported onto the harness** (acceptance), demo and contract intact: same tick, same walk, same summary fields, same exit table — now through `Harness::exit`. Its `step_toward` is replaced by `Snapshot::step_toward`. Its offline test now also asserts the credentials hint is **absent** on a refused handshake (that hint explains a missing trio, not a dead socket). - **`al serve`**: `ALLOWED_CRATES` gains `examples/starter` and `DEFAULT_CRATE` moves to it, so Compile → Run starts on the crate whose body *is* the loop. Both pinned in the existing defaults test. - `rust/PLAN.md` / `rust/ARCHITECTURE.md` note the new module and crate. ## Verification | gate | result | |---|---| | `bash rust/dev/check.sh` | green (fmt, `clippy -D warnings` workspace `--all-targets`, all tests, 28 doctests) | | new unit tests | 15 harness + 21 verbs (exit table, window-knob rules, the `drop` / `chest_opened` / `new_map` / `start` chest fold, leg clamp + heading, the rate limit, every refusal naming itself) | | `cargo test -p starter` | 6 offline process tests green (missing trio, refused socket, window knob honoured / typo'd / zero'd) | | `cargo test -p starter -- --ignored` | **2 live tests green**: a 20 s windowed run logged `attacking` 12× and ended `outcome=finished` exit 0; an open-ended run sent SIGTERM and printed `outcome=stopped`, exit 0 | | `cargo test -p hello -- --ignored` | green (Clinker `moved=240.0 legs=2 arrived=true`, exit 0) | | `cargo test -p al-sdk --test live_game -- --ignored` (`AL_TEST_REQUIRE=1`) | 3 green | | `cargo test -p al-tool --test live_serve --test live_cli -- --ignored` | green (the browser sequence still drives `hello`; `al build` + `al run` still work) | | `al run examples/starter --creds dev/.local --once --build` | `summary ticks=23 outcome=finished deaths=0 reconnects=0` → `[al] exit 0: the child exited cleanly (0 restarts)` | | `al run examples/hello --creds dev/.local --once` | `summary … arrived=true moved=240.0 legs=2` → `exit 0` | | `al serve --crate examples/starter` | `/options` lists starter first with `default_crate: examples/starter`; `/source?crate=examples/starter` returns starter's real text | **Layering rule held:** both user crates depend on `al-sdk` only, plus `tokio` as the process runtime (same exception `hello` documented). No new crate, no `al-client` / `al-protocol` name in either crate's source. No `unwrap` / `expect` outside tests, no `todo!`, library errors stay `thiserror`. ## Notes for the reviewer - The chest table is drained lazily from its own `broadcast` subscriber on each read rather than by a spawned task, because a `Game` is borrowed by every tick handler so nothing wanting `&Game` for its whole life can be `tokio::spawn`ed — the same reason `on_tick_async` exists. `Game::chests` is deliberately **sync** for that reason. - `engage`'s refusals are values (`Engage::Guarded` / `Nowhere` / `Failed`), not log lines, so a farm can count them; the starter prints the reason as its status line. - Two live tests in one file run on parallel cargo threads, and two logins as one character is a server-side `Failed: exception`, so both take a process-wide "one live run at a time" lock (poison-tolerant: a failing live test must not wedge its sibling). Co-Authored-By: Claude <noreply@anthropic.com>
T6-1: al_sdk::code — a starter harness and a combat verb layer, and starter/hello on it
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
99a55d508d
Rust CODE used to read like a graphics driver: `examples/hello` spends ~150
lines on connect_env, the sink, the death hook, auto_respawn, the tick
registration, the run loop, Ctrl-C and the exit table before one tick runs,
and the predicates hand-build `RangeInput`/`AttackInput`/`MonsterArgs`. The JS
starter spends one line (`setInterval(fn, 250)`) and its body is verbs.

New `al_sdk::code`, in two parts.

**`harness`** — `Harness::new().run(|game, tick| ..)` owns the eight things
every CODE crate repeats and hands the user only the async tick body, with
both the handle (for actions) and the borrowed `Snapshot` (for reads). The
window is opt-in: with none, the loop is `Game::run` — the JS's "keep running"
semantics — and only Ctrl-C / SIGTERM or your own `StopToken` ends it, which is
why the signal watch is on by default: it turns both into `request_stop`, so
the run ends as a decision (exit 0) rather than a signal death a supervisor may
restart. Default exit table: chosen stop or elapsed window `0`, a feed that
died mid-run `2`, refused login or a loop that never started `1`; the table is a
knob (`Harness::exit`), the sink is a knob, `window_env` names a typo or a `0`
on stderr instead of turning it into a hang or a zero-length run, and `Summary`
/ `Outcome` are separate so a crate can map its own codes.

**`verbs`** — `engage` (the `is_in_range` / `can_attack` ladder as one await,
every refusal named), `pick_target` (`get_targeted_monster`, else
`get_nearest_monster` + `change_target`), `change_target` / `clear_target`,
`walk_to` (one honest clamped leg), `loot` / `loot_chest` / `chests` over a
`drop`-fed chest table with the JS's two safety rails, `attack_when_ready`, and
the borrowed twins on `Snapshot`. Nothing invents a frame: each wraps an SDK
primitive or one of the two wire frames `Outbound` does not model yet, through
`send_event`, and every item carries its JS name and file:line.

**`examples/starter`** is the crate the `/rust-code` editor opens on: 57 lines,
of which the body is ~25 — potion, loot, pick a target, engage. `main()` is one
builder chain and a closure.

**`examples/hello`** is ported onto the harness with its own contract intact:
its docs promise a mid-demo drop is a clean-ish end (`0` with a message), not the
harness default, so it says so through `Harness::exit`. Its offline test now
also asserts the credentials hint is *absent* on a refused handshake — that hint
explains a missing trio, not a dead socket.

`al serve`: `ALLOWED_CRATES` gains `examples/starter` and `DEFAULT_CRATE` moves
to it, pinned in the existing defaults test.

Tests: 15 harness + 21 verb unit tests (exit table, window-knob rules, the
`drop`/`chest_opened`/`new_map`/`start` fold, leg clamping and heading, the rate
limit, every refusal naming itself), 6 offline process tests for `starter`, 2
live tests (a 20 s windowed run that really swings; an open-ended run ended by
SIGTERM reporting `outcome=stopped` and exit 0), plus `hello`'s 15. Layering
holds: both user crates depend on `al-sdk` (+ `tokio` as the process runtime)
and nothing else.

Verified: `bash rust/dev/check.sh` green (fmt, `clippy -D warnings`, tests, 28
doctests); `--ignored` live suites green for al-sdk / hello / starter / al-tool
(hello moved 240 px, starter swung 12 times, SIGTERM exit 0); `al run
examples/starter --once --build` and `al run examples/hello --once` both "exit 0:
the child exited cleanly"; `al serve --crate examples/starter` answers `/options`
with starter first and `/source` with starter's real text.
sleepy force-pushed task/37-code-verb-layer from 99a55d508d
Some checks are pending
Code Quality / prettier (push) Waiting to run
Code Quality / prettier (pull_request) Waiting to run
to 7612e4dc3a
Some checks failed
Code Quality / prettier (push) Has been cancelled
Code Quality / prettier (pull_request) Has been cancelled
2026-09-24 21:52:53 +02:00
Compare
sleepy merged commit 9e1f6df832 into main 2026-09-24 21:53:10 +02:00
sleepy deleted branch task/37-code-verb-layer 2026-09-24 21:53:10 +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!42
No description provided.