Skip to content

feat(go2.lic): v2.5.0 expose trip status to supervisors; bound the restart loop - #2471

Draft
Nisugi wants to merge 4 commits into
elanthia-online:masterfrom
Nisugi:feat/go2-status-max-restarts
Draft

Nisugi wants to merge 4 commits into
elanthia-online:masterfrom
Nisugi:feat/go2-status-max-restarts

Conversation

@Nisugi

@Nisugi Nisugi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

go2 v2.5.0: make a trip's progress observable to supervising scripts, and stop the restart loop from running forever.

  • Go2.status struct: phase, blocked reason, its cause symbol, the failing command, destination, rooms_left, last_room, last_progress_at, restarts, max_restarts, eta, started_at
  • every change is emitted as go2.status on Lich::Common::Events when that primitive exists; Go2.on_status is the fallback on older Lich, and both receive a frozen snapshot
  • Go2.last_result keeps the final :arrived / :failed / :aborted status after the script exits
  • --max-restarts=<N> (default 30, 0 = unlimited) bounds the restart loop with a clear give-up message naming the last blocker
  • ;go2 status prints a one-line summary

Motivation

A script driving go2 had one signal, Script.running?('go2'). Travelling out of [Hinterwilds, Beaten Path - 29882] while wounded, the exit's search failed with "You are in far too much agony to do that", go path failed, and go2 restarted itself roughly fifteen times over several minutes. Throughout, the supervisor saw "trip underway". A wedged go2 was indistinguishable from a walking one, and the loop had no exit other than success: error_count was never compared to a ceiling and most $go2_restart = true sites never touched it.

The blocked reason is captured from the game lines since the last room title, first failure line wins, so the root cause is reported rather than the cascade of "You can't go there" that typeahead sends afterwards.

Depends on

Draft until elanthia-online/lich-5#1619 (the Lich::Common::Events primitive) lands. The script runs fine without it: the Events.emit is behind a defined? guard and rescued, and Go2.on_status works on any Lich. Once 1619 is released, supervisors should prefer

Events.on('go2.status', name: 'mysupervisor') { |_topic, st| @need_heal = true if st.phase == :blocked }

because that subscription is cleaned up automatically when the listening script dies.

And elanthia-online/lich-5#1622 for the cause field: when that Lich's move() records Lich::Common::Move.last_failure, go2 takes the exact failing line and its classified cause (:injured, :encumbered, :position, :map, ...) from there instead of scraping the buffer, and ;go2 status prints it. On an older Lich cause is nil and the buffer scrape still supplies reason. go2 itself calls move through the unchanged top-level name, so the bounded retries in #1622 apply to every go2 step with no change here.

Behavior notes

  • A nested go2 started for a bank detour reports under the outer trip's status rather than resetting it.
  • status_finish emits only the terminal phase; the reset to :idle is silent since it is not a transition a supervisor can act on.
  • error_count and its lag-check / timeto logic are unchanged; the new ceiling counts restart cycles.

Verification

  • ruby -c and rubocop --only Lint clean
  • helper logic exercised standalone with stubs: reason selection after the last room title, hidden-title fallback, progress cursor, restart ceiling, frozen payloads, mutation attempts by a listener, emit sequence on the real events.rb, and a raising Events.emit being logged rather than propagated
  • not yet exercised on a live trip; the Hinterwilds case above is the intended manual test

🤖 Generated with Claude Code

Nisugi and others added 3 commits September 13, 2026 19:17
Supervising scripts previously had only Script.running?('go2'), so a wedged
go2 (e.g. a search-gated exit while too wounded to search) looked identical
to one that was walking, and the restart loop never gave up.

- Go2.status struct: phase, blocked reason + failing command, destination,
  rooms_left, last_room, last_progress_at, restarts, max_restarts, eta
- Go2.on_status / off_status hooks; Go2.last_result kept after exit
- --max-restarts=<N> (default 30, 0 = unlimited) with a clear give-up exit
- ;go2 status subcommand
- nested bank-detour go2 reports under the outer trip's status

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every status change (and the final :arrived/:failed/:aborted then :idle)
goes out as Events.emit('go2.status', Go2.status) when the Events primitive
exists (lich-5 PR elanthia-online#1619). Go2.on_status stays as the fallback for older Lich
and keeps working alongside it. Both routes share one notify path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…no idle emit

- listeners (Events and on_status) receive st.dup.freeze, so a stashed
  payload keeps its emit-time state and no listener can alter go2's trip
- the Events.emit call is rescued and logged, matching the local hooks, so
  a half-loaded or skewed Events cannot raise into the movement loop
- status_finish emits only the terminal phase (:arrived/:failed/:aborted);
  the return to :idle is not a transition a supervisor can act on

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9ee99dde-3397-4fd3-9f1b-2616f29ec9e7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…esent

Status gains a cause field. mark_blocked prefers the failure move() recorded
during this step (exact line plus a classified :cause such as :injured,
:encumbered, :position, :map; lich-5 PR elanthia-online#1622) and falls back to picking
the line out of the buffer on a Lich whose move() predates that. A failure
recorded before the trip, or already reported, is never reused. go2's own
muckled wait reports cause :muckled. ;go2 status prints the cause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mrhoribu pushed a commit to elanthia-online/lich-5 that referenced this pull request Sep 15, 2026
…ft it into Lich::Common::Move (#1622)

## Summary

- bound every "fix the obstacle and re-send" branch in `move()`:
`MAX_REMEDIES` (3) for remedies that should work first time (stand,
unhide, retreat, empty hands, open, stow), `MAX_ROLLS` (20) for climb
and swim skill rolls; roundtime waits stay uncapped since they always
end
- `Lich::Common::Move.last_failure` after a false or nil return: the
direction sent, the game line that ended it, the attempt count, and a
`:cause` from a small documented set (`:injured`, `:encumbered`,
`:engaged`, `:position`, `:hidden`, `:hands`, `:closed`, `:map`,
`:denied`, `:climb`, `:swim`, `:roundtime`, `:unknown`) so callers
switch on it instead of matching text
- a stand that keeps failing is attributed from character state
(overburdened gives `:encumbered`, limb wounds give `:injured`), because
the game says "You struggle, but fail to stand." for both
- `move.failed` emitted on `Lich::Common::Events` when that module
exists (#1619), rescued; nothing depends on it
- `move` no longer mutates the caller's string: the climb/go swap and
door renumbering used to rewrite wayto strings from the map database in
place
- the four exit paths share one finish lambda
- remedies are sent through #1587's bounded `fput` (one resend, then a
failure symbol), so `move` no longer carries its own copy of the refusal
ladder
- implementation lifted out of `global_defs.rb` into
`Lich::Common::Move.move` (lib/common/move.rb) with a two-line top-level
shim, the same carve-out pattern as ArgParser, OrderlyShutdown and Stash
- first spec coverage for `move()`: 18 examples driving `Move.move`
through a scripted fake stream

## Motivation

Every recovery branch in `move()` follows one pattern: apply a remedy,
re-send the direction, reset the give-up clock. That is right for
obstacles the remedy actually fixes. It is wrong for stand: a stand that
fails for wounds or encumbrance fails every time until something outside
`move()` changes, and each try costs 10 seconds of roundtime.

Seen in play: a prone, wounded character on a go2 trip. The game
answered "You struggle, but fail to stand." then "Roundtime: 10 sec." on
every attempt. `move()` never returned, so go2's step never failed, its
restart counter never moved, and its status read `:moving` for as long
as anyone cared to watch. The same shape exists in the unhide, retreat,
empty-hands and climb branches; the stand case is the one with a
reproduction.

The failure reason is the second half. `move()` sees the exact line at
the moment it matches. go2 (and any supervisor) currently has to scrape
the buffer afterwards to guess why a step failed. Recording the line and
a cause where it is matched means one classifier in one place, instead
of every consumer regexing the same game text.

## What does not change

- name, arguments, defaults
- the tri-state return: true moved, false drop-the-exit, nil
keep-the-exit; every branch returns what it returned before
- every recognition regex, verbatim
- the consumed stream is still restored to the script's downstream
buffer on exit

## What does change, for callers that relied on it

- a remedy that never works now returns nil after its budget instead of
looping. A route that needed more than 20 consecutive failed climb rolls
before one succeeded would now fail; `MAX_ROLLS` is the one constant to
raise if such a route exists.
- the caller's direction string is no longer rewritten. A script that
read its own variable after `move` to learn the corrected verb would no
longer see it. I found no such script in lich-5 or
elanthia-online/scripts.
- one new echo line when a budget runs out.

## Constant resolution after the lift

Inside `Lich::Common::Move`, `XMLData` has no `Lich::Common::XMLData` to
catch it and falls through to the top-level instance; `Script` and
`Spell` resolve to the `Lich::Common` classes that `include
Lich::Common` exposes at top level, so they are the same objects the old
top-level method used. The spec found this the first time (the real
`Lich::Common::Spell` was reached instead of a top-level stub) and now
stubs both.

## Builds on #1587 (merged)

Every remedy in `move` (stand, unhide, retreat, empty hands, open, stow,
drag) goes through `fput(cmd, timeout: 3, max_resends: 1, failures:
:symbol)` from #1587: one refusal-driven resend, then a failure symbol.
On the pre-#1587 fput those options were silently ignored and the stand
remedy recursed without limit, which is the P1 the first review of this
PR found. Rebased onto `main` after #1587 landed; four commits.

Independent of #1619. The `Events.emit` is behind a `defined?` guard.
Once both land, go2's `mark_blocked` can read `Move.last_failure`
instead of scraping the buffer (elanthia-online/scripts#2471).

## Review history

- P1: the stand loop was still unbounded because remedies went through
the old fput, which recurses on "You struggle, but fail to stand." Fixed
by routing remedies through #1587's bounded fput.
- P2: the shared skill-roll branch hardcoded `:climb` for swim, drag and
guard lines. Fixed: the cause is taken from the matched line (`:swim`,
`:drag`, `:denied`, else `:climb`).
- P2: a remedy reply persisted across remedy kinds, so a stand that
succeeded could be reported as the line for a later swim that failed.
Fixed: replies are kept per remedy kind.

## Verification

- `bundle exec rspec spec/lib/common/move_spec.rb`: 23 examples, 0
failures; with #1587's fput spec and `spec/lib/gemstone/`: 1022
examples, 0 failures
- `bundle exec rubocop`: 1172 files inspected, no offenses
- full suite: the same 14 environmental files (frontend, GTK, wine,
Windows launcher) fail identically on pristine `main`, so none are
introduced here
- not yet exercised on a live character on this exact branch; the
prone-and-wounded case above is the intended manual test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Nisugi <nisugi-gs4@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant