Skip to content

combat-trainer: don't leave casting set after a failed spell prep (fixes #7563) - #7564

Open
MahtraDR wants to merge 7 commits into
elanthia-online:mainfrom
MahtraDR:fix/spell-prep-failure-casting
Open

combat-trainer: don't leave casting set after a failed spell prep (fixes #7563)#7564
MahtraDR wants to merge 7 commits into
elanthia-online:mainfrom
MahtraDR:fix/spell-prep-failure-casting

Conversation

@MahtraDR

Copy link
Copy Markdown
Collaborator

Summary

Fixes #7563.

SpellProcess#prepare_spell discarded the return value of DRCA.prepare? and set game_state.casting = true unconditionally. When preparation failed — most commonly an unknown spell where the game replies You have no idea how to cast that spellexecute() then returned early on if game_state.casting, so check_offensive and check_training never ran. The character silently stopped casting all offensive and training spells until check_timer cleared the state 70s later, at which point check_buffs re-picked the same dead spell and the cycle repeated indefinitely. No error or status message appeared.

A character reaches this state by copying a spell list, or by circling to 10 (Analogous Patterns spells are forgotten), or — as noted in the issue thread — by dying while a spell was only temporarily memorized from a scroll.

Changes (all in combat-trainer.lic)

  • Honor prepare?'s return value. On failure, reset casting/cast_timer and return before setting casting. This unblocks casting for every failure reason (unknown spell, area interference, exhausted retries).
  • Disable spells the character genuinely doesn't know. New ct-spell-unknown flag on You have no idea how to cast that spell; when it trips, the spell is marked ct_spell_disabled and the user is messaged. Only that permanent message disables — transient failures just skip the tick and retry.
  • Skip ct_spell_disabled entries in check_buffs, check_training, and check_offensive, so the disable covers all three spell paths (the issue confirms training is affected too), rather than re-attempting and spamming the error every tick.

I deliberately did not use the DRSpells.known_spells / validate.lic route suggested in the issue: it misses magical tattoos and needs a SPELLS send to populate.

Tests

Regression specs added to spec/combat_trainer_spec.rb:

  • #prepare_spell sets casting on success; leaves it unset with a nil timer on failure; disables an unknown spell without setting casting.
  • #check_offensive skips a spell flagged ct_spell_disabled.

Verification:

  • bundle exec rspec spec/combat_trainer_spec.rb — 395 examples, 0 failures
  • bundle exec rspec (full suite) — 3021 examples, 0 failures
  • bundle exec rubocop combat-trainer.lic — no offenses

🤖 Generated with Claude Code

SpellProcess#prepare_spell discarded DRCA.prepare?'s return value and set
game_state.casting = true unconditionally. When prep failed (e.g. an unknown
spell replying "You have no idea how to cast that spell"), execute() then bailed
on `if game_state.casting`, starving all offensive and training casting until
check_timer cleared it 70s later -- and check_buffs re-picked the same dead
spell next tick, silently and indefinitely.

Fixes elanthia-online#7563.

- Honor prepare?'s return: on failure, reset casting/cast_timer and return
  before setting casting. Covers every failure reason (unknown spell, area
  interference, exhausted retries).
- Add a ct-spell-unknown flag on "You have no idea how to cast that spell" and,
  when it trips, mark the spell ct_spell_disabled and message the user so it is
  not retried every tick. Only that permanent message disables; transient
  failures just skip the tick.
- Skip ct_spell_disabled entries in check_buffs, check_training, and
  check_offensive so the disable covers all three spell paths.

Adds regression specs for prepare_spell (success sets casting; failure leaves it
unset with nil timer; unknown spell is disabled) and check_offensive skipping a
disabled spell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 30, 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: Pro Plus

Run ID: d562fb34-6538-47d0-811b-15f67b1afdb1

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.

@simtel12

simtel12 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Tested this locally against the case from #7563: an Empath with Ease Burden in
buff_spells, a spell that character never learned. Before the patch, no offensive spell was
ever attempted and nothing was printed. With the patch, the spell is named and disabled once,
and offensive casting proceeds normally. That fixes the problem I reported.

Two gaps I noticed while reading it over.

1. check_health_empath defeats the disable.

It builds a throwaway hash on every call, so ct_spell_disabled is written to an object that
is discarded immediately:

# combat-trainer.lic:2255, and again at :2269, :2271, :2276
data = { 'abbrev' => 'vh', 'mana' => @empath_spells['VH'].first, ... }
prepare_spell(data, game_state)

If an Empath's empath_healing names a spell they do not know, the Disabling ... message
repeats on every tick. The starvation is gone, because the unless prepared return still
does its job, so this is message spam rather than a functional break. The FOC and HEAL
branches have the same shape.

The other three paths are fine. check_offensive, check_buffs, and check_training all
hand prepare_spell an object that lives in the settings list, so the flag sticks there.

2. The #check_offensive spec passes for a weaker reason than it looks.

The spec stubs sort_by_rate_then_rank: [], so skill is nil, and
ready_spells.find { |spell| spell['skill'] == skill } returns nil. The method then exits
at return unless data no matter what the filter did. Removing the ct_spell_disabled line
does make the spec fail, but only because the strict double raises on the unstubbed
is_offense_allowed? once the filter gets that far, not because the assertion catches the
regression.

Stubbing sort_by_rate_then_rank to return ['Debilitation'] would exercise the skip
directly.

Neither of these blocks the fix as far as I am concerned.

Address tester feedback on elanthia-online#7564:

- Empath healing (VH/FOC/HEAL in check_health_empath) rebuilds the spell hash
  every tick, so a `ct_spell_disabled` flag written onto that hash was discarded
  and the "Disabling ..." message repeated forever. Track disabled spells in a
  persistent @disabled_spells set keyed by abbrev instead, and guard the empath
  paths (with natural FOC->HEAL fallback). This also stops mutating the caller's
  spell hash.
- Strengthen the #check_offensive spec so it exercises the skip directly: stub
  sort_by_rate_then_rank to return the spell's skill and is_offense_allowed?/
  dancing? so removing the guard actually reaches prepare_spell, rather than
  exiting early at `return unless data`.
- Add a #check_health_empath regression spec for the rebuilt-hash case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MahtraDR

Copy link
Copy Markdown
Collaborator Author

Thanks @simtel12 — both spot-on. Pushed 069342f.

1. Empath healing defeating the disable. Root cause was keying the disable off a flag written onto the spell hash, which the check_health_empath VH/FOC/HEAL branches rebuild every tick (as you noted, check_offensive/check_buffs/check_training pass the settings-list object, so those stuck). Switched to a persistent @disabled_spells set keyed by abbrev, so the disable survives the throwaway hashes. check_health_empath now skips a disabled vh/foc/heal (the FOC/HEAL branch falls through to HEAL naturally if FOC is the one disabled), and the Disabling … message prints exactly once via Set#add?. This also stops mutating the caller's hash.

2. Weak #check_offensive spec. Correct — it was passing at return unless data. Reworked it to stub sort_by_rate_then_rank to return the spell's skill plus is_offense_allowed?/dancing?, so with the guard removed the disabled spell resolves to data and prepare_spell actually fires. Mutation-checked both new guards: dropping either makes the spec fail with prepare_spell "received: 1 time", not on an unstubbed-double error. Added a #check_health_empath regression spec for the rebuilt-hash case too.

Full suite green (3022 examples, 0 failures), rubocop clean.

MahtraDR and others added 5 commits August 30, 2026 18:14
…reset

Holistic response to the principal-dev review of elanthia-online#7564:

- Centralize the disabled-spell guard at the top of prepare_spell so EVERY
  caller is covered -- previously only the offensive/buff/training selects and
  empath healing checked it, leaving the necromancer paths (Siphon Vitality,
  Call from Beyond/Within, Necrotic Reconstruction, Devour/Consume Flesh) and
  the Trader regalia path to keep re-sending `prep` and re-triggering the game's
  "no idea how to cast" line every tick. The central guard also short-circuits
  before the destructive prep side-effects (release_cyclics, drop_moon_weapon?).
- Extract reset_casting_state and use it on both non-casting exits (the aborted
  prep and the 70s check_timer recovery) so stale casting_* sub-flags
  (casting_cyclic/sorcery/moonblade/regalia/...) can't bleed into the next cast.
  Previously the abort path and check_timer each cleared only casting/cast_timer.
- Anchor the ct-spell-unknown matcher (^...) to avoid a false, session-long
  disable from an ambient line containing that phrase.
- Keep the disable session-scoped (clears on next combat-trainer start) but make
  it visible: disable_spell announces the spell once with recovery guidance.
- spell_disabled? now returns a strict boolean.

Remove the now-redundant per-branch empath VH guards (the central guard covers
them); keep the FOC->HEAL fallback guards, which still heal when only FOC is
disabled.

Adversarial specs (mutation-verified to fail when the guard/reset/flag-gating is
removed): transient failure must NOT disable a castable spell; unknown spell is
disabled and announced once; disabled spell never pings the game or fires
release_cyclics; abort path and check_timer reset the casting_* sub-flags;
check_timer boundary; the unguarded necromancer check_consume path is protected
by the central guard; FOC->HEAL fallback; and nil-abbrev / case-insensitive /
fresh-instance edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t exits

reset_casting_state now nils @should_invoke and clears @should_harness. A
cambrinth cast that aborts (failed prep) or times out (check_timer) otherwise
left the invoke intent set; the next non-cambrinth cast would then gate
check_current on check_charging? and stall. Cambrinth stays charged game-side
(charges never leak), so nothing is stranded -- we only drop the stale intent.

Extends the abort and check_timer regression specs to assert @should_invoke is
cleared (mutation-verified: both fail if the reset is removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd skips a spell

Two fixes from the round-3 review:

1. The central spell_disabled? guard in prepare_spell returned before
   reset_casting_state, but the necromancer callers (check_cfb/check_cfw/
   heal_corpse and the Devour/Consume branch of check_consume) set
   casting_cfb/cfw/nr/consume = true BEFORE calling prepare_spell. A disabled
   necro spell therefore left the sub-flag set with casting = false, so
   necro_casting? reported true and suppressed looting, arranging, rituals, and
   zombie/bonebug creation until the next successful cast (indefinitely if the
   disabled spell was the necro's only cast). The guard now calls
   reset_casting_state before returning. Regression spec: a disabled Call from
   Beyond leaves casting_cfb false and necro_casting? false (mutation-verified).

2. Drop the '^' anchor on the ct-spell-unknown matcher. It diverged from the
   authoritative unanchored matcher in lich-5 common-arcana.rb (which is what
   makes prepare? return false) and from the sibling unanchored ct-shock-warning
   flag; a leading character on the line would have made the disable silently
   never fire while prepare? still failed.

Also switches the #check_consume spec to a real GameState (the guard now runs
reset_casting_state on that path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilters (test-only)

The disabled-skip filters in check_buffs and check_training had no regression
coverage (deleting either left the suite green). check_buffs' filter is
load-bearing: without it a disabled always-due buff is re-selected by `find`
every tick and monopolizes the single per-tick buff slot, starving all other
due buffs.

Adds two specs (both mutation-verified to fail when their filter is removed):
- check_buffs picks the next due buff, never the disabled always-due one.
- check_training filters out the disabled skill so prepare_spell isn't called.

No production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The check_buffs regression spec set `$weapon_buffs = []`, but reset_data (the
before(:each) hook) does not restore $weapon_buffs, and it's initialized once at
spec load. So the assignment leaked [] into every subsequent example in the run
-- the load-order cross-spec pollution CLAUDE.md forbids. It was also gratuitous:
BadBuff/GoodBuff aren't weapon buffs, so check_buff_conditions? already passes
against the real list. Removed the assignment; test stays green and still fails
if the check_buffs disabled filter is removed. Verified green under random order.

No production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MahtraDR

Copy link
Copy Markdown
Collaborator Author

Tested this locally against the case from #7563: an Empath with Ease Burden in buff_spells, a spell that character never learned. Before the patch, no offensive spell was ever attempted and nothing was printed. With the patch, the spell is named and disabled once, and offensive casting proceeds normally. That fixes the problem I reported.

Please try again one more time?

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.

combat-trainer: a failed spell prep leaves game_state.casting set and silently blocks all offensive and training spells

2 participants