[rust] T2-3 live integration gate + auto-reconnect (#9) #27

Merged
sleepy merged 3 commits from task/9-live-integration-gate into main 2026-09-20 05:55:08 +02:00
Owner

T2-3 the Phase-2 GATE (issue #9)

The first full end-to-end proof that the Rust client drives a real character, plus the last piece of the original T2-1 scope (auto-reconnect with backoff) and the use_potion action.

The GATE (tests/live_integration_gate.rs, #[ignore]d)

One connected session, five steps, each its own function so a failure names the step:

  1. login — full handshake, start decoded, alive on main, hp > 0;
  2. move — move_to a 60 px leg; the server's settle frame confirms the position changed and the char settled;
  3. attack — walk up to the nearest spawned monster, attack it; assert the server's authoritative game_response and that the monster's hp drops across the entities snapshots;
  4. potion — use_potion("hp"); server success + the shared 4 s cooldown is tracked; a second drink of either potion is refused locally by the shared gate;
  5. say — server's authoritative game_response {place:"say"}.

Logs in as the dedicated test character (AL_TEST_* — "Rustler"), falling back to AL_DEV_* only when the test creds are absent, so it never collides with a human (or another live suite) on the same socket (dev/LOCAL.md). Skips cleanly (not fail) when the stack is down, asserts when AL_TEST_REQUIRE=1.

Live run (actual output, against the running server):

gate: ws://127.0.0.1:7192/socket.io/ as user US_0Qup2u2sOxxadmpHdNKbfJBPe46OI character CH_q01pDmPudITVZdOOH920VSTV2avtu
gate[1 login]: Rustler on main at (-87,673) hp 624/624 — OK
gate[2 move]: (-87.0, 673.0) -> (-27.0, 673.0) (60 px) -> OK [move_to]
gate[3 attack]: 38 from 0 px (budget 33, 1 legs) -> OK (response=Some("data")) [attack] (hp before Some(1100.0))
gate[3 attack]: 38 hp 1100.0 -> dropped — hit confirmed
gate[4 potion]: use_potion(hp) -> OK (response=Some("data")) [use_potion]
gate[4 potion]: use_hp cooldown now -> Some(3.999723813s)
gate[4 potion]: second drink (shared gate) -> GUARDED locally: OnCooldown { name: "use_mp", remaining_ms: 3999 } [use_potion(mp)]
gate[5 say]: say -> OK (response=Some("data")) [say]
gate: all 5 steps passed — phase-2 end-to-end proof complete
test result: ok. 1 passed (4.30 s)

Both pre-existing live tests still pass alongside it (they use the dev character, so all three can run in one cargo test -- --ignored).

Auto-reconnect with backoff (recovery.rs)

  • ReconnectPolicy — base/max delay, exponential multiplier, jitter, optional max attempts; delay_for(attempt) + attempt_delays(policy, n) (pure, fully unit-tested: sequence grows to the cap, jitter bounded, max attempts respected).
  • ConnectionEnd (in client.rs) — the pump now distinguishes a local close() from a server-side drop; that is the signal the recovery loop keys off.
  • RecoveredSession — owns the retry loop: when the inbound stream ends unexpectedly it re-runs the full handshake (T2-1's reconnect(), which already handles the server's ~250 ms re-auth refusal window after a drop — dc_players, server.js:12266/10886/15810) and republishes the session; a local close stops it. client(), is_connected(), is_reconnecting(), reconnect counters.
  • run_reconnect / ReconnectEvent / ReconnectOutcome — the generic loop, testable with fake stream ends (11 offline policy tests, no live server needed).

use_potion action

use_potion("hp" | "mp") over the server's QUICK potions (node/server.js:11195-11224: +50 hp / +100 mp, clamped, one shared player.last.potion timer = 4000 ms, not_ready {ms} while cooling, success_response({used}), then eval pot_timeout(4000)). Registers under place "use", keeps a client-side shared gate (one guard covers the server's one timer — a second drink of either potion is declined locally), surfaces not_ready as the cooldown, and anything else falls through to use_item (inventory potions take a different path). CharacterCredentials gains from_test_env / from_env_preferring_test (+ AL_TEST_* env constants) for the dedicated test character.

Tests

  • Offline (gate): reconnect policy sequence/jitter/max-attempts (11 tests), use_potion shared-gate decision, credential helpers. al-client 60 → 77 unit tests.
  • Live (#[ignore]d): the 5-step gate above + the two pre-existing live tests (no regression).

Gate

bash rust/dev/check.sh green: fmt, clippy pedantic -D warnings, all tests. All three live tests run green against the server.

Assumptions

  • The gate uses Rustler (AL_TEST_*); Clinker (AL_SDK_*) is reserved for the al-sdk live test running in parallel (T3-2) so the two live suites never share a socket.
  • Reconnection is exposed as an opt-in RecoveredSession; CharacterClient::connect keeps its plain semantics (T3-3's SDK loop will own the policy choice).
  • Orchestrator note: the subagent landed the recovery commit and stopped with use_potion + the cred helpers uncommitted and the gate test not yet written; the orchestrator committed those, wrote live_integration_gate.rs, verified it live, and finished the PR.
## T2-3 the Phase-2 GATE (issue #9) The first full end-to-end proof that the Rust client drives a real character, plus the last piece of the original T2-1 scope (auto-reconnect with backoff) and the `use_potion` action. ### The GATE (`tests/live_integration_gate.rs`, #[ignore]d) One connected session, five steps, each its own function so a failure names the step: 1. **login** — full handshake, `start` decoded, alive on `main`, hp > 0; 2. **move** — `move_to` a 60 px leg; the server's settle frame confirms the position changed and the char settled; 3. **attack** — walk up to the nearest spawned monster, `attack` it; assert the server's authoritative `game_response` **and** that the monster's `hp` drops across the `entities` snapshots; 4. **potion** — `use_potion("hp")`; server success + the shared 4 s cooldown is tracked; a second drink of either potion is refused locally by the shared gate; 5. **say** — server's authoritative `game_response {place:"say"}`. Logs in as the **dedicated test character** (`AL_TEST_*` — "Rustler"), falling back to `AL_DEV_*` only when the test creds are absent, so it never collides with a human (or another live suite) on the same socket (`dev/LOCAL.md`). Skips cleanly (not fail) when the stack is down, asserts when `AL_TEST_REQUIRE=1`. **Live run (actual output, against the running server):** ``` gate: ws://127.0.0.1:7192/socket.io/ as user US_0Qup2u2sOxxadmpHdNKbfJBPe46OI character CH_q01pDmPudITVZdOOH920VSTV2avtu gate[1 login]: Rustler on main at (-87,673) hp 624/624 — OK gate[2 move]: (-87.0, 673.0) -> (-27.0, 673.0) (60 px) -> OK [move_to] gate[3 attack]: 38 from 0 px (budget 33, 1 legs) -> OK (response=Some("data")) [attack] (hp before Some(1100.0)) gate[3 attack]: 38 hp 1100.0 -> dropped — hit confirmed gate[4 potion]: use_potion(hp) -> OK (response=Some("data")) [use_potion] gate[4 potion]: use_hp cooldown now -> Some(3.999723813s) gate[4 potion]: second drink (shared gate) -> GUARDED locally: OnCooldown { name: "use_mp", remaining_ms: 3999 } [use_potion(mp)] gate[5 say]: say -> OK (response=Some("data")) [say] gate: all 5 steps passed — phase-2 end-to-end proof complete test result: ok. 1 passed (4.30 s) ``` Both pre-existing live tests still pass alongside it (they use the dev character, so all three can run in one `cargo test -- --ignored`). ### Auto-reconnect with backoff (`recovery.rs`) - `ReconnectPolicy` — base/max delay, exponential multiplier, jitter, optional max attempts; `delay_for(attempt)` + `attempt_delays(policy, n)` (pure, fully unit-tested: sequence grows to the cap, jitter bounded, max attempts respected). - `ConnectionEnd` (in `client.rs`) — the pump now distinguishes a **local** `close()` from a **server-side drop**; that is the signal the recovery loop keys off. - `RecoveredSession` — owns the retry loop: when the inbound stream ends unexpectedly it re-runs the full handshake (T2-1's `reconnect()`, which already handles the server's ~250 ms re-auth refusal window after a drop — `dc_players`, `server.js:12266`/`10886`/`15810`) and republishes the session; a local close stops it. `client()`, `is_connected()`, `is_reconnecting()`, reconnect counters. - `run_reconnect` / `ReconnectEvent` / `ReconnectOutcome` — the generic loop, testable with fake stream ends (11 offline policy tests, no live server needed). ### `use_potion` action `use_potion("hp" | "mp")` over the server's QUICK potions (`node/server.js:11195-11224`: `+50` hp / `+100` mp, clamped, one shared `player.last.potion` timer = 4000 ms, `not_ready {ms}` while cooling, `success_response({used})`, then `eval pot_timeout(4000)`). Registers under place `"use"`, keeps a client-side shared gate (one guard covers the server's one timer — a second drink of *either* potion is declined locally), surfaces `not_ready` as the cooldown, and anything else falls through to `use_item` (inventory potions take a different path). `CharacterCredentials` gains `from_test_env` / `from_env_preferring_test` (+ `AL_TEST_*` env constants) for the dedicated test character. ### Tests - Offline (gate): reconnect policy sequence/jitter/max-attempts (11 tests), `use_potion` shared-gate decision, credential helpers. al-client 60 → 77 unit tests. - Live (`#[ignore]d`): the 5-step gate above + the two pre-existing live tests (no regression). ### Gate `bash rust/dev/check.sh` green: fmt, clippy pedantic -D warnings, all tests. All three live tests run green against the server. ### Assumptions - The gate uses Rustler (`AL_TEST_*`); Clinker (`AL_SDK_*`) is reserved for the al-sdk live test running in parallel (T3-2) so the two live suites never share a socket. - Reconnection is exposed as an opt-in `RecoveredSession`; `CharacterClient::connect` keeps its plain semantics (T3-3's SDK loop will own the policy choice). - Orchestrator note: the subagent landed the recovery commit and stopped with `use_potion` + the cred helpers uncommitted and the gate test not yet written; the orchestrator committed those, wrote `live_integration_gate.rs`, verified it live, and finished the PR.
Auto-reconnect with backoff — the last piece of T2-1's issue, and the
'automatic reconnect loop that reacts to is_connected()' T2-1 deferred.

- client.rs (smallest hook, no public behaviour change): the pump now
  classifies how a stream ended (ConnectionEnd::LocalClose vs ServerDrop)
  and publishes it on a watch sender it owns; new read-only
  connection_end()/await_connection_end(). reconnect() stamps alive=false
  first so its own teardown reads as ours, not as a drop.
- recovery.rs: ReconnectPolicy (base/multiplier/max, deterministic ±25%
  jitter, per-episode max_attempts), the pure retry loop run_reconnect
  (retry on Failed, stop on LocalClosed/switch), and RecoveredSession:
  supervisor task that re-runs CharacterClient::connect on a server drop
  and hands out the live client through client(); reconnect_count,
  last_reconnect_at, is_connected, is_reconnecting, failure, on_swapped.
- error.rs: WaitError + SwapError (no Result<_, ()>).
- tests/reconnect_policy.rs: offline ladder + retry-vs-stop tests driven by
  fake stream ends; recovery unit tests in-crate.
rust: T2-3 the live integration GATE (login→move→attack→potion→say, one session, test char)
Some checks failed
Code Quality / prettier (push) Has been cancelled
Code Quality / prettier (pull_request) Has been cancelled
6ec6f06c1b
sleepy merged commit 3edf375481 into main 2026-09-20 05:55:08 +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!27
No description provided.