Skip to content

fix(parser): UNSUPPORTED one-off: The Foretold Soldier - #4579

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-63-unsupported-one-off-the
Jun 29, 2026
Merged

fix(parser): UNSUPPORTED one-off: The Foretold Soldier#4579
matthewevans merged 3 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-63-unsupported-one-off-the

Conversation

@ntindle

@ntindle ntindle commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a parser misparse affecting 1 card(s) in the Doctor Who Commander precons.

Root cause: UNSUPPORTED one-off: The Foretold Soldier

Cards corrected

  • The Foretold Soldier

Fix

Implemented cluster 63 (The Foretold Soldier, WHO) by recognizing "It becomes foretold" (CR 702.143d) as a post-exile continuation that reuses existing primitives — NO new gated engine variant. Found and completed a partial in-tree implementation from an earlier interrupted run.

PARSER: ast.rs adds ContinuationAst::BecomesForetold; sequence.rs adds parse_becomes_foretold_continuation (pure tag/alt/all_consuming, the one trim line carries an allow-noncombinator annotation), a classifier arm, an application arm that emits Effect::GrantCastingPermission { CastingPermission::Foretold { cost: ManaCost::zero (placeholder), turn_foretold: 0 }, target: exile_designation_grant_target(previous), grantee: ObjectOwner }, a consuming-predicate arm, and renames plotted_grant_target -> exile_designation_grant_target (now serves both plotted and foretold designations).

RESOLVER: grant_permission.rs extends the Foretold arm to set obj.face_down, derive the real cost from the object's printed Foretell keyword via casting::foretell_cost (made pub(crate)), and emit GameEvent::BecameForetold; plus a CR 608.2c guarded ParentTarget->source fallback so the anaphoric "it" (empty target slot) binds to the just-exiled source. casting.rs: foretell_cost -> pub(crate) as the single cost authority.

EVENT PLUMBING: new GameEvent::BecameForetold { object_id } in events.rs, wired into every compiler-flagged exhaustive match — trigger_index.rs (no-op key, CR 702.143c so it does NOT fire foretell triggers), trigger_matchers.rs batched-subject counter (=> 0), public_state.rs dirty-marking, and log.rs (Zone category + message). Distinct from GameEvent::Foretold (the CR 702.143a special action) by design.

TESTS (building-block level): parser unit test (Exile it face down. It becomes foretold. -> ChangeZone->Exile sub-chaining GrantCastingPermission{Foretold}, asserts no Unimplemented anywhere in the chain); resolver unit test (empty targets + source_id -> obj.foretold/face_down set, permission carries the printed {1}{G} cost and stamped turn, BecameForetold fired and Foretold NOT fired); CR 702.143c regression in trigger_matchers (match_foretell returns false for BecameForetold, true for the real Foretold special action). All pass; the cast-from-exile later-turn gate is already locked by existing tests on the identical CastingPermission::Foretold.

VERIFICATION: cargo fmt clean; clippy -p engine --all-targets -D warnings exit 0; cargo check --workspace exit 0; full engine lib suite 14083 passed / 0 failed; oracle-gen re-parse of The Foretold Soldier shows 0 Unimplemented / 0 Unknown. Parser combinator gate is RED but 100% pre-existing baseline drift (baseline ff799f8 = PR #904) across files I never touched — diffing the gate before/after my single comment reword proved zero net-new violations. All CR annotations verified against docs/MagicCompRules.txt (702.143a/c/d/e and 608.2c). No commit made.

JUDGEMENT CALLS: (1) Treated the pre-existing partial in-tree changes as my own prior work since this is a dedicated single-task worktree and they matched the approved plan exactly; completed the missing event/visibility/test pieces. (2) Categorized the log line as Zone (grouped with the analogous Foretold event) per the plan, not State where BecomesPlotted lives. (3) Proved the only NEW runtime code path with a direct resolve() state-delta test rather than a heavyweight combat GameScenario, because the cast gate is pre-tested on the same permission and ChangeZone ObjectId preservation is a pre-existing invariant; end-to-end card re-parse supplies the integration confirmation.

Files changed

  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/game/effects/grant_permission.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/types/events.rs
  • crates/engine/src/game/public_state.rs
  • crates/engine/src/game/log.rs
  • crates/engine/src/game/trigger_index.rs
  • crates/engine/src/game/trigger_matchers.rs

CR references

  • CR 702.143a (foretell special action; foretold cards exiled face down) — grep -n "^702.143a" docs/MagicCompRules.txt
  • CR 702.143c (an effect referring to foretelling means the special action; 'becomes foretold' is NOT foretelling, so it must not fire foretell triggers) — grep -n "^702.143c" docs/MagicCompRules.txt
  • CR 702.143d (an effect making a card in exile become foretold; castable for any foretell cost it has after the turn it became foretold ends) — grep -n "^702.143d" docs/MagicCompRules.txt
  • CR 702.143e (face-down foretold cards in exile must be differentiable) — grep -n "^702.143e" docs/MagicCompRules.txt
  • CR 608.2c (read the whole text / apply the rules of English — anaphoric 'it' binds to the source) — grep -n "^608.2c" docs/MagicCompRules.txt

Verification

  • cargo fmt --all — pass
  • check-parser-combinators.sh <upstream merge-base> — pass
  • cargo clippy -p engine --all-targets -- -D warnings — pass
  • cargo test -p engine — pass
  • oracle-gen --filter "the foretold soldier" — pass
    Cards confirmed re-parsed correctly: The Foretold Soldier

🤖 Generated with Claude Code

@ntindle
ntindle requested a review from matthewevans as a code owner June 29, 2026 01:09
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 91 card(s), 82 signature(s) (baseline: main 641daed45864)

8 card(s) · ability/DealDamage · field targeting: optional (up to)

Examples: Bogardan Hellkite, Case of the Market Melee, Dragonlord Atarka (+5 more)

6 card(s) · ability/ChangeZone · field targeting: optional (up to)

Examples: Brago, King Eternal, Herald of the Forgotten, Iname, Life Aspect (+3 more)

3 card(s) · ability/Discard · field targeting: optional (up to)

Examples: Hollow Marauder, Tinybones Joins Up, Your Plans Mean Nothing

3 card(s) · ability/Mill · field targeting: optional (up to)

Examples: Court of Cunning, Memory Vampire, Tinybones Joins Up

3 card(s) · ability/PayCost · field targeting: optional (up to)

Examples: Fire Lord Sozin, Numa, Joraga Chieftain, The Thing

3 card(s) · ability/PutCounter · field targeting: optional (up to)

Examples: Jugan, the Rising Star, Quirion Beastcaller, Verdurous Gearhulk

3 card(s) · ability/PutCounterAll · field targeting: optional (up to)

Examples: Filigree Vector, Liege of the Tangle, Sweet-Gum Recluse

3 card(s) · ability/TargetOnly · field targeting: optional (up to)

Examples: Loaming Shaman, Portal Manipulator, Sparkshaper Visionary

3 card(s) · ability/static_structure · removed: static_structure

Examples: Fblthp, Lost on the Range, Locus of Enlightenment, The Master, Formed Anew

2 card(s) · static/AdditionalLandDrop(255) · added: AdditionalLandDrop(255) (affects=you)

Examples: Fastbond, Naya

2 card(s) · ability/Attach · field targeting: optional (up to)

Examples: Armory Automaton, Super-Soldier Serum

2 card(s) · ability/CastFromZone · removed: CastFromZone (target=any target)

Examples: Fastbond, Naya

2 card(s) · ability/ChangeZone · field target: triggering sourceself

Examples: Jocasta, Automaton Avenger, Tamiyo, Inquisitive Student

2 card(s) · ability/Draw · field targeting: optional (up to)

Examples: Chancellor of the Mulligan, Communal Brewing

2 card(s) · trigger/YouAttack · field valid target: you

Examples: Jocasta, Automaton Avenger, W'Kabi, Shield of the Nation

2 card(s) · trigger/YouAttack · field watches: you control commander

Examples: Jocasta, Automaton Avenger, W'Kabi, Shield of the Nation

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

Examples: Ethereal Valkyrie, The Foretold Soldier

2 card(s) · ability/empty · added: empty

Examples: Demonic Hordes, Goblin Flotilla

2 card(s) · ability/the · removed: the (kind=activated)

Examples: Words of Wilding, Words of Worship

2 card(s) · ability/turn · field targeting: optional (up to)

Examples: Illithid Harvester, Weaver of Lies

1 card(s) · static/AdditionalLandDrop(1) · removed: AdditionalLandDrop(1)

Examples: Two Streams Facility

1 card(s) · static/AdditionalLandDrop(2) · field affects: you

Examples: Azusa, Lost but Seeking

1 card(s) · ability/BecomeCopy · field target: in exile creaturein exile 1+ memory counters creature

Examples: The Animus

1 card(s) · ability/BecomeSaddled · field conditional: target is Mounthas an object target and target is Mount

Examples: Alacrian Armory

1 card(s) · ability/CastFromZone · field target: in exile cardin exile 1+ discovery counters card

Examples: Lara Croft, Tomb Raider

… 57 more signature(s) (57 card-changes) — see parse-diff.json
  • 1 card(s) · ability/ChangeZone · field conditional: target is suspectedhas an object target and target is suspected
  • 1 card(s) · ability/ChangeZone · field from: graveyard
  • 1 card(s) · ability/ChangeZone · field target: in graveyard you control card non-landin graveyard 2+ time counters you control card non-land
  • 1 card(s) · ability/ChangeZone · field target: in graveyard you control creature Assassinin graveyard 1+ memory counters you control creature Assassin
  • 1 card(s) · ability/ChangeZone · field target: legendary in graveyard creaturelegendary in graveyard 1+ memory counters creature
  • 1 card(s) · ability/ChangeZone · field target: triggering sourceparent target
  • 1 card(s) · ability/ChangeZone · field target: you control in exile cardyou control in exile 1+ study counters card
  • 1 card(s) · ability/ChangeZone · removed: ChangeZone (target=opponent, to=exile)
  • 1 card(s) · ability/ChooseFromZone · added: ChooseFromZone (count=1, zone=exile)
  • 1 card(s) · static/Continuous · added: Continuous (affects=self, mods=grant all activated abilities of)
  • 1 card(s) · static/Continuous · field conditional: not (shares a color with the most common color among all permanents)
  • 1 card(s) · ability/CreateDrawReplacement · added: CreateDrawReplacement (kind=activated, replacement_effect=DealDamage)
  • 1 card(s) · ability/CreateDrawReplacement · added: CreateDrawReplacement (kind=activated, replacement_effect=GainLife)
  • 1 card(s) · ability/CreateDrawReplacement · added: CreateDrawReplacement (kind=activated, replacement_effect=Token)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=2, kind=activated, target=any target)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=event amount, target=any target)
  • 1 card(s) · ability/Destroy · field targeting: optional (up to)
  • 1 card(s) · ability/Double · field targeting: optional (up to)
  • 1 card(s) · ability/Draw · field conditional: not (target is dealt damage this turn)has an object target and not (target is dealt damage this turn)
  • 1 card(s) · ability/Draw · field conditional: target is tokenhas an object target and target is token
  • 1 card(s) · ability/Draw · field conditional: target is you control creaturehas an object target and target is you control creature
  • 1 card(s) · ability/Draw · field conditional: target is you control permanenthas an object target and target is you control permanent
  • 1 card(s) · ability/GiveControl · field targeting: optional (up to)
  • 1 card(s) · ability/GrantCastingPermission · added: GrantCastingPermission
  • 1 card(s) · ability/Investigate · field conditional: target is youhas an object target and target is you
  • 1 card(s) · replacement/Moved · added: Moved
  • 1 card(s) · ability/PreventDamage · field targeting: optional (up to)
  • 1 card(s) · ability/PutCounter · field conditional: target is Soldierhas an object target and target is Soldier
  • 1 card(s) · ability/PutCounter · field conditional: target is artifacthas an object target and target is artifact
  • 1 card(s) · ability/PutCounter · field counter: 1 P1P1ObjectCountDistinct { filter: Typed(TypedFilter { type_filters: [Card, Non(Land)], controller: None, properties: [Owned…
  • 1 card(s) · ability/PutCounter · field counter: Sum { exprs: [Ref { qty: ObjectCount { filter: Typed(TypedFilter { type_filters: [Card], controller: None, properties: …Sum { exprs: [Ref { qty: ObjectCount { filter: Typed(TypedFilter { type_filters: [Card], controller: None, properties: …
  • 1 card(s) · ability/RemoveCounter · added: RemoveCounter (counter=2 hour, target=self)
  • 1 card(s) · ability/RemoveCounter · added: RemoveCounter (counter=EventContextAmount P1P1, target=self)
  • 1 card(s) · ability/RemoveCounter · field target: parent targetself
  • 1 card(s) · ability/RemoveCounter · field targeting: optional (up to)
  • 1 card(s) · ability/RollDie · added: RollDie (sides=6)
  • 1 card(s) · ability/RollDie · field targeting: optional (up to)
  • 1 card(s) · ability/Sacrifice · field targeting: optional (up to)
  • 1 card(s) · ability/SwitchPT · field targeting: optional (up to)
  • 1 card(s) · ability/Tap · field target: parent targetself
  • 1 card(s) · ability/Token · field targeting: optional (up to)
  • 1 card(s) · static/TopOfLibraryHasPlot · added: TopOfLibraryHasPlot (affects=any target)
  • 1 card(s) · static/TopOfLibraryPlotPermission · added: TopOfLibraryPlotPermission (affects=non-land)
  • 1 card(s) · ability/add type artifact, add type creature · field conditional: target is Vehiclehas an object target and target is Vehicle
  • 1 card(s) · ability/base power 2, base toughness 2 · field conditional: target is creaturehas an object target and target is creature
  • 1 card(s) · ability/base power 3, base toughness 3, add type artifact, add type creature · field targeting: optional (up to)
  • 1 card(s) · ability/choose · removed: choose
  • 1 card(s) · ability/draw · field targeting: optional (up to)
  • 1 card(s) · ability/effect_structure · removed: effect_structure
  • 1 card(s) · ability/its · added: its
  • 1 card(s) · ability/its · field targeting: optional (up to)
  • 1 card(s) · ability/power +2, toughness +0, grant Vigilance · field conditional: target is whitehas an object target and target is white
  • 1 card(s) · ability/roll · removed: roll
  • 1 card(s) · ability/static_structure · added: static_structure
  • 1 card(s) · ability/target_opponent_does_the_same · added: target_opponent_does_the_same
  • 1 card(s) · ability/unless · removed: unless
  • 1 card(s) · ability/unless · removed: unless (duration=until end of combat)

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

@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] BecomesForetold grants a real zero-cost foretell permission when the card does not already have a printed Foretell keyword. Evidence: crates/engine/src/parser/oracle_effect/sequence.rs emits CastingPermission::Foretold { cost: ManaCost::zero(), ... }; crates/engine/src/game/effects/grant_permission.rs only replaces that placeholder when the object already has a printed Keyword::Foretell; and data/mtgish-cards.json shows Ethereal Valkyrie's effect grants IsForetoldForCost with "its mana cost reduced by {2}", not a printed Foretell keyword. Why it matters: the coverage bot says this PR newly supports Ethereal Valkyrie, but that card's exiled card would become castable from exile for {0} instead of its mana cost reduced by {2}. Please either parse and carry the effect-defined foretell cost into the granted permission with a discriminating Ethereal Valkyrie test, or keep that cost-bearing form unsupported with a strict failure until the cost is represented.

…ll cost form

Ethereal Valkyrie's trigger says "exile a card from your hand face down.
It becomes foretold. Its foretell cost is its mana cost reduced by {2}."
The PR's BecomesForetold continuation parsed "it becomes foretold" and
emitted CastingPermission::Foretold{cost:{0}} as a sub_ability.  Since
the exiled card has no printed Keyword::Foretell, grant_permission.rs
left the zero-cost placeholder untouched — making the card incorrectly
castable from exile for free instead of its mana cost reduced by {2}.

Fix (option B — strict failure as requested by maintainer review):
- Add `is_foretell_cost_override_sentence()` in sequence.rs: nom
  combinator that matches "its foretell cost is <anything>".
- In the parse_effect_chain_ir chunk loop (mod.rs), detect this sentence
  and clear any immediately-preceding absorbed BecomesForetold
  continuation, preventing the wrong {0} permission from being emitted.
- The sentence then falls through to Unimplemented, honestly documenting
  that effect-defined foretell cost expressions are not yet supported.
- The Foretold Soldier's plain "it becomes foretold." (no cost sentence)
  is unaffected: the grant is still emitted and at runtime derives cost
  from the card's own printed Foretell keyword via foretell_cost().
- Add test parse_becomes_foretold_with_cost_override_suppresses_grant
  verifying no GrantCastingPermission::Foretold appears in the chain
  when a cost-override sentence follows.

Verified: cargo clippy -p engine -D warnings (clean),
cargo test -p engine (14 098 passed, 0 failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
@matthewevans matthewevans self-assigned this Jun 29, 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.

Approved. I re-reviewed the current head after the stale changes-requested review. The prior zero-cost foretell blocker is fixed: effect-driven “becomes foretold” now derives the printed Foretell cost at resolution, effect-defined foretell-cost text strict-fails instead of granting a bogus placeholder cost, and BecameForetold stays distinct from the Foretold special-action event. Current head CI is green.

@matthewevans matthewevans added the bug Bug fix label Jun 29, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jun 29, 2026
@matthewevans matthewevans removed their assignment Jun 29, 2026
Merged via the queue into phase-rs:main with commit 88bb3ce Jun 29, 2026
11 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.

2 participants