Skip to content

fix(combat): turn-anchor Guiding Bolt advantage (SRD duration bug) + Battle Master maneuver cue#1033

Merged
100yenadmin merged 2 commits into
mainfrom
fix/combat-fidelity-guidingbolt-maneuver-cue
Jun 19, 2026
Merged

fix(combat): turn-anchor Guiding Bolt advantage (SRD duration bug) + Battle Master maneuver cue#1033
100yenadmin merged 2 commits into
mainfrom
fix/combat-fidelity-guidingbolt-maneuver-cue

Conversation

@100yenadmin

Copy link
Copy Markdown
Member

Why

Running the combat-sprint (the surface that actually exercises combat — not a code-read) scored mech 3.3, and the Angry-DM found two HIGH defects. Root-caused against source + the real transcript (qa/transcripts/cs-mechread.jsonl):

A — REAL engine bug: Guiding Bolt advantage expires one round-boundary too early

SRD 5.2 Guiding Bolt grants advantage on the next attack "before the end of your next turn" — a turn-anchored window. The engine stored it as a fixed rounds_remaining=1 round-counter, which next_turn ticks to 0 at the start of the new round — before the next attacker acts — so the qualifying attack resolved advantage:false. The existing test only covered the same-round case, so it shipped green.

Fix (additive, mirrors the repeat_save exemption): add ActiveEffect.expires_end_of_turn_of (caster id, defaults None); exempt such markers from the tick_round_effects round-decrement; bump rounds_remaining +1 at materialization; tick/expire them in next_turn only at the caster's turn-end. Orphan guard sweeps the marker if the caster dies (death sets dead=True but doesn't pop the order — caught by adversarial review; active_ids is built from living combatants) or is removed.

B — NOT an engine bug, a missing cue: Battle Master superiority die spent for no bonus damage

The engine's maneuver-damage linkage is correct + tested (two paths). The DM called use_resource without maneuver=, burning the die as a plain point (working as designed). The gap: _turn_brief cues second_wind/action_surge/channel_divinity but not superiority_dice. Fix: add the suggested_when cue (the #A3 nova-cue precedent) + an advisory warning on a bare in-combat die-pool spend. No mechanics change.

Validation

  • tests/test_effect_durations.py + tests/test_class_resources.py + tests/test_combat.py250 passed (single-process).
  • New tests prove RED→GREEN by reverting the source: the cross-round advantage fix and the dead-caster orphan-guard both fail pre-fix, pass post-fix.
  • Adversarial 2-reviewer pass (correctness + invariant/wire-safety): verdict fix-then-ship; the one blocker (dead-caster leak) is fixed here with its own regression test.

Invariants

Additive (new field defaults None, old snapshots round-trip under extra="forbid"); engine = sole writer; engine rolls & DM is told; no existing guard weakened (test diffs are additions-only).

🤖 Generated with Claude Code

Eva added 2 commits June 19, 2026 22:38
…aneuver cue

Two combat-fidelity fixes, both additive (engine is the sole writer; every new
field defaults to today's behavior; old snapshots round-trip; models stay
extra=forbid).

FIX A (engine bug, hot combat path) — Guiding Bolt's "the next attack roll
against the target before the END OF YOUR NEXT TURN has Advantage" was stored as
a fixed round counter (rounds_remaining=1). next_turn ticks round-scale effects
at the START of a new round (turn_index wraps to 0), so a marker materialized in
round 1 was expired at the very start of round 2 — BEFORE the next attacker
acted — losing the advantage SRD 5.2 owes it. The same-round path was tested and
fine; the CROSS-round case was the gap. Root cause: the window is TURN-ANCHORED
to the caster's next turn, not a fixed round count.

Fix (SRD-exact, mirrors the existing repeat_save exemption pattern):
- models.ActiveEffect: add `expires_end_of_turn_of: Optional[str] = None`,
  holding the CASTER's character_id. None == not turn-anchored (every old
  snapshot/effect unchanged).
- combat.tick_round_effects: exempt turn-anchored markers from the round-counter
  tick (so the round-boundary tick can't expire them), exactly mirroring the
  repeat_save `surviving.append(eff); continue` block.
- server.attack (rider materialization): when grants_advantage is True, set
  expires_end_of_turn_of = the rider's source_id (the spellcaster — the
  PendingOnHitRider already carries the caster id at cast_spell time, matched as
  attack()'s attacker == caster; no new field needed). Bump the marker's
  rounds_remaining by +1 so the CAST turn's own end does not expire it.
- server.next_turn: after advancing, for each turn-anchored marker whose caster
  is `previous` (the combatant whose turn just ended, captured BEFORE turn_index
  advanced), decrement rounds_remaining and expire at 0 — i.e. "the end of your
  next turn". ORPHAN GUARD: also expire any marker whose caster is no longer an
  active combatant (caster died/removed), so it can't leak. The consume-on-attack
  path in attack() is untouched; the new expiry only matters when no one attacked.

DEVIATION from the literal spec step 4 (documented): the spec said expire on the
FIRST caster-turn-end (== `previous`). That breaks the existing same-round test
(test_guiding_bolt_marker_auto_grants...): the cast happens DURING the caster's
turn, so the first next_turn-off-the-caster is the cast turn itself — expiring
there kills the marker before a same-round attacker can use it. SRD "your NEXT
turn" means the turn AFTER the cast turn, so the marker is given a +1
rounds_remaining grace and ticked at each caster-turn-end (cast turn: 2->1; next
turn: 1->0 -> expire). This preserves the same-round test and fixes the
cross-round bug. No existing test/guard weakened.

FIX B (missing cue only — NOT a mechanics bug; the maneuver-damage paths are
correct + tested) — _turn_brief emitted a tactical `suggested_when` cue for
second_wind/action_surge/channel_divinity but NOT for superiority_dice, so the
DM was never steered to the attack(maneuver=) path and burned the die via a bare
use_resource (correct-but-pointless).
- server._turn_brief: add a `superiority_dice` branch steering the DM to declare
  the maneuver ON the attack (attack(maneuver=..., maneuver_resource=...)).
- server.use_resource: advisory-only `warning` when a die pool is spent in active
  combat with no maneuver= (does NOT block — the spend still succeeds).

TESTS (all single-process, no xdist):
- test_effect_durations.py:
  * test_guiding_bolt_marker_survives_into_casters_next_turn — the cross-round
    bug: fighter acts before the cleric in round 2; the marker survives the
    round-2-start tick and the fighter attacks with advantage
    (advantage_source == 'Guiding Bolt'), then it's consumed. PROVEN to FAIL on
    pre-fix code (marker expired at the round boundary -> advantage False).
  * test_guiding_bolt_marker_expires_at_end_of_casters_next_turn_if_unused — when
    unused, the marker expires at the end of the caster's round-2 turn and does
    NOT leak into round 3.
- test_class_resources.py:
  * test_turn_brief_superiority_dice_suggests_maneuver_on_attack
  * test_use_resource_bare_superiority_die_in_combat_warns
  * test_use_resource_bare_superiority_die_out_of_combat_no_warning (advisory inert
    out of combat)

Verified: test_effect_durations + test_class_resources + test_combat = 249
passed; affected adjacent suites (action_economy, effect_riders, finesse_sneak,
multiattack_parser, world_clock_rest_seams, zones, combat_depth_enforcement,
qa_gate) all green.
… (not just on remove)

Adversarial review found a real leak: death (_die) sets dead=True but does NOT pop c.combat.order,
so a dead-but-unremoved caster (the common 0-HP monster case) defeated the orphan guard — the
turn-anchored advantage marker leaked for the rest of the fight and granted unearned advantage to
later attacks on the marked foe. Build active_ids from LIVING combatants (mirrors the advance
loop's own not-dead skip). Proven RED->GREEN by reverting the one-line guard.
+ test_guiding_bolt_marker_swept_when_caster_dies_midcombat.
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@100yenadmin, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 3 hours, 8 minutes, and 55 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 685509f4-0954-4b29-a46d-6ed832cd43f2

📥 Commits

Reviewing files that changed from the base of the PR and between e7692e8 and 1ccfe51.

📒 Files selected for processing (5)
  • servers/engine/combat.py
  • servers/engine/models.py
  • servers/engine/server.py
  • servers/engine/tests/test_class_resources.py
  • servers/engine/tests/test_effect_durations.py

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ccfe510af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread servers/engine/server.py
Comment on lines +4574 to +4578
active_ids = {
cb.character_id
for cb in order
if (h := c.characters.get(cb.character_id)) is not None and not h.dead
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't expire Guiding Bolt on caster death

When a Guiding Bolt caster is killed before an ally's next attack in the same initiative cycle (for example: caster hits, enemy kills the caster, then an ally acts), this living-only active_ids makes caster_gone true on the enemy's next_turn, so the non-concentration rider is removed before the ally can use the advantage. Guiding Bolt's rider lasts until the end of the caster's next turn or the next attack and is not concentration-based, so death shouldn't cancel it immediately; expire it when the dead caster's initiative slot would have passed instead of sweeping it as soon as the caster is dead.

Useful? React with 👍 / 👎.

@100yenadmin 100yenadmin merged commit 540eff2 into main Jun 19, 2026
20 checks passed
100yenadmin added a commit that referenced this pull request Jun 19, 2026
… annotation (#1034) (#1035)

Checkpoint marking the Guiding Bolt SRD duration fix (found by running the combat-sprint, proven
RED->GREEN, adversarially reviewed) + the ruler-version annotation. Still NOT a GA — mech remains
below the 4.5 bar; story BG-caliber + satisfaction green.

Co-authored-by: Eva <arncalso@gmail.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