Skip to content

Partial: Dragon Man, Reformed Robot - #7030

Open
JacobWoodson wants to merge 1 commit into
phase-rs:mainfrom
JacobWoodson:card/dragon-man-reformed-robot
Open

Partial: Dragon Man, Reformed Robot#7030
JacobWoodson wants to merge 1 commit into
phase-rs:mainfrom
JacobWoodson:card/dragon-man-reformed-robot

Conversation

@JacobWoodson

@JacobWoodson JacobWoodson commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a parse-fidelity defect on Dragon Man, Reformed Robot.

Issue: Graveyard-cast permission drops the "by discarding a card in addition to paying its other costs" additional cost (extra_cost is None instead of a CastExtraCost with mode Additional / discard-a-card), so it parses as castable from the graveyard for its normal cost with no discard required.

Files changed

  • crates/engine/src/parser/oracle_cost.rs
  • crates/engine/src/parser/oracle_static/mod.rs
  • crates/engine/src/parser/oracle_static/restriction.rs
  • crates/engine/src/parser/oracle_casting.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs
  • crates/engine/tests/integration/main.rs

CR references

  • CR 601.2b
  • CR 601.2f
  • CR 601.2h
  • CR 118.3
  • CR 118.9
  • CR 119.8
  • CR 701.9a

Track

Developer

LLM

Model: claude-opus-4-8
Thinking: high

Tier: Frontier

Verification

  • cargo fmt --all — passed
  • ./scripts/check-parser-combinators.sh (Gate A) — passed
  • cargo clippy-strict — passed
  • cargo test -p phase-engine — failed
  • cargo export-cards data --stats --output data/card-data.json --sidecar-dir client/public + mirror to client/public (card-data regen) — passed
  • cargo coverage — incomplete
  • cargo semantic-audit — not_run

Scope Expansion

None.

Validation Failures

See review/cross-check notes.

CI Failures

  • cargo test -p phase-engine: 2 failing tests, BOTH pre-existing and UNRELATED to Dragon Man -- (1) game::engine::stage2_injector_tests::the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event and (2) game::engine::bounded_offer_conjunct_tests::f2c_the_cr_603_5_conjunct_set_has_one_production_assembler. Both live in committed crates/engine/src/game/engine.rs (bounded-offer work, commit 4b34e54 + rebases), NOT in my working-tree diff. Root cause is a Windows path-separator issue: the tests build producer-location strings via Path::display() (engine.rs:15273) which emits backslashes on Windows, but the expected vecs hardcode forward slashes (engine.rs:15386-15388, e.g. "game/effects/mod.rs:6175"). The count/partition asserts (total 37, partition 5/7/25) PASS; only the exact string-vector comparison fails on / vs . Not fixed: maintainer-owned meta-test infra with elaborate drift logs, out of scope for card verification, and CLAUDE.md multi-agent safety forbids editing files I did not author. Deterministic, so retries were futile.
  • cargo coverage: did NOT complete within the session. Initial run + retry failed at LINK with LNK1120 (347 unresolved anon..llvm. externals) from a corrupted target/tool incremental artifact (stale libengine rlib missing codegen units) -- a build-environment issue, not a source problem (clippy-strict compiled the whole engine cleanly at dev profile; oracle-gen linked fine at tool profile). Retry 2 cleared phase-engine's tool-profile artifacts (cargo clean -p phase-engine --profile tool; removed 82.6 GiB) and target/tool/incremental, then re-ran coverage; the tool-profile rebuild (engine + deps at opt-level 1) was still compiling phase-engine when the harness forced finalization. Therefore Dragon Man was NOT coverage-confirmed as supported:true gap:0. NOTE: direct inspection of the freshly regenerated data/card-data.json shows 'dragon man, reformed robot' parses fully with 0 Unimplemented effects (typed Flying keyword; SetDynamicPower CDA = Max of two Aggregate/Max ManaValue refs over noncreature permanents-you-control and noncreature graveyard cards; GraveyardCastPermission static with a Discard additional cost) -- strong evidence it is supported, but not the required coverage confirmation.
  • cargo semantic-audit: NOT run -- blocked behind the same in-progress tool-profile rebuild. semanticAuditClean could not be determined.

Summary by CodeRabbit

  • Bug Fixes

    • Improved casting validation for cards played from the graveyard or exile, including discard, sacrifice, counter-removal, and exile costs.
    • Correctly parses additional costs such as paying, discarding, sacrificing, tapping, removing, and exiling.
    • Prevents casting when required costs are unsupported or cannot be paid.
    • Preserves source-zone restrictions for complex cost conditions.
  • Tests

    • Added coverage for graveyard casting with discard and exile costs, including successful casts and invalid-cost scenarios.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds shared gerund-cost parsing, preserves modeled additional costs in graveyard-cast permissions, rejects unsupported riders, and validates affordability for all static extra costs. Regression and integration tests cover discard and exile costs.

Changes

Graveyard cast cost handling

Layer / File(s) Summary
Gerund cost parsing and self-flash handling
crates/engine/src/parser/oracle_cost.rs, crates/engine/src/parser/oracle_casting.rs, crates/engine/src/parser/oracle_static/mod.rs
Supported gerund phrases now produce AbilityCost values. Composite filters retain embedded zones. Unsupported self-flash costs reject the casting option.
Graveyard permission rider parsing
crates/engine/src/parser/oracle_static/restriction.rs, crates/engine/src/parser/oracle_static/tests.rs
Graveyard permissions distinguish absent, parsed, and unmodeled additional-cost riders. Parsed riders become CastExtraCost values. Unsupported riders reject the permission.
Additional cost affordability validation
crates/engine/src/game/casting.rs
Static extra costs now use AbilityCost::is_payable, including discard, sacrifice, counter-removal, and pay-life costs.
End-to-end graveyard cast validation
crates/engine/tests/integration/*graveyard*_cost.rs, crates/engine/tests/integration/main.rs
Integration tests cover Demilich, Helbrute, and Dragon Man casts, card movement, insufficient cards, and missing discard selections.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant GraveyardPermissionParser
  participant CastValidation
  participant GameState
  OracleText->>GraveyardPermissionParser: parse gerund additional cost
  GraveyardPermissionParser-->>GraveyardPermissionParser: create CastExtraCost or decline
  GraveyardPermissionParser->>CastValidation: provide cast permission
  CastValidation->>CastValidation: check AbilityCost::is_payable
  CastValidation->>GameState: apply mana and additional costs
  GameState-->>GameState: move cards and resolve permanent
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 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 identifies the affected card and matches the pull request scope, but it does not state the specific parsing fix.
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 unit tests (beta)
  • Create PR with unit tests

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.

@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: 4

🧹 Nitpick comments (3)
crates/engine/src/parser/oracle_static/tests.rs (2)

14268-14286: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The dispatch assertion does not check what the comment claims.

The comment states the dispatch must leave "no Unimplemented node behind for it", but the assertion only proves that some static with the same mode exists. A leftover Effect::Unimplemented for the same line would still pass. hogaak_full_card_records_restriction_and_drops_no_unimplemented_line in crates/engine/src/parser/oracle_casting.rs (Line 873) shows the pattern that actually enforces the claim.

Add the absence check, or narrow the comment.

💚 Suggested addition
     assert!(
         parsed
             .statics
             .iter()
             .any(|parsed_def| parsed_def.mode == def.mode),
         "full Oracle dispatch must route Dragon Man's line to the discard-cost \
          permission, got {:?}",
         parsed.statics
     );
+    assert!(
+        !parsed.abilities.iter().any(|ability| matches!(
+            ability.effect.as_ref(),
+            crate::types::ability::Effect::Unimplemented { .. }
+        )),
+        "the graveyard line must not also leave an Unimplemented effect behind: {:?}",
+        parsed.abilities
+    );
🤖 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 14268 - 14286,
Strengthen the assertion in the full Oracle dispatch test around
parse_oracle_text so it verifies that no Unimplemented node remains for the
tested line, following the absence-check pattern in
hogaak_full_card_records_restriction_and_drops_no_unimplemented_line. Keep the
existing mode-dispatch assertion and ensure the comment accurately reflects both
requirements.

Source: Path instructions


14339-14342: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the exile filter contents, not just is_some().

filter.is_some() passes for any filter, including a bare TargetFilter::Any. The rules-relevant part of Demilich's rider is that only instant and sorcery cards can pay it. A regression that widened the filter to "any card" would keep this test green.

Assert the disjunction legs carry TypeFilter::Instant and TypeFilter::Sorcery, as cost_exile_self_and_count_other_you_control_recovers_count_and_filter does in crates/engine/src/parser/oracle_cost.rs (Line 2120).

🤖 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 14339 - 14342,
Strengthen the Demilich exile-cost assertion around filter by matching its
contents rather than only checking filter.is_some(). Verify the filter’s
disjunction includes TypeFilter::Instant and TypeFilter::Sorcery, following the
assertion pattern used by
cost_exile_self_and_count_other_you_control_recovers_count_and_filter, while
rejecting a bare TargetFilter::Any.

Source: Path instructions

crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs (1)

131-159: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The blocked case does not prove the instant/sorcery filter is enforced.

The blocked scenario has three eligible cards and the allowed scenario has four. Both outcomes are explained by raw count alone. If the exile filter regressed to "any card", the blocked scenario would still contain only three non-Demilich cards, so the test stays green while the rules fidelity is lost.

Add a third scenario: three instant/sorcery cards plus one ineligible graveyard card (for example a creature card). The cast must still be blocked. That isolates the filter from the count.

🤖 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/demilich_helbrute_graveyard_exile_cost.rs`
around lines 131 - 159, Extend
demilich_graveyard_cast_blocked_without_four_exilable_cards with a third
scenario containing exactly three instant/sorcery cards and one ineligible card
such as a creature. Give it the same phase, life, and mana setup, then assert
can_cast_object_now is false to verify the instant/sorcery filter rather than
raw graveyard count.

Source: Path instructions

🤖 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/casting.rs`:
- Around line 13848-13851: Add a focused test covering the cast-permission path
around static_extra and is_payable for an Exile cost with a variable count such
as EXILE_COST_X. Verify that the cost is payable for every eligible exile-card
choice, while preserving the existing behavior for ineligible choices.

In `@crates/engine/src/parser/oracle_casting.rs`:
- Around line 236-241: Update the self-flash parsing arm around
parse_gerund_cost to recognize both “its other costs” and “their other costs”
closers, with an optional “paying ” prefix, matching the additional-cost
combinator used by parse_cast_permission_additional_cost_rider. De-gerund the
captured cost before calling parse_gerund_cost, and preserve the existing
Unimplemented handling and option.cost flow.

In `@crates/engine/src/parser/oracle_cost.rs`:
- Around line 87-107: Update parse_gerund_cost to use TextPair (or the module’s
existing nom_on_lower pattern) so gerund matching remains case-insensitive while
the matched remainder retains its original casing. Pass that original-cased
remainder to parse_oracle_cost, preserving subtype values such as “Vehicle” for
downstream filters.

In `@crates/engine/src/parser/oracle_static/tests.rs`:
- Around line 14339-14342: The tests do not verify that the exile-cost filters
restrict cards correctly. In
crates/engine/src/parser/oracle_static/tests.rs#L14339-L14342, replace the
presence check with assertions that the disjunction legs use TypeFilter::Instant
and TypeFilter::Sorcery; in
crates/engine/src/parser/oracle_static/tests.rs#L14391-L14400, bind the filter
instead of discarding it and assert TypeFilter::Creature with
FilterProp::Another; in
crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs#L131-L159,
add three eligible instant/sorcery cards plus one ineligible graveyard card and
assert casting remains blocked.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_static/tests.rs`:
- Around line 14268-14286: Strengthen the assertion in the full Oracle dispatch
test around parse_oracle_text so it verifies that no Unimplemented node remains
for the tested line, following the absence-check pattern in
hogaak_full_card_records_restriction_and_drops_no_unimplemented_line. Keep the
existing mode-dispatch assertion and ensure the comment accurately reflects both
requirements.
- Around line 14339-14342: Strengthen the Demilich exile-cost assertion around
filter by matching its contents rather than only checking filter.is_some().
Verify the filter’s disjunction includes TypeFilter::Instant and
TypeFilter::Sorcery, following the assertion pattern used by
cost_exile_self_and_count_other_you_control_recovers_count_and_filter, while
rejecting a bare TargetFilter::Any.

In `@crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs`:
- Around line 131-159: Extend
demilich_graveyard_cast_blocked_without_four_exilable_cards with a third
scenario containing exactly three instant/sorcery cards and one ineligible card
such as a creature. Give it the same phase, life, and mana setup, then assert
can_cast_object_now is false to verify the instant/sorcery filter rather than
raw graveyard count.
🪄 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: b0da4f2d-c633-4a71-8c1a-1378ea488b02

📥 Commits

Reviewing files that changed from the base of the PR and between 152b368 and 4e83f70.

📒 Files selected for processing (9)
  • crates/engine/src/game/casting.rs
  • crates/engine/src/parser/oracle_casting.rs
  • crates/engine/src/parser/oracle_cost.rs
  • crates/engine/src/parser/oracle_static/mod.rs
  • crates/engine/src/parser/oracle_static/restriction.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs
  • crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs
  • crates/engine/tests/integration/main.rs

Comment on lines 13848 to 13851
if let Some(extra) = static_extra {
if let Some(amount) = find_pay_life_cost(&extra.cost, state, player, prepared.object_id)
{
if !super::life_costs::can_pay_life_cast_or_activation_cost(state, player, amount) {
return false;
}
if !extra.cost.is_payable(state, player, prepared.object_id) {
return false;
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect AbilityCost::is_payable for sentinel-count and composite handling.
set -euo pipefail

rg -nP --type=rust -C2 'fn is_payable' | head -40

fd -t f -e rs --exec rg -ln 'fn is_payable' {} \; | while IFS= read -r f; do
  echo "=== $f ==="
  ast-grep outline "$f" --match 'is_payable' --view expanded
  rg -n -C6 'EXILE_COST_X|REMOVE_COUNTER_COST_X|REMOVE_COUNTER_COST_ANY_NUMBER|u32::MAX' "$f"
  rg -n -A12 'AbilityCost::(OneOf|Composite)' "$f"
done

Repository: phase-rs/phase

Length of output: 319


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Changed file context:"
sed -n '13828,13865p' crates/engine/src/game/casting.rs

echo
echo "Find is_payable occurrences:"
rg -n -C4 'fn is_payable|use .*is_payable|\.is_payable\(' crates/engine/src crates -g '*.rs' | head -200

echo
echo "Find AbilityCost definitions:"
rg -n -C4 'enum AbilityCost|struct AbilityCost|type AbilityCost' crates/engine/src crates -g '*.rs' | head -200

echo
echo "Specific sentinel identifiers:"
rg -n 'EXILE_COST_X|REMOVE_COUNTER_COST_X|REMOVE_COUNTER_COST_ANY_NUMBER|u32::MAX|CostSentinel|Sentinel' crates/engine/src crates -g '*.rs' | head -200

Repository: phase-rs/phase

Length of output: 39835


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "AbilityCost definition and payable helper body:"
sed -n '8730,9050p' crates/engine/src/types/ability.rs
sed -n '250,720p' crates/engine/src/game/cost_payability.rs

echo
echo "Targeted is_payable tests:"
sed -n '1300,1450p' crates/engine/src/game/cost_payability.rs
sed -n '33920,34480p' crates/engine/src/game/casting_tests.rs

Repository: phase-rs/phase

Length of output: 50370


Add a direct test for variable-count exile affordability.

is_payable handles Composite and OneOf correctly, and variable remove-counter counts are already payable. The remaining untested shape on this cast-permission path is variable exile costs; make sure Exile { EXILE_COST_X, .. } accepts any eligible exile card choice.

🤖 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/casting.rs` around lines 13848 - 13851, Add a focused
test covering the cast-permission path around static_extra and is_payable for an
Exile cost with a variable count such as EXILE_COST_X. Verify that the cost is
payable for every eligible exile-card choice, while preserving the existing
behavior for ineligible choices.

Comment on lines +236 to 241
let cost = parse_gerund_cost(cost_text);
if matches!(cost, AbilityCost::Unimplemented { .. }) {
return None;
}
option = option.cost(cost);
return Some(option);

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify which "in addition to ... other costs" closer variants appear alongside a flash rider.
set -euo pipefail

fd -t f 'card-data' | head -20

fd -t f -e json 'card-data' --exec rg -o -i 'as though it had flash by [^."]*' {} \; | sort -u | head -50

fd -t f -e json 'card-data' --exec rg -o -i 'in addition to (paying )?(its|their) other costs' {} \; | sort | uniq -c

Repository: phase-rs/phase

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching oracle_casting/restriction nearby\n'
fd -t f 'oracle_casting|restriction' crates/engine/src/parser | sed -n '1,120p'

printf '\nRelevant section of oracle_casting.rs\n'
sed -n '200,290p' crates/engine/src/parser/oracle_casting.rs

printf '\nRelevant section of restriction.rs\n'
sed -n '2070,2110p' crates/engine/src/parser/oracle_static/restriction.rs

printf '\nSearch nearby definitions and call sites\n'
rg -n "parse.*self.*flash|self.flash|self-fla|In addition to|paying their other costs|its other costs|other costs|AbilityCost::Unimplemented" crates/engine/src/parser crates/engine -g '*.rs' | sed -n '1,220p'

Repository: phase-rs/phase

Length of output: 37167


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe: extract the exact parser condition from oracle_casting.rs and test the
# candidate self-flash rider variants against the same strip_suffix branch.
python3 - <<'PY'
from pathlib import Path
import re

path = Path('crates/engine/src/parser/oracle_casting.rs')
text = path.read_text()
m = re.search(r'if let Some\(cost_text\) = text\.strip_suffix\(" in addition to paying its other costs"\)', text)
print('self-flash strip_suffix target:', m.group(0) if m else 'not found')

target = ' in addition to paying its other costs'
cases = [
    "If you cast this spell, you may cast it as though it had flash in addition to its other costs.",
    "If you cast this spell, you may cast it as though it had flash in addition to their other costs.",
    "If you cast this spell, you may cast it as though it had flash in addition to paying its other costs.",
    "If you cast this spell, you may cast it as though it had flash in addition to paying their other costs.",
]
for case in cases:
    print(repr(case), 'strips=', case.endswith(target), 'slices=', case[:-len(target)] if case.endswith(target) else None)
PY

Repository: phase-rs/phase

Length of output: 708


🌐 Web query:

Magic: the Gathering collector search oracle cards as though it had flash by in addition to its other costs

💡 Result:

In Magic: The Gathering, "as though it had flash" is not a keyword ability itself, but a specific phrasing used in card text to create a static, permissive effect [1][2]. This effect allows a player to cast a spell at a time they would normally be unable to, specifically treating the spell as if it possessed the Flash keyword [3][1]. Under the Comprehensive Rules, rule 609.4 defines these "as though" effects as those that allow a player to do something as if a specific condition were true [4][1]. When an effect states, for example, "You may cast creature spells as though they had flash," it modifies the timing restrictions for casting those spells, enabling the player to cast them anytime they could cast an instant [5][3]. Key technical aspects include: 1. Scope of Effect: These effects apply strictly to the action of casting the spell [6][7][8]. They do not change the timing for activating abilities that can normally only be activated "as a sorcery" [6][7][8]. 2. Bestow and Other Qualities: If a player is granted the ability to cast certain spells (e.g., Auras) as though they had flash, they may initiate casting a spell with variable qualities (such as a creature with Bestow) as though it had flash if they choose to cast it in a mode that satisfies those qualities [3][9]. 3. Cumulative Effects: If multiple "as though" effects apply, they function cumulatively [4][1]. For instance, if you are allowed to cast spells from a graveyard as though they were in your hand and also as though they had flash, both permissions apply [4][1]. Common examples of cards featuring this effect include Vedalken Orrery ("You may cast spells as though they had flash") [4][1], Winding Canyons [1], Prophet of Kruphix [5], and Borne Upon a Wind [10][8].

Citations:


🌐 Web query:

Magic the Gathering Oracle "You may cast this spell as though it had flash by in addition to paying its other costs"

💡 Result:

In Magic: The Gathering, the phrase "You may cast this spell as though it had flash by [doing X] in addition to paying its other costs" is a template used to grant a spell a timing-based ability (flash) conditional on paying an additional cost. 1. Timing: The phrase "as though it had flash" allows you to cast the spell at any time you could cast an instant, regardless of its original card type (e.g., a sorcery) [1][2][3]. 2. Cost: The phrase "by [doing X] in addition to paying its other costs" defines an additional cost you must pay to utilize this timing benefit [4]. "Other costs" refer to the spell's mana cost and any other applicable costs (such as kicker, commander tax, or previous alternative costs) [4][5]. 3. Interaction: This construction allows a player to pay the specified additional cost to change the spell's timing restriction [4][5]. Because this is an additional cost, it is added to the spell's total cost calculation [4][5]. If the spell is cast using this effect, the card is treated as having flash for that specific casting event [6][4]. An example of this phrasing can be found on cards like Tegwyll's Scouring, which states: "You may cast Tegwyll's Scouring as though it had flash by tapping three untapped creatures you control with flying in addition to paying its other costs" [7].

Citations:


🌐 Web query:

Tegwyll's Scouring Oracle text "You may cast ... as though it had flash by tapping three untapped creatures ... in addition to paying its other costs"

💡 Result:

The official Oracle text for the Magic: The Gathering card Tegwyll's Scouring is: "You may cast this spell as though it had flash by tapping three untapped creatures you control with flying in addition to paying its other costs. Destroy all creatures. Create three 1/1 black Faerie Rogue creature tokens with flying." [1][2] This card is a Sorcery from the Wilds of Eldraine Commander set [1][2]. The ability allows you to cast it at instant speed (as though it had flash) by tapping three untapped creatures with flying you control, provided you also pay the standard mana cost of {4}{B}{B} [1][2].

Citations:


🌐 Web query:

site:api.scryfall.com/cards/named name:"Tegwyll's Scouring" oracle_text:"You may cast"

💡 Result:

No exact search results were found for that Scryfall API query.

The card’s Oracle text is:

You may cast Tegwyll’s Scouring as though it had flash by tapping three untapped creatures you control with flying in addition to paying its other costs. Destroy all creatures. Create three 1/1 black Faerie Rogue creature tokens with flying. (mtgstocks.com)

Citations:


Handle self-flash additional-cost closers the same shape as the graveyard parser.

The self-flash arm currently matches only by <cost> in addition to paying its other costs. A canonical variant such as You may cast ... as though it had flash by tapping X in addition to paying their other costs would fail this strip_suffix, fall through the other guarded arms, and emit Some(option) with no cost. Use the shared additional-cost-combinator pattern from parse_cast_permission_additional_cost_rider: optional paying , then its other costs or their other costs, before de-gerunding the cost.

🤖 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_casting.rs` around lines 236 - 241, Update
the self-flash parsing arm around parse_gerund_cost to recognize both “its other
costs” and “their other costs” closers, with an optional “paying ” prefix,
matching the additional-cost combinator used by
parse_cast_permission_additional_cost_rider. De-gerund the captured cost before
calling parse_gerund_cost, and preserve the existing Unimplemented handling and
option.cost flow.

Source: Coding guidelines

Comment on lines +87 to +107
pub(crate) fn parse_gerund_cost(phrase: &str) -> AbilityCost {
type E<'a> = super::oracle_nom::error::OracleError<'a>;
let lower = phrase.trim().to_lowercase();
// Compose one `value(stem, tag(gerund))` arm per cost verb — each maps a
// gerund onto the imperative stem `parse_oracle_cost` already recognizes.
let deconjugated = alt((
value("pay", tag::<_, _, E<'_>>("paying ")),
value("discard", tag("discarding ")),
value("sacrifice", tag("sacrificing ")),
value("tap", tag("tapping ")),
value("remove", tag("removing ")),
value("exile", tag("exiling ")),
))
.parse(lower.as_str());
let Ok((rest, stem)) = deconjugated else {
return AbilityCost::Unimplemented {
description: phrase.trim().to_string(),
};
};
parse_oracle_cost(&format!("{stem} {rest}"))
}

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 | 🟡 Minor | ⚡ Quick win

Preserve the original casing when de-conjugating the gerund.

parse_gerund_cost lowercases the whole phrase and then feeds the lowercased remainder into parse_oracle_cost. The downstream filter parsers keep the casing of their input for subtypes: cost_exile_self_and_count_other_you_control_recovers_count_and_filter (Line 2128) asserts TypeFilter::Subtype("Vehicle".to_string()) from a mixed-case input. A subtype-bearing gerund rider such as "sacrificing a Vehicle" therefore lowers to Subtype("vehicle") instead of Subtype("Vehicle"), which will not match the printed subtype at runtime.

No currently parsed card reaches that arm (the shipped riders are discard, pay-life, and exile), so this is latent. It becomes a wrong-filter bug as soon as a subtype rider is added. Match on the lowered text but slice the original, as the rest of this module does with nom_on_lower / TextPair.

♻️ Suggested shape
-    let lower = phrase.trim().to_lowercase();
-    let deconjugated = alt((
-        value("pay", tag::<_, _, E<'_>>("paying ")),
-        value("discard", tag("discarding ")),
-        value("sacrifice", tag("sacrificing ")),
-        value("tap", tag("tapping ")),
-        value("remove", tag("removing ")),
-        value("exile", tag("exiling ")),
-    ))
-    .parse(lower.as_str());
-    let Ok((rest, stem)) = deconjugated else {
+    let original = phrase.trim();
+    let lower = original.to_lowercase();
+    let deconjugated = nom_on_lower(original, &lower, |i| {
+        alt((
+            value("pay", tag::<_, _, E<'_>>("paying ")),
+            value("discard", tag("discarding ")),
+            value("sacrifice", tag("sacrificing ")),
+            value("tap", tag("tapping ")),
+            value("remove", tag("removing ")),
+            value("exile", tag("exiling ")),
+        ))
+        .parse(i)
+    });
+    let Some((stem, rest)) = deconjugated else {
         return AbilityCost::Unimplemented {
-            description: phrase.trim().to_string(),
+            description: original.to_string(),
         };
     };

As per coding guidelines: "Use TextPair for case-insensitive matching that preserves original casing".

📝 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
pub(crate) fn parse_gerund_cost(phrase: &str) -> AbilityCost {
type E<'a> = super::oracle_nom::error::OracleError<'a>;
let lower = phrase.trim().to_lowercase();
// Compose one `value(stem, tag(gerund))` arm per cost verb — each maps a
// gerund onto the imperative stem `parse_oracle_cost` already recognizes.
let deconjugated = alt((
value("pay", tag::<_, _, E<'_>>("paying ")),
value("discard", tag("discarding ")),
value("sacrifice", tag("sacrificing ")),
value("tap", tag("tapping ")),
value("remove", tag("removing ")),
value("exile", tag("exiling ")),
))
.parse(lower.as_str());
let Ok((rest, stem)) = deconjugated else {
return AbilityCost::Unimplemented {
description: phrase.trim().to_string(),
};
};
parse_oracle_cost(&format!("{stem} {rest}"))
}
pub(crate) fn parse_gerund_cost(phrase: &str) -> AbilityCost {
type E<'a> = super::oracle_nom::error::OracleError<'a>;
let original = phrase.trim();
let lower = original.to_lowercase();
// Compose one `value(stem, tag(gerund))` arm per cost verb — each maps a
// gerund onto the imperative stem `parse_oracle_cost` already recognizes.
let deconjugated = nom_on_lower(original, &lower, |i| {
alt((
value("pay", tag::<_, _, E<'_>>("paying ")),
value("discard", tag("discarding ")),
value("sacrifice", tag("sacrificing ")),
value("tap", tag("tapping ")),
value("remove", tag("removing ")),
value("exile", tag("exiling ")),
))
.parse(i)
});
let Some((stem, rest)) = deconjugated else {
return AbilityCost::Unimplemented {
description: original.to_string(),
};
};
parse_oracle_cost(&format!("{stem} {rest}"))
}
🤖 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_cost.rs` around lines 87 - 107, Update
parse_gerund_cost to use TextPair (or the module’s existing nom_on_lower
pattern) so gerund matching remains case-insensitive while the matched remainder
retains its original casing. Pass that original-cased remainder to
parse_oracle_cost, preserving subtype values such as “Vehicle” for downstream
filters.

Source: Coding guidelines

Comment on lines +14339 to +14342
assert!(
filter.is_some(),
"the exile cost must carry the instant/sorcery card filter, got {filter:?}"
);

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 | 🟡 Minor | ⚡ Quick win

No test asserts the exile cost's filter, at any layer. The parser tests check count, zone, and mode, and the integration test varies only the number of eligible cards. A regression that widened the exile filter to "any card" — or that dropped FilterProp::Another — keeps every assertion in this PR green, while letting an ineligible card, or Helbrute itself, pay the cost.

  • crates/engine/src/parser/oracle_static/tests.rs#L14339-L14342: replace filter.is_some() with assertions that the disjunction legs carry TypeFilter::Instant and TypeFilter::Sorcery.
  • crates/engine/src/parser/oracle_static/tests.rs#L14391-L14400: bind the filter instead of discarding it with .., then assert TypeFilter::Creature and FilterProp::Another.
  • crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs#L131-L159: add a scenario with three instant/sorcery cards plus one ineligible graveyard card, and assert the cast stays blocked.
📍 Affects 2 files
  • crates/engine/src/parser/oracle_static/tests.rs#L14339-L14342 (this comment)
  • crates/engine/src/parser/oracle_static/tests.rs#L14391-L14400
  • crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs#L131-L159
🤖 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 14339 - 14342,
The tests do not verify that the exile-cost filters restrict cards correctly. In
crates/engine/src/parser/oracle_static/tests.rs#L14339-L14342, replace the
presence check with assertions that the disjunction legs use TypeFilter::Instant
and TypeFilter::Sorcery; in
crates/engine/src/parser/oracle_static/tests.rs#L14391-L14400, bind the filter
instead of discarding it and assert TypeFilter::Creature with
FilterProp::Another; in
crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs#L131-L159,
add three eligible instant/sorcery cards plus one ineligible graveyard card and
assert casting remains blocked.

Source: Path instructions

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Generated for head 4e83f700347a291e3858a4577a501d6915787f3b.

Parse changes introduced by this PR · 14 card(s), 9 signature(s) (baseline: main 152b36879be0)

🟢 Added (4 signatures)

  • 10 cards · ➕ static/GraveyardCastPermission(Cast,unlimited,extra_cost=additional) · added: GraveyardCastPermission(Cast,unlimited,extra_cost=additional) (affects=self)
    • Affected (first 3): A-Demilich, Alien Symbiosis, Demilich (+7 more)
  • 1 card · ➕ static/GraveyardCastPermission(Cast,once_per_turn,extra_cost=additional) · added: GraveyardCastPermission(Cast,once_per_turn,extra_cost=additional) (affects=creature)
    • Affected (first 3): Kotis, Sibsig Champion
  • 1 card · ➕ static/GraveyardCastPermission(Cast,once_per_turn,extra_cost=additional) · added: GraveyardCastPermission(Cast,once_per_turn,extra_cost=additional) (affects=permanent, conditional=has 8+ counters)
    • Affected (first 3): Exploration Broodship
  • 1 card · ➕ ability/static_structure · added: static_structure
    • Affected (first 3): Maestros Ascendancy

🔴 Removed (4 signatures)

  • 10 cards · ➖ static/GraveyardCastPermission(Cast,unlimited) · removed: GraveyardCastPermission(Cast,unlimited) (affects=self)
    • Affected (first 3): A-Demilich, Alien Symbiosis, Demilich (+7 more)
  • 1 card · ➖ static/GraveyardCastPermission(Cast,once_per_turn) · removed: GraveyardCastPermission(Cast,once_per_turn) (affects=creature)
    • Affected (first 3): Kotis, Sibsig Champion
  • 1 card · ➖ static/GraveyardCastPermission(Cast,once_per_turn) · removed: GraveyardCastPermission(Cast,once_per_turn) (affects=instant or sorcery)
    • Affected (first 3): Maestros Ascendancy
  • 1 card · ➖ static/GraveyardCastPermission(Cast,once_per_turn) · removed: GraveyardCastPermission(Cast,once_per_turn) (affects=permanent, conditional=has 8+ counters)
    • Affected (first 3): Exploration Broodship

🔵 Support status (1 signature)

  • 1 card · ↕️ cost/CastingOption:AsThoughHadFlash · support: unsupportedsupported
    • Affected (first 3): Tegwyll's Scouring

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

@matthewevans matthewevans self-assigned this Aug 5, 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.

Request changes — compound gerund additional costs are not safe to mark supported.

🔴 Blocker

[HIGH] Compound gerund additional costs remain falsely payable. Evidence: crates/engine/src/parser/oracle_static/restriction.rs:2105-2110 rejects only a top-level AbilityCost::Unimplemented; the cost parser can retain an unimplemented discard/sacrifice conjunct inside Composite, crates/engine/src/game/cost_payability.rs:641-650,706-708 recursively treats that node as payable, and payment then fails at crates/engine/src/game/costs.rs:1341-1344. The current parse-diff names Demonic Embrace and Wickerfolk Indomitable, while its 14 cards / 9 signatures also exceeds the PR narrative. Why it matters: CR 601.2f and CR 601.2h (verified in docs/MagicCompRules.txt:2468,2472) require additional costs to be locked and fully payable; this path advertises a cast that cannot make a partial/unpayable payment. Suggested fix: recursively reject the permission until every composite conjunct is concrete, with runtime tests that exercise both compound components.

[MED] Gerund parsing lowercases subtype identity. Evidence: crates/engine/src/parser/oracle_cost.rs passes a lowercased remainder through the cost parsing path. Why it matters: subtype filters require the original spelling/canonical identity. Suggested fix: match case-insensitively with TextPair (or the existing equivalent) while passing the original-cased remainder downstream.

[MED] The added tests do not prove the claimed filters or dispatch completeness. Evidence: the new parser tests do not assert the actual filters/Another, and do not directly assert no Effect::Unimplemented remains for the dispatched line. Why it matters: a widened filter or partial parser fallback can remain green. Suggested fix: assert precise filter structure plus ineligible cases and a direct no-Unimplemented dispatch assertion.

Recommendation: request changes.

@matthewevans matthewevans added the bug Bug fix label Aug 5, 2026
@matthewevans

Copy link
Copy Markdown
Member

Correction to my current-head changes-requested review: I retract only the MED claim that gerund lowercasing breaks subtype identity. parse_gerund_cost delegates type/subtype recognition through the case-insensitive canonicalization path (oracle_util::parse_subtype, used by oracle_target), so that cited Vehicle case is not a present defect. The HIGH compound-gerund nested-Unimplemented/false-supported-cost blocker and the filter/no-Unimplemented test-discrimination requirements remain unchanged.

@matthewevans matthewevans removed their assignment Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants