Skip to content

ship/featui show storm copy counts - #7041

Merged
matthewevans merged 9 commits into
mainfrom
ship/featui-show-storm-copy-counts
Aug 5, 2026
Merged

ship/featui show storm copy counts#7041
matthewevans merged 9 commits into
mainfrom
ship/featui-show-storm-copy-counts

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 5, 2026

Copy link
Copy Markdown
Member
  • feat(ui): show storm copy counts
  • test(engine): cover storm display snapshots
  • test(engine): exercise granted storm cast pipeline
  • fix(engine): keep storm outside permanent trigger doublers
  • test(engine): complete storm display coverage
  • test(engine): complete trigger provenance fixtures

Summary by CodeRabbit

  • New Features

    • Storm-triggered spell copy counts are now shown on cards in hand and on the game stack.
    • Counts account for applicable Storm effects and display localized singular/plural labels.
    • Stack entries with different Storm copy counts remain clearly distinguished.
  • Bug Fixes

    • Improved handling of trigger-doubling effects that apply to permanents you control.
    • Corrected Storm detection and timing for granted abilities and duplicate Storm effects.

@matthewevans
matthewevans enabled auto-merge August 5, 2026 19:20
@matthewevans matthewevans added the feature Larger-scoped feature label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Storm triggers now carry typed copy-count provenance through the engine and API. Derived views calculate prospective counts for the viewing player’s hand. Desktop, mobile, and stack components display localized Storm badges. Parser and integration tests cover source scope and trigger-count behavior.

Changes

Storm provenance flow

Layer / File(s) Summary
Provenance contracts and Storm trigger generation
crates/engine/src/types/*, crates/engine/src/game/triggers.rs, crates/engine/src/game/stack.rs, client/src/adapter/types.ts
Adds typed Storm provenance, preserves duplicate cast-time Storm instances, attaches copy counts to generated triggers, and keeps differing provenance values in separate stack groups.
Derived Storm views and stack grouping
crates/engine/src/game/derived_views.rs, crates/engine/src/game/stack.rs
Calculates viewer-scoped prospective Storm counts and exposes trigger provenance in stack details.
Hand and stack Storm badges
client/src/components/hand/*, client/src/components/stack/StackEntry.tsx, client/src/i18n/locales/*/game.json
Displays localized Storm copy-count badges in desktop hand, mobile hand, held cards, and stack metadata.
Parser scope and Storm regression coverage
crates/engine/src/parser/oracle_static/*, crates/engine/tests/integration/*
Preserves controlled-permanent source filters and adds coverage for Storm grants, duplicate instances, and Veyran source scope.
Existing fixture compatibility
crates/engine/src/**, crates/phase-ai/src/**, crates/server-core/src/filter.rs
Initializes optional provenance as None across existing constructors and test fixtures.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CastResolution
  participant TriggerGeneration
  participant DerivedViews
  participant Client
  CastResolution->>TriggerGeneration: preserve cast-time Storm instances
  TriggerGeneration->>DerivedViews: expose Storm provenance and copy count
  DerivedViews->>Client: provide hand counts and stack provenance
  Client->>Client: render localized Storm badges
Loading

Possibly related PRs

  • phase-rs/phase#6674: Both changes update trigger and stack data structures and related fixtures.
  • phase-rs/phase#6812: Both changes preserve distinct stack-entry metadata during grouping.
  • phase-rs/phase#6842: Both changes propagate trigger provenance through the engine and stack metadata.

Suggested labels: quality

Suggested reviewers: lgray, parthmishra, mike-thedude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: displaying Storm copy counts in the UI.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/featui-show-storm-copy-counts

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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Generated for head ccfa34bf95de3a1c20a00bfcd15553e032b3170a.

Parse changes introduced by this PR · 23 card(s), 9 signature(s) (baseline: main fcadeaf0eb31)

🟡 Modified fields (9 signatures)

  • 6 cards · 🔄 static/DoubleTriggers(Any) · changed field affects: you control permanent
    • Affected (first 3): Dr. Beverly Crusher, Felix Five-Boots, Fractured Realm (+3 more)
  • 6 cards · 🔄 static/DoubleTriggers(EntersBattlefield([])) · changed field affects: you control permanent
    • Affected (first 3): Elesh Norn, Mother of Machines, Naban, Dean of Iteration, Starfield Vocalist (+3 more)
  • 3 cards · 🔄 static/DoubleTriggers(CreatureAttacking) · changed field affects: you control permanent
    • Affected (first 3): Isshin, Two Heavens as One, Windcrag Siege, Wulfgar of Icewind Dale
  • 2 cards · 🔄 static/DoubleTriggers(CreatureDying) · changed field affects: you control permanent
    • Affected (first 3): Drivnod, Carnage Dominus, Teysa Karlov
  • 2 cards · 🔄 static/DoubleTriggers(EntersBattlefield([Land])) · changed field affects: you control permanent
    • Affected (first 3): Ancient Greenwarden, Traveling Chocobo
  • 1 card · 🔄 static/DoubleTriggers(BattlefieldTransition(enter=true,leave=true,[Legendary,Artifact]… · changed field affects: you control permanent
    • Affected (first 3): Gandalf the White
  • 1 card · 🔄 static/DoubleTriggers(ControlledCreatureDealtDamage) · changed field affects: you control permanent
    • Affected (first 3): Wayta, Trainer Prodigy
  • 1 card · 🔄 static/DoubleTriggers(ControllerCastOrCopiedSpell([Instant,Sorcery])) · changed field affects: you control permanent
    • Affected (first 3): Veyran, Voice of Duality
  • 1 card · 🔄 static/DoubleTriggers(EntersBattlefield([Artifact,Creature])) · changed field affects: you control permanent
    • Affected (first 3): Panharmonicon

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

@matthewevans
matthewevans added this pull request to the merge queue Aug 5, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
client/src/components/hand/MobileHandDrawer.tsx (1)

307-314: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated Storm copy-count badge into one shared component. All three sites render the identical bg-violet-700 rounded-full count badge with the same t("storm.copies", { count }) title, differing only in position and text size. Consolidate into a single presentational component that accepts a count and a position/size variant.

  • client/src/components/hand/MobileHandDrawer.tsx#L307-L314: replace this <span> block with the shared badge component, passing stormCopyCount and the drawer-card position/size variant.
  • client/src/components/hand/MobileHeldHandCard.tsx#L136-L143: replace this <span> block with the shared badge component, passing stormCopyCount and the held-card position/size variant.
  • client/src/components/hand/PlayerHand.tsx#L1013-L1020: replace this <span> block with the shared badge component, passing stormCopyCount and the fanned hand-card position/size variant.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/hand/MobileHandDrawer.tsx` around lines 307 - 314,
Extract the repeated Storm copy-count badge into a shared presentational
component accepting count and position/size variants, preserving the shared
violet styling and translated title. Replace the badge in
client/src/components/hand/MobileHandDrawer.tsx#L307-L314 with the drawer-card
variant, client/src/components/hand/MobileHeldHandCard.tsx#L136-L143 with the
held-card variant, and client/src/components/hand/PlayerHand.tsx#L1013-L1020
with the fanned hand-card variant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/triggers.rs`:
- Around line 4224-4237: Update the comment immediately above the
storm_instances calculation to describe Storm as a triggered ability that
functions on the stack, matching CR 702.40a/b. Keep the existing explanation
that Storm instances are frozen at cast time and must not be re-evaluated from
live spell keywords; change only the inaccurate “spell ability”
characterization.

In `@crates/engine/src/parser/oracle_static/evasion.rs`:
- Around line 198-209: Update the runtime matching logic associated with doubler
source filters and TypeFilter::Permanent so it matches only battlefield
permanents, excluding permanent spells on the stack. For leave-the-battlefield
sources, resolve the source through last-known-information or zone-change
history before applying the permanent check, and add a runtime test covering
spell sources.

In `@crates/engine/src/parser/oracle_static/tests.rs`:
- Around line 12086-12087: Update the explanatory comment near the Storm trigger
behavior to include a Verified Comprehensive Rules citation in the format “CR
<number>: <description>”. Use the appropriate rule number and describe that
Storm is preserved because its synthesized trigger consumes every cast-time
instance.

In `@crates/engine/tests/integration/granted_storm_snapshot.rs`:
- Around line 18-20: Correct the rules citation in the Storm grant test comment
by replacing CR 601.2f with CR 601.2a, while preserving the existing CR 611.2f
and CR 702.40a references and explanatory text.

In `@crates/engine/tests/integration/veyran_storm_source_scope.rs`:
- Around line 42-45: Add a positive reach guard before the existing
storm_triggers assertion by using runner.cast(...).commit() to verify Veyran
doubles a cast-triggered ability from a controlled permanent. Keep the
storm_triggers == 1 assertion afterward to confirm Storm remains excluded
because its source is the spell, and ensure both checks exercise the production
pipeline.

---

Nitpick comments:
In `@client/src/components/hand/MobileHandDrawer.tsx`:
- Around line 307-314: Extract the repeated Storm copy-count badge into a shared
presentational component accepting count and position/size variants, preserving
the shared violet styling and translated title. Replace the badge in
client/src/components/hand/MobileHandDrawer.tsx#L307-L314 with the drawer-card
variant, client/src/components/hand/MobileHeldHandCard.tsx#L136-L143 with the
held-card variant, and client/src/components/hand/PlayerHand.tsx#L1013-L1020
with the fanned hand-card variant.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99a7fba9-55c4-4731-89e5-6f5d93372e67

📥 Commits

Reviewing files that changed from the base of the PR and between 6290ef0 and ccfa34b.

📒 Files selected for processing (64)
  • client/src/adapter/types.ts
  • client/src/components/hand/MobileHandDrawer.tsx
  • client/src/components/hand/MobileHeldHandCard.tsx
  • client/src/components/hand/PlayerHand.tsx
  • client/src/components/stack/StackEntry.tsx
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/pt/game.json
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/archenemy_tests.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/cipher.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/bounce.rs
  • crates/engine/src/game/effects/change_zone.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/counter.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/transform_effect.rs
  • crates/engine/src/game/effects/venture.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_exile_return_tests.rs
  • crates/engine/src/game/engine_keyword_action_stack_tests.rs
  • crates/engine/src/game/engine_tests.rs
  • crates/engine/src/game/engine_trigger_target_tests.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/planechase.rs
  • crates/engine/src/game/planechase_tests.rs
  • crates/engine/src/game/priority.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/trigger_matchers.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_dedup_regression_tests.rs
  • crates/engine/src/game/triggers_ordering_parity_tests.rs
  • crates/engine/src/game/triggers_pr7_order_template_tests.rs
  • crates/engine/src/game/triggers_push_first_contract_tests.rs
  • crates/engine/src/parser/oracle_static/evasion.rs
  • crates/engine/src/parser/oracle_static/snapshot_tests.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/keywords.rs
  • crates/engine/tests/integration/game_state_boxed_ability_serde.rs
  • crates/engine/tests/integration/granted_storm_snapshot.rs
  • crates/engine/tests/integration/integration_bending.rs
  • crates/engine/tests/integration/issue_3282_consign_to_memory_counter.rs
  • crates/engine/tests/integration/issue_5983_sothera_dies_edict.rs
  • crates/engine/tests/integration/kamigawa_flip_cards.rs
  • crates/engine/tests/integration/louisoix_sacrifice_counter.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/veyran_storm_source_scope.rs
  • crates/engine/tests/integration/vivien_invocation_reflexive_power.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/evasion_removal_priority.rs
  • crates/server-core/src/filter.rs

Comment on lines +4224 to +4237
// CR 702.40a/b: Storm is a spell ability, so its instances are
// frozen when the spell is cast. Do not re-evaluate live spell
// keywords after the cast event: a conditional grant may no longer
// match once the spell itself has entered the cast ledger.
let storm_instances = state
.objects
.get(cast_obj_id)
.map(|obj| {
obj.cast_spell_keywords
.iter()
.filter(|keyword| matches!(keyword, Keyword::Storm))
.count()
})
.unwrap_or_default();

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the CR 702.40a citation: it does not describe Storm as "a spell ability."

The comment states: "Storm is a spell ability, so its instances are frozen when the spell is cast." CR 702.40a actually reads: "Storm is a triggered ability that functions on the stack. "Storm" means "When you cast this spell, copy it for each other spell that was cast before it this turn." "Spell ability" is a distinct CR 113.3a classification, and Storm is not one. Update the comment to accurately describe Storm as a triggered ability that functions on the stack, so the CR annotation matches the rule it cites.

The underlying engineering rationale (snapshot the keyword count at cast time rather than re-querying live keywords) still holds; only the rule characterization is wrong.

As per path instructions for crates/engine/**: "rules-touching code with no verified CR <number>: <description> annotation, or a CR citation whose rule body does not describe the code."

📝 Proposed comment fix
-            // CR 702.40a/b: Storm is a spell ability, so its instances are
-            // frozen when the spell is cast. Do not re-evaluate live spell
-            // keywords after the cast event: a conditional grant may no longer
-            // match once the spell itself has entered the cast ledger.
+            // CR 702.40a/b: Storm is a triggered ability that functions on the
+            // stack, and its instance count is fixed when the spell is cast.
+            // Do not re-evaluate live spell keywords after the cast event: a
+            // conditional grant may no longer match once the spell itself has
+            // entered the cast ledger.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// CR 702.40a/b: Storm is a spell ability, so its instances are
// frozen when the spell is cast. Do not re-evaluate live spell
// keywords after the cast event: a conditional grant may no longer
// match once the spell itself has entered the cast ledger.
let storm_instances = state
.objects
.get(cast_obj_id)
.map(|obj| {
obj.cast_spell_keywords
.iter()
.filter(|keyword| matches!(keyword, Keyword::Storm))
.count()
})
.unwrap_or_default();
// CR 702.40a/b: Storm is a triggered ability that functions on the
// stack, and its instance count is fixed when the spell is cast.
// Do not re-evaluate live spell keywords after the cast event: a
// conditional grant may no longer match once the spell itself has
// entered the cast ledger.
let storm_instances = state
.objects
.get(cast_obj_id)
.map(|obj| {
obj.cast_spell_keywords
.iter()
.filter(|keyword| matches!(keyword, Keyword::Storm))
.count()
})
.unwrap_or_default();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/triggers.rs` around lines 4224 - 4237, Update the
comment immediately above the storm_instances calculation to describe Storm as a
triggered ability that functions on the stack, matching CR 702.40a/b. Keep the
existing explanation that Storm instances are frozen at cast time and must not
be re-evaluated from live spell keywords; change only the inaccurate “spell
ability” characterization.

Source: Path instructions

Comment on lines +198 to +209
/// CR 603.2d: A doubler `affected` filter must name a source-domain constraint.
/// `Permanent` is a real constraint because spells are not permanents; `Card`
/// and `Any` alone do not constrain the source. A clause is therefore valid
/// when it carries a permanent or concrete type/subtype restriction, or a
/// property such as "another" / "of the chosen type".
fn doubler_source_is_restrictive(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::Typed(tf) => {
tf.type_filters.iter().any(|t| {
!matches!(
t,
TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any
)
}) || !tf.properties.is_empty()
tf.type_filters
.iter()
.any(|t| !matches!(t, TypeFilter::Card | TypeFilter::Any))
|| !tf.properties.is_empty()

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
rg -n -C 10 \
  'apply_trigger_doubling|matches_target_filter|type_filter_matches|TypeFilter::Permanent|lki_cache|Zone::Battlefield' \
  crates/engine/src --glob '*.rs'

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== relevant files =="
git ls-files 'crates/engine/src/game/*.rs' 'crates/engine/src/types/game_state.rs' 'crates/engine/src/parser/oracle_static/evasion.rs' | sed -n '1,120p'

echo
echo "== evasion parser section =="
cat -n crates/engine/src/parser/oracle_static/evasion.rs | sed -n '160,230p'

echo
echo "== TypeFilter definition =="
rg -n -C 6 'enum TypeFilter|TypeFilter::Permanent|CoreType::' crates/engine/src/types crates/engine/src/game --glob '*.rs' | sed -n '1,240p'

echo
echo "== trigger and source matcher section locations =="
rg -n -C 8 'struct Trigger|trigger.source_id|TypeFilter::Permanent|type_filter_matches|matches_target_filter|match_target|target_filter' crates/engine/src/game --glob '*.rs' | sed -n '1,260p'

Repository: phase-rs/phase

Length of output: 49949


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== parser type phrase Permanent references =="
rg -n -C 8 'TypeFilter::Permanent|Permanent|parse_type_phrase|typed_filter' cratesngineengine crates/engine/src/parser crates/engine/src/types --glob '*.rs' | sed -n '1,260p'

echo
echo "== trigger doublers and affected filter usages =="
rg -n -C 10 'DoubleTriggers|affected|apply_trigger_doubling|trigger_source_context|triggers_dedup|source_id|matches_target_filter|type_filter_matches_object' crates/engine/src/game crates/engine/src/types --glob '*.rs' | sed -n '1,420p'

Repository: phase-rs/phase

Length of output: 24883


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== exact TypeFilter enum =="
rg -n -C 20 '^pub enum TypeFilter|enum TypeFilter' crates/engine/src --glob '*.rs'

echo
echo "== exact TargetFilter and Typed definition =="
rg -n -C 20 '^pub enum TargetFilter|pub struct TypedFilter|struct TypedFilter' crates/engine/src --glob '*.rs'

echo
echo "== trigger_index relevant =="
cat -n crates/engine/src/game/trigger_index.rs | sed -n '1,140p'

echo
echo "== statics DoubleTriggers relevant =="
rg -n -C 20 'DoubleTriggers|affected|cause' crates/engine/src/types/statics.rs | sed -n '3240,3305p'

Repository: phase-rs/phase

Length of output: 18018


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== filter module list =="
git ls-files 'crates/engine/src/game/filter*.rs' | sed -n '1,40p'

echo
echo "== filter::matches_target_filter source =="
rg -n -C 25 'pub.*fn matches_target_filter|matches_target_filter|type_filter_matches_object|TypeFilter::Permanent|InZone|filter_props|properties' crates/engine/src/game/filter.rs crates/engine/src/game/*.rs --glob '*.rs' | sed -n '1,520p'

echo
echo "== trigger collector and doubler source locations =="
rg -n -C 12 'DoubleTriggers|apply_trigger_doubling|collect_pending_triggers|trigger_source_context|trigger_source_context_for_latch|TriggerCause|cause|affected' crates/engine/src/game crates/engine/src/types --glob '*.rs' | sed -n '1,520p'

echo
echo "== parser static evasion relevant lines =="
cat -n crates/engine/src/parser/oracle_static/evasion.rs | sed -n '220,360p'

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== exact parser static evasion lines =="
sed -n '220,340p' crates/engine/src/parser/oracle_static/evasion.rs

echo
echo "== exact TypeFilter enum =="
sed -n '3809,3835p' crates/engine/src/types/ability.rs

echo
echo "== exact TargetFilter enum start =="
sed -n '5095,5175p' crates/engine/src/types/ability.rs

echo
echo "== filter matches_target_filter definitions =="
rg -n -C 30 'pub fn matches_target_filter|fn matches_target_filter|pub fn object_matches_target|fn object_matches_target|type_filter_matches_object|TypeFilter::Permanent|InZone|properties' crates/engine/src/game/filter.rs crates/engine/src/game --glob '*.rs' | sed -n '1,800p'

echo
echo "== trigger doublers exact lines =="
rg -n -C 20 'DoubleTriggers|DoubleTrigger|apply_trigger_doubling|affected|valid_card|DoubleTriggers' crates/engine/src/game crates/engine/src/types --glob '*.rs' | sed -n '1,700p'

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== file size/outline for filter =="
wc -l crates/engine/src/game/filter.rs
ast-grep outline crates/engine/src/game/filter.rs --match matches_target_filter --view expanded || true

echo
echo "== matches_target_filter implementation =="
rg -n -C 40 'pub fn matches_target_filter|fn matches_target_filter|matches_target_filter\(' crates/engine/src/game/filter.rs | sed -n '1,600p'

echo
echo "== trigger matcher implementation =="
rg -n -C 40 'pub fn .*matches|fn .*matches|target_filter_matches|matches_target_filter|TypeFilter::Permanent|zone' crates/engine/src/game/triggers.rs crates/engine/src/game --glob 'triggers*.rs' | sed -n '1,900p'

echo
echo "== exact trigger collector and doubler implementation =="
rg -n -C 35 'DoubleTriggers|TriggerCause::DoubleTriggers|DoubleTrigger|collect_pending_triggers|trigger_source_context|trigger_source_context_for_latch|apply_trigger_doubling|affected' crates/engine/src/game crates/engine/src/types --glob '*.rs' | sed -n '1,700p'

Repository: phase-rs/phase

Length of output: 50371


Make TypeFilter::Permanent restrict doubles to battlefield permanents.

CR 403.3 defines permanents as objects on the battlefield. A permanent spell has a permanent card type, but it is not a permanent. “Permanent” is accepted as a trigger-doubler source filter, so the runtime matcher must skip stack objects and use LKI zone/zone-change history for leave-the-battlefield sources. Add a runtime test for spell sources to cover the current parser assertion gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_static/evasion.rs` around lines 198 - 209,
Update the runtime matching logic associated with doubler source filters and
TypeFilter::Permanent so it matches only battlefield permanents, excluding
permanent spells on the stack. For leave-the-battlefield sources, resolve the
source through last-known-information or zone-change history before applying the
permanent check, and add a runtime test covering spell sources.

Source: Path instructions

Comment on lines +12086 to +12087
// remains excluded because its cast-grant count is not consumed, while Storm is
// preserved because its synthesized trigger consumes every cast-time instance.

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate file and relevant lines =="
wc -l crates/engine/src/parser/oracle_static/tests.rs
sed -n '12050,12110p' crates/engine/src/parser/oracle_static/tests.rs | cat -n

echo
echo "== Search for nearby CR citations in parser/tests context =="
rg -n "702\.40[ab]|Storm|cast time|triggers? separately" crates/engine/src/parser crates/engine/src -S

echo
echo "== Check for existing CR style in parser/tests =="
rg -n "CR [0-9]+(\.[0-9]+)*:" crates/engine/src/parser -S | head -40

Repository: phase-rs/phase

Length of output: 50370


🌐 Web query:

Magic Comprehensive Rules 2026 CR 702.40a Storm copy count CR 702.40b Storm triggers separately

💡 Result:

According to the Magic: The Gathering Comprehensive Rules, the storm ability is defined under rule 702.40 [1][2]. As of August 2026, the relevant subsections are: 702.40a: Storm is a triggered ability that functions on the stack. "Storm" means "When you cast this spell, copy it for each other spell that was cast before it this turn. If the spell has any targets, you may choose new targets for any of the copies." [1][2] 702.40b: If a spell has multiple instances of storm, each triggers separately [1][2]. Key mechanics of these rules include: - The copies created by the storm ability are placed directly onto the stack and are not considered cast [1]. - Because they are not cast, these copies do not generate their own storm triggers and are not counted by other storm spells cast later in the turn [1]. - If a spell with storm has any targets, you are permitted to choose new targets for each of the individual copies [1].

Citations:


Add a Verified Comprehensive Rules annotation.

Add a CR <number>: <description> citation for the Storm trigger behavior documented by this comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_static/tests.rs` around lines 12086 - 12087,
Update the explanatory comment near the Storm trigger behavior to include a
Verified Comprehensive Rules citation in the format “CR <number>:
<description>”. Use the appropriate rule number and describe that Storm is
preserved because its synthesized trigger consumes every cast-time instance.

Sources: Path instructions, MCP tools

Comment on lines +18 to +20
/// CR 601.2f + CR 611.2f + CR 702.40a: a Storm grant that only applies before
/// the caster has cast a spell this turn is latched before the spell enters the
/// cast ledger, then produces its trigger from that snapshot.

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the CR citation.

Replace CR 601.2f with CR 601.2a. CR 601.2f defines total-cost determination. The cast-time characteristic behavior in this test is covered by CR 601.2a and CR 611.2f. (media.wizards.com)

As per path instructions, rules-touching engine code requires a verified CR citation whose rule body describes the code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/granted_storm_snapshot.rs` around lines 18 -
20, Correct the rules citation in the Storm grant test comment by replacing CR
601.2f with CR 601.2a, while preserving the existing CR 611.2f and CR 702.40a
references and explanatory text.

Source: Path instructions

Comment on lines +42 to +45
assert_eq!(
storm_triggers, 1,
"Veyran must not double Storm because Storm belongs to the spell, not a permanent"
);

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a positive reach guard for Veyran.

storm_triggers == 1 is only a negative assertion. If VEYRAN_DOUBLER_ORACLE is unimplemented, or its static effect is inactive, this assertion still passes.

First verify through runner.cast(...).commit() that Veyran doubles a cast-triggered ability of a controlled permanent. Then retain this assertion to verify that Storm is excluded because its source is the spell on the stack.

As per path instructions, a negative assertion requires a paired positive reach guard through the production pipeline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/veyran_storm_source_scope.rs` around lines 42
- 45, Add a positive reach guard before the existing storm_triggers assertion by
using runner.cast(...).commit() to verify Veyran doubles a cast-triggered
ability from a controlled permanent. Keep the storm_triggers == 1 assertion
afterward to confirm Storm remains excluded because its source is the spell, and
ensure both checks exercise the production pipeline.

Source: Path instructions

Merged via the queue into main with commit 0db6292 Aug 5, 2026
18 of 19 checks passed
@matthewevans
matthewevans deleted the ship/featui-show-storm-copy-counts branch August 5, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant