Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
…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>
…ned?(name, false) Lich::Gemstone::Status is visible inside the script through the game include, so `const_defined?(:Status)` (inheriting) found it, the struct was never defined, and Status.new raised "undefined method 'new' for module Lich::Gemstone::Status" on every launch. Name the struct TripStatus and guard both constants with const_defined?(name, false), which only asks about Go2 itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…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>
mrhoribu
left a comment
There was a problem hiding this comment.
PR Review: feat(go2.lic): v2.5.0 expose trip status to supervisors; bound the restart loop (#2471)
Reviewed: 1 file, +272/-3 at 3d50ac6 on 2026-09-17. Based on: full clone of scripts plus the lich-5 checkout at 236a9a2c (which already carries lib/common/events.rb and lib/common/move.rb, i.e. the lich-5#1619/#1622 surfaces this PR depends on).
Verdict: Request changes — one blocker that stops go2 loading at all on GemStone.
Summary: Genuinely useful observability work with a clean design (read model + edge events, frozen snapshots, graceful degradation on older Lich), but the Status constant guard collides with Lich::Gemstone::Status and takes the whole script down on GS, and three smaller issues undercut the signal the feature exists to provide.
Blockers
1. const_defined?(:Status) finds Lich::Gemstone::Status, so Status is never defined and go2 dies at load on GemStone — scripts/go2.lic:266-268
Status = Struct.new(:phase, :reason, :cause, :command, :destination, :rooms_left, :last_room,
:last_progress_at, :restarts, :max_restarts, :eta, :started_at,
keyword_init: true) unless const_defined?(:Status)
@@status ||= Status.new(phase: :idle, restarts: 0, max_restarts: 0)Module#const_defined? defaults to inherit: true, and for a module receiver that search falls back to Object and its ancestors. On a GemStone session Lich runs include Lich::Gemstone at the top level of lib/main/main.rb:232 (and again at :581 / :667 for the pipe and --gemstone paths), which puts Lich::Gemstone into Object's ancestry; lib/gemstone/infomon/status.rb — required unconditionally by GameLoader.gemstone at lib/common/gameloader.rb:31 — defines Lich::Gemstone::Status. So inside module Go2, const_defined?(:Status) is true, the Struct.new is skipped, and line 268 resolves Status to the Gemstone module.
Reproduced against Ruby 4.0.5 with nothing but the two lines of context Lich supplies:
$ ruby repro.rb
repro.rb:11:in '<module:Go2>': undefined method 'new' for module Lich::Gemstone::Status (NoMethodError)
This is at module-body scope, above every CLI branch, so it is not a trip-time failure — ;go2 <anything> stops working for every GemStone character the moment this merges. DragonRealms is unaffected (Lich::DragonRealms has no Status), which is also why a DR-side smoke test would not catch it. Ironically the PR's own motivating case (Hinterwilds) is GS.
Fix: pass inherit: false — unless const_defined?(:Status, false) — or sidestep the lookup entirely with Go2.const_defined?(:Status, false). BLOCKED_LINE at scripts/go2.lic:384 uses the identical guard; no top-level BLOCKED_LINE exists today, but it is the same latent trap and worth fixing in the same pass. This is the same class of bug as the briefcombat fix merged last week (7fd0b971, "prevent constant redefinition on subsequent runs"), so it is probably worth a repo-wide convention rather than a one-off.
Major
2. ;go2 status is refused while a trip is running, and ;force go2 status perturbs the trip it is reporting on — scripts/go2.lic:1383-1386
elsif Script.current.vars[1] =~ /^status$/i
echo Go2.status_summaryScript.start rejects a second instance of a running script (lich-5/lib/common/script.rb:159: "--- Lich: go2 is already running (use ;force [scriptname] if desired)."), so during a trip — the only time the status is interesting — ;go2 status prints that refusal instead of the status. The help text added at scripts/go2.lic:1094 doesn't mention ;force.
;force go2 status does run, but the dispatch at 1383 sits well below the module body's side effects, so a status query mutates live trip state before it prints anything:
scripts/go2.lic:224—@@mounted = falseis unconditional. If the running trip is mounted,Go2.mountedis cleared underneath it; the trip'sunless standing? or ... or Go2.mountedguard then fires astand, gets "You cannot do that while mounted.", and ifUserVars.mapdb_use_urchinsis on that path now also flips urchins off and forces a re-path (:2596-2604).scripts/go2.lic:1199-1201— the forced instance registersbefore_dying { update_urchin_expire.call }, which sendsurchin statusto the game on exit (:1180-1196).
Both are pre-existing hazards of the dispatch's position (the bank-detour force_start_script at :2493 already trips the @@mounted one), but status is the first subcommand whose entire purpose is to be run mid-trip, so it turns a latent edge into the normal usage. Two options: move the status branch above the module-body side effects, or drop the subcommand and document ;e echo Go2.status_summary, which reads the same class variables with no script start at all.
3. A bank-detour go2 that hits the ceiling declares the outer trip failed — scripts/go2.lic:2397-2403
if max_restarts > 0 and Go2.status.restarts > max_restarts
...
Go2.status_finish(:failed)
exit
endEvery other terminal-state site is guarded against the nested case — status_reset and before_dying behind unless $go2_started_go2_bank at :2371-2382, and the arrival at :2785 — but this one is not. The nested instance started by force_start_script at :2493 runs the same code with $go2_started_go2_bank true and shares @@status with its parent, so when a bank detour exhausts the ceiling it: emits go2.status with phase: :failed, writes Go2.last_result, and replaces the live @@status with a fresh idle struct.
The outer go2 does not check the detour's outcome — it falls through to unless (start_room = Room.current) at :2498 and keeps walking. From that point its destination, started_at and max_restarts are gone from the status, restarts has been zeroed, and its eventual status_finish(:arrived) at :2785 reports arrival with a nil destination. A supervisor subscribed to go2.status — the intended consumer — is handed a terminal :failed for a trip that is still in progress.
Same root, smaller edge: Go2.status.restarts += 1 at :2396 increments the shared counter, so a nested bank trip inherits the outer's restart count and compares it against its own local max_restarts. A go2 that has already restarted 30 times will make its bank detour give up on the first restart, and the message will read "giving up after 31 restarts".
Guarding this site the same way the others are (unless $go2_started_go2_bank, echoing and exiting without touching status) fixes both.
4. notify_status iterates the hook hash unprotected, so registering a hook during an emit raises — scripts/go2.lic:319
@@status_hooks.each_value { |blk|
begin
blk.call(snapshot)
rescue StandardError => eRuby raises RuntimeError: can't add a new key into hash during iteration when a key is added to a Hash that is being iterated, and Go2.on_status at :289 is a bare @@status_hooks[name] = block with no mutex. Delivery is synchronous on go2's thread and hooks are expected to do real work, so the GVL is released inside blk.call and another script's thread can land its registration in that window. Confirmed on Ruby 4.0.5:
race.rb:4:in 'block in <main>': can't add a new key into hash during iteration (RuntimeError)
Two shapes: a supervisor calling Go2.on_status from its own thread while go2 is mid-emit dies at registration with that error (the raise lands in the registering thread), and a hook that registers a new named hook from inside its own callback raises out of each_value on go2's thread — outside the per-hook begin/rescue, so it propagates into the movement loop and kills the trip. That directly contradicts the guarantee stated at :307-309 ("Neither route may raise into the movement loop, whatever a listener ... does"). Re-registering under an existing name is fine (no new key), so this only bites first registrations — i.e. supervisor startup, which is exactly when it happens.
@@status_hooks.values.each { ... } closes it; a mutex around on_status / off_status / the snapshot would be tidier. (Deletion during iteration is legal in MRI, so off_status is safe as written.)
Minor
5. Restart increments are invisible to event subscribers — scripts/go2.lic:2396
Go2.status.restarts += 1 mutates the struct directly rather than going through status_update, so nothing is emitted for it. The following Go2.status_update(phase: :pathing) unless Go2.status.phase == :blocked at :2405 re-emits and carries the new count along — except when the phase is :blocked, which is the wedged case this PR was written for. A supervisor watching go2.status events on the Hinterwilds scenario therefore never sees restarts climb; only pollers of Go2.status do. Routing it through status_update(restarts: Go2.status.restarts + 1) would make the two views agree.
6. The "frozen snapshot" does not protect String fields — scripts/go2.lic:311
snapshot = st.dup.freeze freezes the struct, not its members, and reason and command are the same String objects the live status holds. snapshot.reason << "..." in a listener mutates go2's own state. Verified on 4.0.5: after a listener appends to snapshot.a, the live struct reads back the mutated string. The comment at :305-307 ("nothing they do can alter go2's own trip state") overstates what the freeze buys. Either freeze the strings when they are set, or soften the comment.
7. on_status hooks are never reaped when the registering script dies — scripts/go2.lic:286-296
The PR body correctly notes that Events subscriptions are cleaned up by ScriptDeath when the owner dies (lich-5/lib/common/events.rb:31-36), but @@status_hooks has no equivalent. A supervisor that crashes without calling off_status leaves its block registered for the life of the Lich process, invoked on go2's thread on every future trip, holding its dead script's binding alive. Recording Script.current&.object_id alongside the block and skipping (or dropping) entries whose owner is no longer in Script.running would match the Events contract the docstring holds itself up against.
8. @@seen_move_failure is process-wide but Move.last_failure is per-thread — scripts/go2.lic:271, 344-350
Lich::Common::Move stores the record in Thread.current[LAST_FAILURE_KEY] (lich-5/lib/common/move.rb:76-84) precisely because several scripts may be moving one character at once. @@seen_move_failure is a class variable, so the nested bank instance's fresh_move_failure writes over the parent's cursor with a Failure from a different thread. Since the comparison is equal?, the parent's next mark_blocked can then treat its own already-consumed failure as fresh and report a stale reason once. Narrow, and the cursor self-corrects on the next real failure — but a Thread.current[:go2_seen_move_failure] would match the primitive it is tracking.
9. Changelog entry is longer and more technical than this repo's convention — scripts/go2.lic:17-29
Thirteen lines naming Lich::Common::Move.last_failure, Events.on('go2.status') and "Lich 5.22+". The surrounding entries (2.4.0, 2.3.0) are one to three player-facing lines; the module docstring at :235-263 and the PR description already carry the API detail for script authors. Something closer to "go2 now reports trip progress to other scripts, and gives up after 30 restarts instead of looping forever (--max-restarts=<N>)" fits the file better.
Nits
scripts/go2.lic:1094— the;go2 statushelp line is inserted between--max-restartsand--disable-confirm=<on|off>, inside the flag block, rather than with the other subcommands.scripts/go2.lic:2553-2554— two consecutivestatus_updatecalls emit two events for one logical transition; merging them into one call would halve the churn on every re-path.scripts/go2.lic:2564-2571—checksleeping/checkbound/checkstunned/checkwebbedare each called twice, once to buildmuckled_byand once for theecho. Building the list and echoing from it would keep them in sync if a future check is added.
Open questions
status_summaryonlast_resultafter a nested failure. If finding 3 is fixed by guarding the nestedstatus_finish, does the outer trip still want a way to know its detour gave up? Right now it doesn't check, and the detour's failure is silently followed by "You're too poor to go to the bank" on the next pass through:2486. Settles it: is a failed bank detour meant to abort the outer trip, or to fall through as it does today?mark_blockedin the StringProc branch (:2663).Go2.mark_blocked(last_sent) if Room.current.id == room_id_before_proc and room_id_before_proc != next_id.to_imarks blocked whenever the proc left us in the same room. Are there wayto procs that legitimately take more than onepathstep, or that return to the same room as an intermediate state (a lift, a boat, a queue)? Settles it: one example of a multi-step StringProc in the map DB that would false-positive here — if none exists, the check is sound as written.--max-restartsdefault of 30 against the reported failure. The Hinterwilds case restarted ~15 times over several minutes; 30 restarts is therefore roughly ten minutes of wedged walking before the ceiling fires. Settles it: is ten minutes the intended patience, or was 30 chosen as "comfortably above normal" without a wall-clock estimate?
What this PR gets right
- The read-model-plus-edge-events split is the right shape, and it matches what
lich-5/lib/common/events.rb:44-48explicitly recommends. PollingGo2.statusand subscribing togo2.statusare both first-class rather than one being a bolt-on. - Degradation is handled properly everywhere it matters:
defined?(Lich::Common::Events),Move.respond_to?(:last_failure), anddefined?(Lich.log)all guard, andblocked_reason's buffer scrape is a real fallback rather than a stub. The script genuinely runs on an older Lich. blocked_reasonpicking the first failure line after the last room title is the non-obvious detail that makes the reason field worth having — reporting the typeahead cascade instead of the root cause would have made the whole feature noise.- Preserving
:blockedacross re-paths (:2405,:2554) so the phase only clears when a room actually changes innote_progressis the correct semantics, and easy to get wrong in the other direction. - Movement semantics are left alone. The
room_id_before_proccomparison is explicitly status-only with$go2_restart = truestill commented out at:2665, which keeps this reviewable as an additive change. ruby -candrubocop(repo config, Ruby 4.0.5) are both clean — confirmed locally, not taken on trust.
Coverage notes
- Full clone of
elanthia-online/scriptsat9a8e789bwith PR head3d50ac62fetched, plus the locallich-5checkout at236a9a2cfor the framework contracts (Script.start,Script#vars,Events,Move,GameLoader,StringProc). ruby -c→ Syntax OK.rubocopwith the repo's.rubocop.yml(TargetRubyVersion 4.0, customascii_only_sourcecop) → no offenses. Ruby 4.0.5 via rbenv.- Findings 1, 4 and 6 were reproduced with standalone Ruby 4.0.5 scripts rather than reasoned about; findings 2 and 3 were traced through the file and the lich-5 source but not run against a live game. Nothing here was exercised on a real trip.
- Not reviewed: GUI paths, the Gtk setup block, and the pre-2.5.0 body of
go2.licexcept where the diff touches it. TheLich::Common::Move.last_failurefield names (dir,line,cause) were verified againstlich-5/lib/common/move.rb:43in this checkout; if lich-5#1622 changes that Struct before merging,mark_blockedat:401-407needs re-checking. - The PR is still marked draft and blocked on lich-5#1619/#1622 per its own description, so the dependency gap is the author's stated position, not a finding.
…ted bank detour, hook lock Major 2: the `status` subcommand is dispatched at the top of the module, before `@@mounted = false` and the urchin before_dying, so `;force go2 status` no longer perturbs the trip it reports on; help lists it under "other commands" as `;force go2 status` since Script.start refuses a second instance (also the first nit). Major 3: a nested bank detour keeps its own restart counter and, on hitting the ceiling, echoes and exits without touching the shared status; the outer trip echoes once that the detour gave up and falls through as before. Major 4: on_status/off_status/snapshot run under @@status_lock and notify_status iterates a copy of the hooks outside the lock, so a registration during an emit can neither raise nor deadlock. Minor 5: the restart increment goes through status_update so listeners see it climb even while the phase stays :blocked. Minor 6: status_update stores frozen copies of String fields, so a snapshot's strings can't be edited into the live status. Minor 7: each hook records Script.current; entries whose owner is no longer in Script.list are dropped on the next emit (Script.list rather than Script.running so a hidden supervisor isn't reaped). Minor 8: the seen-move-failure cursor is Thread.current[:go2_seen_move_failure], matching Move.last_failure's per-thread storage. Minor 9: the 2.5.0 changelog is two player-facing lines. Nits: one status_update per re-path; the muckled echo is built from the same list as the reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed bank detour fails the trip Answers the two open questions from the review of elanthia-online#2471. Patience: a restart count is a poor clock. Restarts come fast when every attempt is refused, so 30 of them was roughly ten minutes of wedged walking in the Hinterwilds case. A second ceiling, --max-stuck=<seconds> (CharSettings max_stuck_seconds, default 60, 0 = never), is measured from the last room change and checked beside the restart ceiling. Either one ends the trip the same way: an echo with the last blocker, status_finish(:failed) with the reason, and exit, so whatever started go2 reads Go2.last_result or the go2.status event and decides what to do. Bank detour: the silvers were needed to make the trip, so a detour that gives up or cannot reach a bank no longer lets the outer trip walk on to be refused at the first toll. The nested instance records why it failed, and the outer finishes :failed with that reason. Not having enough in the bank fails the trip the same way instead of an exit that read as :aborted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the review, and for the reproductions. Everything is addressed at Blocker 1. The struct is Major 2. The Major 3. A nested bank detour counts its own restarts, and when it gives up it records why and exits without Major 4. Minor 5 to 9. Restart increments go through Patience. Agreed that ten minutes is far too long, and a restart count is a poor clock. There is now StringProc. I could not settle it. The check is status-only, so a proc that legitimately ends in its starting room would show a transient |
Summary
go2 v2.5.0: make a trip's progress observable to supervising scripts, and stop the restart loop from running forever.
Go2.statusstruct:phase, blockedreason, itscausesymbol, the failingcommand,destination,rooms_left,last_room,last_progress_at,restarts,max_restarts,eta,started_atgo2.statusonLich::Common::Eventswhen that primitive exists;Go2.on_statusis the fallback on older Lich, and both receive a frozen snapshotGo2.last_resultkeeps the final:arrived/:failed/:abortedstatus 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 statusprints a one-line summaryMotivation
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 pathfailed, 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_countwas never compared to a ceiling and most$go2_restart = truesites 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::Eventsprimitive) lands. The script runs fine without it: theEvents.emitis behind adefined?guard and rescued, andGo2.on_statusworks on any Lich. Once 1619 is released, supervisors should preferbecause that subscription is cleaned up automatically when the listening script dies.
And elanthia-online/lich-5#1622 for the
causefield: when that Lich'smove()recordsLich::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 statusprints it. On an older Lichcauseis nil and the buffer scrape still suppliesreason. go2 itself callsmovethrough the unchanged top-level name, so the bounded retries in #1622 apply to every go2 step with no change here.Behavior notes
status_finishemits only the terminal phase; the reset to:idleis silent since it is not a transition a supervisor can act on.error_countand its lag-check /timetologic are unchanged; the new ceiling counts restart cycles.Verification
ruby -candrubocop --only Lintcleanevents.rb, and a raisingEvents.emitbeing logged rather than propagated🤖 Generated with Claude Code