Skip to content

# Auto-order Stonehoof per-attacker triggers with 10+ attackers (#5335)#5399

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
andriypolanski:fix/issue-5335-stonehoof-mass-attack
Jul 9, 2026
Merged

# Auto-order Stonehoof per-attacker triggers with 10+ attackers (#5335)#5399
matthewevans merged 6 commits into
phase-rs:mainfrom
andriypolanski:fix/issue-5335-stonehoof-mass-attack

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Auto-order Stonehoof per-attacker triggers with 10+ attackers (#5335)

Summary

Declaring combat with Stonehoof Chieftain and 10+ other attackers surfaced a spurious OrderTriggers prompt and left attackers without trample/indestructible before blockers — trample damage never reached the defending player. This fix certifies per-attacker attack fan-out as order-independent (CR 603.3b / CR 508.3) and teaches ability_rw to read TriggeringSource from nested transient statics.

Fixes #5335.

Root cause

Stonehoof's attack trigger fires once per attacking creature you control. With 10+ raiders:

  1. Trigger ordering — each sibling carries a distinct single-attacker AttackersDeclared event, but the AST uses TriggeringSource event context. The C2 fail-closed walker and legacy_batch_prompt parity gate treated the batch as conflict-prone, forcing an OrderTriggers prompt even though each trigger only grants keywords to its own event object (commutative under CR 603.3b).

  2. Conflict analysisGenericEffect static grants carry affected: TriggeringSource on the nested static, not on the outer target slot. ability_rw defaulted to SelfRef (Stonehoof), so the batch profiler incorrectly flagged write conflicts between siblings.

  3. Combat harnessrun_combat did not drain attack triggers before declare blockers, so integration tests (and gameplay) could reach blocking with an unresolved Stonehoof stack.

Changes

Engine — trigger ordering (triggers.rs)

  • Added attack_triggers_have_disjoint_event_objects to detect per-attacker fan-out where each event object is a distinct attacker disjoint from all trigger sources.
  • When C0-distinct events are certified and the disjoint fan-out predicate holds, skip legacy_batch_prompt and auto-order without player input.
  • attack_triggers_have_disjoint_event_objects builds the full group source-id set and requires every per-trigger event object to be absent from that set (not only absent from its own trigger source). The first-event-only event_object_excludes_sources conjunct is no longer reused for this fast path.
  • C0-distinct path returns early in trigger_events_match_for_ordering when batch profiler certifies conflict-clean commutation.

Engine — ability read/write profiling (ability_rw.rs)

  • GenericEffect profiling now falls back to nested static_abilities[].affected when the outer target is None, so TriggeringSource grants profile as writes to the triggering attacker, not Stonehoof.

Tests

  • issue_5335_stonehoof_mass_attack.rs: three regressions — no OrderTriggers prompt with 10 attackers, trample+indestructible on each attacker after triggers resolve, 18 trample damage to player when nine attackers go unblocked.
  • triggers.rs unit tests: mixed fan-out where an event object is another member's source must prompt; ten-raider homogeneous fan-out passes the disjoint gate.
  • rules.rs: run_combat helper drains OrderTriggers if present and empties the attack-trigger stack before declare blockers.

Out of scope

  • Other same-controller trigger batches that are not per-attacker AttackersDeclared fan-out with disjoint event objects — still fail-closed through C2 / legacy_batch_prompt.
  • Stonehoof's own intrinsic trample/indestructible keywords — unchanged.

Test plan

  • cargo fmt --all
  • cargo test -p engine attack_triggers_disjoint
  • cargo test -p engine --test integration issue_5335 (3/3)
  • Tilt test-engine green after merge

Risk notes

  • The disjoint fan-out fast path is narrowly gated: single-attacker events, distinct attackers, source ≠ event object, and existing C0-distinct / event-object-excludes-sources conjuncts must all hold. Unrelated trigger batches keep the fail-closed prompt.
  • ability_rw fallback only applies when GenericEffect.target is None and a nested static carries affected — player-chosen targets on the outer slot are unchanged.

andriypolanski and others added 2 commits July 9, 2026 00:29
…ckers (phase-rs#5335)

Per-attacker AttackersDeclared fan-out is certified order-independent so
Stonehoof grants resolve without a spurious OrderTriggers prompt, and
ability_rw profiles TriggeringSource static grants against the attacking
creature rather than the source.

Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request addresses issue #5335 by certifying per-attacker attack triggers as order-independent and updating ability_rw to resolve TriggeringSource from nested transient statics when the outer target is None. It also adds comprehensive integration tests and updates the combat harness to drain attack triggers before blockers are declared. Feedback on the changes highlights a critical issue in ability_rw.rs where only the first static ability's target is profiled when the outer target is None, which could lead to under-profiling and false auto-ordering if multiple static abilities exist; a fix is suggested to collect and profile all unique affected targets.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/engine/src/game/ability_rw.rs Outdated

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] The disjoint attack fan-out gate only checks each event object against its own trigger source, but then suppresses the legacy event-context prompt for the whole group. Evidence: crates/engine/src/game/triggers.rs:3576 says each event object must be disjoint from every member source, but the implementation only rejects ctx.pending.source_id == attacker at crates/engine/src/game/triggers.rs:3593, while the group-level event_object_excludes_sources used at crates/engine/src/game/triggers.rs:3793 is computed from only the first event object at crates/engine/src/game/triggers.rs:3738. Why it matters: a mixed group of same-controller attack triggers can contain a later single-attacker event whose attacker is another member source, and this path can still drop legacy_batch_prompt, so profiles_conflict treats event-object writes as source-disjoint and may auto-order an order-observable trigger group. Suggested fix: build the set of all group source ids and require every per-trigger attacker/event object to be absent from that set before setting disjoint_per_attacker_fanout, or keep legacy_batch_prompt unless that all-events disjointness proof is available.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 23 card(s), 32 signature(s) (baseline: main 662b02b67d86)

6 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (event=CreateToken, expiry=EndOfTurn)

Examples: A-Ochre Jelly, Aether Refinery, Angel of Suffering (+3 more)

2 card(s) · ability/BecomeCopy · field target: creaturecreature + cards exiled by source

Examples: Lazav, Wearer of Faces, Mimeoplasm, Revered One

2 card(s) · ability/ChangeZone · field target: cardcard + cards exiled by source

Examples: Foreboding Steamboat, Negative Zone Portal

2 card(s) · ability/Mana · added: Mana (kind=activated, mana=1 of {W}{U}{B}{R}{G})

Examples: Insidious Roots, Springleaf Parade

2 card(s) · ability/Mana · removed: Mana (kind=activated, mana=1 of {W}{U}{B}{R}{G})

Examples: Insidious Roots, Springleaf Parade

2 card(s) · ability/the · removed: the

Examples: A-Ochre Jelly, Ochre Jelly

1 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (conditional=instead if (cast from graveyard), event=CreateToken, expiry=EndOfTurn)

Examples: Increasing Confusion

1 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (conditional=previous effect outcome, event=CreateToken, expiry=EndOfTurn)

Examples: Galloping Lizrog

1 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (event=CreateToken, expiry=EndOfTurn, kind=activated)

Examples: Sokrates, Athenian Teacher

1 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (event=CreateToken, expiry=EndOfTurn, kind=activated, timing=sorcery speed)

Examples: Kaya, Geist Hunter

1 card(s) · ability/ChangeZone · added: ChangeZone (from=exile, target=creature + cards exiled by source, to=battlefield)

Examples: Sothera, the Supervoid

1 card(s) · ability/ChangeZone · added: ChangeZone (target=you control creature, to=exile)

Examples: Sothera, the Supervoid

1 card(s) · ability/ChangeZoneAll · field target: permanentpermanent + cards exiled by source

Examples: Trove Warden

1 card(s) · ability/ChangeZoneAll · field target: you control cardyou control card + cards exiled by source

Examples: Kardum, Patron of Flames

1 card(s) · static/Continuous · field affects: token attached by aura or equipment you control creaturetoken you control creature

Examples: Koll, the Forgemaster

1 card(s) · static/Continuous · field affects: you control creature Tokentoken you control permanent

Examples: Jaheira, Friend of the Forest

1 card(s) · static/Continuous · removed: Continuous (affects=self, conditional=has 4+ counters)

Examples: Quest for Renewal

1 card(s) · ability/GivePlayerCounter · added: GivePlayerCounter (count=max(-1*# of poison counters its controller has+9, 0), counter=Poison, kind=activated, target=player, timing=sorcery speed)

Examples: Vraska, Betrayal's Sting

1 card(s) · ability/Mill · removed: Mill (conditional=instead if (cast from graveyard), count=2*event amount, target=parent target)

Examples: Increasing Confusion

1 card(s) · ability/Mill · removed: Mill (count=2*event amount, target=controller)

Examples: Angel of Suffering

1 card(s) · trigger/Phase · field condition: # of creature controlled by player with fewest = 0

Examples: Sothera, the Supervoid

1 card(s) · ability/PutCounter · removed: PutCounter (conditional=previous effect outcome, counter=Multiply { factor: 2, inner: Ref { qty: EventContextAmount } } P1P1, target=self)

Examples: Galloping Lizrog

1 card(s) · ability/TargetOnly · removed: TargetOnly (target=you control creature)

Examples: Sothera, the Supervoid

1 card(s) · static/UntapsDuringEachOtherPlayersUntapStep · added: UntapsDuringEachOtherPlayersUntapStep (affects=you control creature, conditional=has 4+ counters)

Examples: Quest for Renewal

1 card(s) · ability/become · removed: become (kind=activated, timing=sorcery speed)

Examples: Vraska, Betrayal's Sting

… 7 more signature(s) (7 card-changes) — see parse-diff.json
  • 1 card(s) · ability/get · removed: get
  • 1 card(s) · ability/get · removed: get (kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/grant ability · removed: grant ability (affects=parent target, duration=until end of turn, grants=grant ability, kind=activated, target=creature)
  • 1 card(s) · ability/replacement_structure · removed: replacement_structure
  • 1 card(s) · ability/set card types artifact, remove all Creature subtypes, remove all Artifact subt… · added: set card types artifact, remove all Creature subtypes, remove all Artifact subtypes, add subtype Treasure, remove all abilities, grant ability (affects=parent …
  • 1 card(s) · ability/twice · removed: twice (duration=until end of turn, kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/when · removed: when

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans self-assigned this Jul 9, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maintainer review: current head resolves the prior fan-out blocker by checking each event object against the whole trigger-source set, adds a negative mixed-source regression plus Stonehoof runtime coverage, and strips the root PR note artifact. Auto-merge can wait for the restarted checks.

@matthewevans matthewevans added the bug Bug fix label Jul 9, 2026
@matthewevans matthewevans enabled auto-merge July 9, 2026 01:29

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maintainer fixup: current head narrows the per-attacker auto-order shortcut to the proven Stonehoof fanout case, leaving the broader distinct-event C2 guard intact; auto-merge can wait for restarted checks.

@matthewevans matthewevans added this pull request to the merge queue Jul 9, 2026
@matthewevans matthewevans removed their assignment Jul 9, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maintainer handler re-approval for current head: security scan clean, right-seam trigger/ability_rw review clean, discriminating Stonehoof runtime coverage present, CI green.

Merged via the queue into phase-rs:main with commit fced6f1 Jul 9, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiple Trample creatures — 10+ creatures + Stonehoof Chieftain

2 participants