Fix Orcish Farmer - #6705
Conversation
…cish Farmer)
`oracle_nom/duration.rs` recognized the step-deadline family only as two
hardcoded strings ("your next end step", "the next end step"). Every other
`until [the beginning of] <possessor> next <step>` phrase fell through, so its
clause lost the duration.
Orcish Farmer ("{T}: Target land becomes a Swamp until its controller's next
untap step.") is the silently-wrong case: no `Effect::Unimplemented`, no parse
warning, the card reads as supported, and the duration defaults to
`Permanent` (CR 611.2a), turning an opponent's land into a Swamp forever.
Factor the production into its two real axes — possessor and step — and map
each pair onto the existing `Duration::UntilNextStepOf` variant. No new engine
variant: `Phase::Upkeep`, `Phase::Untap` and `PlayerScope::{Controller,AnyTurn}`
all already exist. `step_deadline_scope` is the single place that decides
whether a pair has a runtime authority whose scoping matches the phrase, and
declines the rest so they stay honestly `Effect::unimplemented`.
Give the one newly-reachable deadline its expiry authority (CR 500.4 — as a
step begins, effects that last until that step expire): both halves, mirroring
the existing end-step pair —
* `layers::prune_until_next_upkeep_effects` for transient continuous effects
* `layers::prune_upkeep_step_casting_permissions` for durational
`PlayFromExile` grants (Elkin Bottle / Grinning Totem lower to this, not to
a continuous effect)
wired at `Phase::Upkeep` after the skip check (CR 614.10a) and before the
upkeep triggers (CR 500.6). The existing untap prune gains the turn-agnostic
arm so no emitted pair is left unenforced.
Measured over all 35,316 MTGJSON card faces: 10 faces change, none regress.
Four lose an `Effect::Unimplemented` (Elkin Bottle, Erhnam Djinn, Grinning
Totem, Xenic Poltergeist); six record a duration that was previously dropped
while keeping an independent gap in the same clause.
Backlog: removes Orcish Farmer from root cause 25 (29 -> 28).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThe parser now supports validated next-step duration phrases and explicitly demotes unbound delayed sweeps. The game engine prunes upkeep- and untap-scoped state at turn boundaries, with unit and integration tests covering expiration behavior. ChangesStep-scoped deadlines
Delayed-sweep parser honesty
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested labels: Sequence Diagram(s)sequenceDiagram
participant OracleParser
participant auto_advance
participant DeadlinePruning
participant GameState
OracleParser->>GameState: create UntilNextStepOf duration
auto_advance->>DeadlinePruning: process active_player upkeep
DeadlinePruning->>GameState: remove expired effects
DeadlinePruning->>GameState: remove expired casting permissions
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.44.1)crates/engine/src/parser/oracle_effect/mod.rsast-grep timed out on this file Comment |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Correct the CR basis for the AnyTurn deadline expiry. Evidence: CR 603.7b governs delayed triggered abilities, not a continuous effect or casting permission. The new upkeep/untap AnyTurn code uses it as the justification for expiring “the next [step]” at the first occurrence (including the new duration.rs and layers.rs annotations/tests). Why it matters: the current citations justify different rules behavior than the continuous-effect/casting-permission durations this PR implements. Suggested fix: please correct or remove the inaccurate citation everywhere introduced by this PR; the expiry belongs under CR 500.4 + CR 611.2a, while any step-specific rule can remain only as descriptive context.
Since this is your first contribution, please use the AI contributor gates when updating the PR.
Parse changes introduced by this PR · 14 card(s), 18 signature(s) (baseline: main
|
…n deadline Review finding (HIGH, @matthewevans): CR 603.7b governs delayed triggered abilities — "a delayed triggered ability will trigger only once, the next time its trigger event occurs". It is not authority for when a continuous effect or a casting permission expires, which is what the turn-agnostic `PlayerScope::AnyTurn` deadline in this PR actually is. Replace every CR 603.7b citation this PR introduced with the correct basis: CR 611.2a (the effect lasts as long as *stated* — "the next <step>" names a step without naming whose it is, so the deadline is the first such step) plus CR 500.4 (as that step begins, effects lasting until it expire). The per-step rules CR 502.3 / CR 503.1 / CR 513.1 stay only as descriptive context naming which step each prune owns. Scope: the 10 sites this PR added. The pre-existing CR 603.7b citations are left alone — in `turns.rs` and `effects/rebound.rs` they annotate genuine delayed triggers and are correct, and `layers.rs:229` predates this branch. Annotations only; no behavior change. Full engine suite green (17864 lib, 4134 integration). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — confirmed, not refuted. CR 603.7b reads "A delayed triggered ability will trigger only once—the next time its trigger event occurs", so it is authority about delayed triggers, not about when a continuous effect or a casting permission expires. That is exactly what the Fixed in
Scoped to what this PR introduced, as you asked. Pre-existing CR 603.7b citations are untouched: the ones in Annotations only — no behavior change, and the parse-diff is unchanged. Per AI-CONTRIBUTOR.md I re-ran the gates against the new head rather than letting the old records stand:
Both PASS lines in the PR body have been updated to this head. Branch is also merged up to current One thing I flagged in the body and want to make sure you see rather than discover in review: this PR takes Grinning Totem to zero |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Preserve Grinning Totem's mandatory delayed cleanup instead of silently promoting it to supported.
Evidence:
- Grinning Totem's Oracle text requires: "At the beginning of your next upkeep, if you haven't played it, put it into its owner's graveyard."
- The current-head parse-diff adds
GrantCastingPermission (duration=until next upkeep (you))for Grinning Totem and removes the prior unsupporteduntilsignature. crates/engine/src/parser/oracle_nom/duration.rs:166-179now accepts the first sentence's duration. At the deadline,crates/engine/src/game/layers.rs:589-640only removes the casting permission; it creates no delayed zone-change instruction for an unplayed card.
Why this matters: the card can now appear fully supported while leaving an unplayed opponent-owned card in exile, rather than putting it into its owner's graveyard at the required upkeep. The prior unsupported marker made this gap visible; this PR removes that protection without implementing the second sentence.
Please either implement the generic delayed "at the beginning of your next upkeep, if [not played], put that card into its owner's graveyard" behavior and add a Grinning Totem pipeline test, or retain an honest unsupported marker until that clause is modeled. Please do not add a card-name special case.
|
Confirmed — and investigating it turned up something that I think changes the right scope for this PR, so I want your call before pushing anything. Your finding is correct, and worse than "the condition is dropped"I built the pipeline test you asked for and drove the real flow (activate → search → exile → advance to my next upkeep): The opponent's card is stranded in exile permanently. I had guessed CR 603.7c would make the sweep a benign no-op; that was wrong, and I should not have shipped the disclosure in place of a test. Two real parser bugs, both fixed locally1. The graveyard-sweep anaphor is not recognized. Fixed by adding a graveyard-sweep branch to that same recall axis. Two structural differences from the hand branch, both load-bearing: it must be scanned at word boundaries rather than start-anchored (the sweep never begins its clause — "At the beginning of your next upkeep, if you haven't played it, put it into …"), and its possessive axis is wider, because a swept card may be owned by another player (Grinning Totem: "its owner's graveyard"; Three Wishes: "your graveyard"). No card-name special case. 2. An intervening clause shadows the exile, so With both, the delayed trigger now registers and fires with exactly the shape the working analog has: The remaining blocker looks like a pre-existing engine bug, and it isn't mineThe card still doesn't move. A fired delayed trigger with that shape never reaches
I separately ruled out the self-sacrifice cost and the opponent-owned card with their own fixtures. The only differing field is the destination: a fired delayed That is in shared trigger-batch machinery, has nothing to do with this PR's duration change, and likely affects other cards. I've stopped there rather than guess at a fix in that path. What I'd like you to decide
This PR is untouched and still green at |
|
Thanks for tracing this through. Please take Option 2. Do not push the parser-only fixes to this PR: they would still leave Grinning Totem falsely supported while its required delayed cleanup remains nonfunctional. Restore a generic honest unsupported marker here instead. Please split the parser anaphor/stamping work and the shared delayed-trigger dispatch investigation into a separate, engine-implementer-scoped PR, with an end-to-end card test that proves the unplayed card reaches its owner's graveyard. Do not add a card-name special case. The existing current-head changes request remains the acceptance gate for this PR. No additional review action is needed until the head changes. |
…emented Review finding (HIGH, @matthewevans): recognizing Grinning Totem's "until … next upkeep" duration took the card to zero Unimplemented, so it read as fully supported while its required delayed cleanup stayed nonfunctional — the unplayed opponent-owned card is stranded in exile forever. Verified by driving the real activate → search → exile → next-upkeep pipeline. Per the review, restore an honest marker here rather than half-implementing the sweep; the parser anaphor/stamping work and the shared delayed-trigger dispatch bug are split out to their own engine-implementer-scoped PR. `demote_unbound_delayed_sweeps` runs as a post-lowering invariant beside the existing `scrub_granting_placeholder_descriptions` degrade net, replacing a `CreateDelayedTrigger` whose inner chain is a graveyard move on an UNBOUND anaphor (target still `ParentTarget`/`Any`) with `Effect::unimplemented("delayed_unplayed_exile_sweep", …)`. A post-lowering pass rather than one grammar arm because several builders emit `CreateDelayedTrigger`, and the honesty requirement is a property of the final tree. Not a card-name special case — it is the whole impulse-cleanup class: Grinning Totem, Bank Job, Glimpse the Impossible, Three Wishes, Elkin Lair. Deliberately narrow, so it cannot demote anything that works: only inside a delayed trigger (the 77-card "search your library … put it into your graveyard" wording never reaches it), and only an UNBOUND target — Valakut Exploration's `ExiledBySource` sweep and Necropotence's tracked-set hand recall are both untouched, each pinned by a reach-guarded negative test. Measured over all 35,316 MTGJSON faces, vs the previous head: 5 faces change. Grinning Totem, Bank Job and Glimpse the Impossible gain the honest marker (Grinning Totem is now 1 -> 1 against main, so it never flips to supported); Three Wishes and Elkin Lair were already unsupported and only gain the key. Bank Job and Glimpse the Impossible were falsely supported on main for the same reason and now correctly report the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Taken Option 2. Pushed as
It runs as a post-lowering invariant beside the existing Not a card-name special case. The guard is the shape, and the shape is the whole impulse-cleanup class:
Bank Job and Glimpse the Impossible were falsely supported on Those two flips land in Narrow by construction, so it can't demote anything that works — only inside a delayed trigger (the 77 cards sharing the raw "search your library … put it into your graveyard" wording never reach it) and only an unbound target. Valakut Exploration's As directed, the parser anaphor/stamping fixes and the shared delayed-trigger dispatch investigation are not in this PR. I'll open them separately, engine-implementer-scoped, with the end-to-end test proving the unplayed card reaches its owner's graveyard. For that PR's benefit, the dispatch bug isolates to the destination: Gates re-run against the new head:
Both PASS lines in the body are updated to this head, and the body's Claimed parse impact and Scope Expansion sections now carry the numbers above. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
crates/engine/src/parser/oracle_effect/mod.rs (2)
13585-13600: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConsider directed subtraction instead of
QuantityExpr::Differencefor the max-minus-current-lands count.This file documents elsewhere (
balance_clause_effect, and explicitly in the siblingparse_threshold_land_balance_ira few hundred lines above) thatQuantityExpr::Differenceresolves via.abs(), and that safe usage requires the caller to guaranteeleft >= right. The sibling function computing the structurally identical "max lands minus my lands" quantity deliberately avoidsDifferencefor exactly this reason, buildingSum[max, Multiply(-1, my_count)]and wrapping it inup_to(...)instead — so a would-be-negative value clamps to 0 rather than getting silently flipped positive by.abs().Here,
left >= rightalmost certainly holds in practice (the max only rises across the resolution, and no iterating player can exceed it via this ability's own effect), so this is unlikely to manifest as a live bug — but it's inconsistent with the file's own documented defensive precedent for the identical shape, and the fix is a direct copy of the neighboring pattern.♻️ Proposed fix mirroring `parse_threshold_land_balance_ir`
- let difference = QuantityExpr::Difference { - left: Box::new(maximum_lands.clone()), - right: Box::new(QuantityExpr::Ref { - qty: QuantityRef::ObjectCount { - filter: scoped_lands, - }, - }), - }; + let difference = QuantityExpr::Sum { + exprs: vec![ + maximum_lands.clone(), + QuantityExpr::Multiply { + factor: -1, + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: scoped_lands, + }, + }), + }, + ], + };🤖 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_effect/mod.rs` around lines 13585 - 13600, Replace the QuantityExpr::Difference used for the max-minus-current-lands calculation with the directed subtraction pattern from parse_threshold_land_balance_ir: build a Sum of maximum_lands and Multiply(-1, the scoped player land count), then wrap it with up_to(...) so negative results clamp to zero. Keep the existing maximum_lands and scoped_lands semantics unchanged.
13544-13557: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDead branch:
AggregateFunction::Min("fewest") is parsed then unconditionally rejected.The
altaccepts either"most"or"fewest", but the very next statement rejects any result other thanMaxvia aVerifyerror. Since "fewest" can never survive the check, this could simply betag("most")directly. If the intent was to mirror the "parse a general extremum, then verify only Max is coherent" idiom used elsewhere in this file (parse_balance_arm_b,parse_catch_up_draw_ir), consider routing through the same shared extremum combinator those use instead of a fresh inlinealtover literal words, per the "reuse existing shared building blocks before adding inline extraction logic" guidance.As per path instructions for
crates/engine/**/*.rs: "Reuse existing shared building blocks before adding utility or inline extraction logic; test the building block and its parameter range rather than a single card case."🤖 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_effect/mod.rs` around lines 13544 - 13557, Remove the dead inline extremum branch in this parser: either parse only the `"most"` literal when only Max is valid, or reuse the shared extremum combinator used by `parse_balance_arm_b` and `parse_catch_up_draw_ir`, then retain validation that only `AggregateFunction::Max` is accepted. Do not continue parsing `"fewest"` through an unconditional rejection path.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/parser/oracle_effect/mod.rs`:
- Around line 1693-1753: Update the documentation comment above
delayed_sweep_is_unbound_anaphor to replace the incorrect CR 611.2a citation
with the applicable Comprehensive Rules references covering delayed triggered
abilities and identifying objects across zones. Leave the function logic and the
existing CR 400.7 citation unchanged.
In `@crates/engine/src/parser/oracle.rs`:
- Around line 6728-6731: Update the demotion logic around
delayed_sweep_is_unbound_anaphor so TargetFilter::Any alone does not establish
an unbound anaphor; inspect or propagate typed binding provenance and demote
only cleanup chains lacking an earlier intended exile/permission binding.
Preserve valid unrestricted delayed graveyard effects, and add a regression case
covering a non-anaphoric Any target.
- Around line 6696-6710: Update the delayed-trigger citation for
demote_unbound_delayed_sweeps from CR 611.2a to CR 603.7, retaining CR 400.7
only if needed for the object-identity claim. Apply this documentation change in
crates/engine/src/parser/oracle.rs at lines 6696-6710 and
crates/engine/src/parser/oracle_tests.rs at lines 22946-22956; both sites
require the citation update.
---
Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 13585-13600: Replace the QuantityExpr::Difference used for the
max-minus-current-lands calculation with the directed subtraction pattern from
parse_threshold_land_balance_ir: build a Sum of maximum_lands and Multiply(-1,
the scoped player land count), then wrap it with up_to(...) so negative results
clamp to zero. Keep the existing maximum_lands and scoped_lands semantics
unchanged.
- Around line 13544-13557: Remove the dead inline extremum branch in this
parser: either parse only the `"most"` literal when only Max is valid, or reuse
the shared extremum combinator used by `parse_balance_arm_b` and
`parse_catch_up_draw_ir`, then retain validation that only
`AggregateFunction::Max` is accepted. Do not continue parsing `"fewest"` through
an unconditional rejection path.
🪄 Autofix (Beta)
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: 169e6115-7416-4ff5-af64-8c6967e96f4e
📒 Files selected for processing (5)
crates/engine/src/game/turns.rscrates/engine/src/parser/oracle.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/parser/oracle_tests.rscrates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- crates/engine/tests/integration/main.rs
- crates/engine/src/game/turns.rs
| let demote = match &*def.effect { | ||
| Effect::CreateDelayedTrigger { effect, .. } => { | ||
| crate::parser::oracle_effect::delayed_sweep_is_unbound_anaphor(effect) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Do not treat TargetFilter::Any as proof of an unbound anaphor.
The invoked predicate demotes every delayed graveyard move with ParentTarget or Any, without proving an earlier exile/permission binding was intended. Valid unrestricted delayed graveyard effects can therefore become Unimplemented. Carry or inspect typed binding provenance and demote only genuinely unbound cleanup chains; add a non-anaphoric Any regression case.
🤖 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.rs` around lines 6728 - 6731, Update the
demotion logic around delayed_sweep_is_unbound_anaphor so TargetFilter::Any
alone does not establish an unbound anaphor; inspect or propagate typed binding
provenance and demote only cleanup chains lacking an earlier intended
exile/permission binding. Preserve valid unrestricted delayed graveyard effects,
and add a regression case covering a non-anaphoric Any target.
Source: Coding guidelines
matthewevans
left a comment
There was a problem hiding this comment.
Current-head maintainer review complete. The only follow-up corrects the delayed-trigger/object-identity annotations to CR 603.7a, CR 603.7c, and CR 400.7; no runtime behavior changed. The unsupported delayed-sweep handling remains intentionally honest.
Summary
Fixes the dropped-effect-duration misparse (
docs/parser-misparse-backlog.mdroot cause 25) for Orcish Farmer, whose{T}: Target land becomes a Swamp until its controller's next untap step.silently lost its duration and defaulted toDuration::Permanent(CR 611.2a) — noEffect::Unimplemented, no parse warning, the card read as fully supported while turning an opponent's land into a Swamp forever.The root cause is that
oracle_nom/duration.rs— the documented single authority for the phrase→Durationgrammar — recognized the step-deadline family only as two hardcoded strings ("your next end step","the next end step"). This factors it into one production over its two real axes (possessor × step), which also picks up theuntil [the beginning of] your next upkeepphrasing (9 cards print it). Every emitted pair reuses the existingDuration::UntilNextStepOfvariant with existingPhase/PlayerScopevalues — no new engine variant, field, or serialized shape — and the one deadline that had no runtime expiry authority (Phase::Upkeep) gets one, mirroring the existing end-step pair.Recognizing that duration also took Grinning Totem to zero
Unimplemented, which would have read as fully supported while its required delayed cleanup stayed nonfunctional. Per review round 2 this PR keeps that clause honestly unimplemented instead:demote_unbound_delayed_sweepsis a post-lowering invariant that marks any delayed graveyard sweep whose swept objects were never bound to a concrete set. It covers the whole impulse-cleanup class — Grinning Totem, Bank Job, Glimpse the Impossible, Three Wishes, Elkin Lair — not one card.Backlog list hygiene:
Orcish Farmerremoved from root cause 25 (29 → 28 cards; file totals 4733 → 4732 distinct, 4767 → 4766 appearances). It is the only backlog card this change fixes — Cycle of Life and Gabriel Angelfire also gain the duration but stay listed, because the misparses they are listed for (root cause 1's target restriction, root cause 12's modal split) are untouched.Files changed
crates/engine/src/parser/oracle_nom/duration.rscrates/engine/src/parser/oracle.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/parser/oracle_tests.rscrates/engine/src/game/layers.rscrates/engine/src/game/turns.rscrates/engine/tests/integration/until_next_step_deadline_durations.rscrates/engine/tests/integration/main.rsdocs/parser-misparse-backlog.mdTrack
Non-developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: not-applicable —
/engine-implementercould not be run as specified. Its pipeline requires isolated planner/reviewer subagents, and subagent spawning was not authorized in this session; the skill is explicit that silently degrading to same-context self-review is the failure mode it exists to prevent. Every step of its checklist was executed inline instead — plan, CR verification againstdocs/MagicCompRules.txt, implementation, verification, and a/review-implchecklist pass against the committed diff — and the missing context isolation is recorded under Validation Failures rather than papered over.CR references
All base numbers grep-verified against
docs/MagicCompRules.txt(CR of 2026-06-19).CR 500.4— the authorizing rule, newly cited: "As a step or phase begins, if there are effects that last until that step or phase, those effects expire." This is what everyUntilNextStepOfprune implements; the pre-existing code cited only the per-step rules, which say when a step happens, not that durations end there.CR 502.1/CR 502.3— the untap step and its untap turn-based action (Orcish Farmer's deadline).CR 503.1— the upkeep step (the new deadline and both of its prunes).CR 513.1— the end step (existing deadline, re-cited at the refactored seam).CR 500.6— "at the beginning of" abilities trigger as the step begins; justifies pruning before the upkeep triggers.CR 614.10a— "Anything scheduled for the 'next' occurrence of something waits for the first occurrence that isn't skipped"; justifies placing the prune after the skip-step check (an Eon-Hub-skipped upkeep must not expire the effect).CR 611.2a— a continuous effect lasts as long as stated, and with no stated duration lasts until end of game. Two roles here: it is the default that produced the Orcish Farmer bug, and it is the basis for the turn-agnostic reading — "the next <step>" states a step without naming whose it is, so the deadline is the first such step (PlayerScope::AnyTurn).CR 109.4/CR 109.5— object controller vs. "you/your": the two possessor readings the grammar must keep apart.CR 400.7— "An object that moves from one zone to another becomes a new object with no memory of, or relation to, its previous existence": why a sweep's zone change must name the actual object, and why an unboundParentTargetcannot.CR 305.7,CR 613.1d,CR 502.4,CR 704.5b— test assertions and test-scaffolding rationale.Verification
Track is declared Non-developer per the entry prompt, but the pinned toolchain (
nightly-2026-04-19) turned out to be installable without elevated privileges, so the mechanical checks were run locally anyway. What could not be run is the card-data tier:./scripts/setup.sh --agentrequiresjqandpnpm, neither installable here, socargo coverage/cargo semantic-audit/cargo parser-gapsare CI-owned. A direct whole-corpus parse diff is supplied in their place.cargo fmt --all --check— clean.cargo clippy-strict(clippy --all-targets -- -D warnings, whole workspace) — clean, 0 warnings.cargo test -p engine—ok. 17883 passed; 0 failed; 6 ignored(lib) ·ok. 4140 passed; 0 failed; 2 ignored(integration) ·ok. 8 passedandok. 9 passed(bin unit tests). No pre-existing test changed behavior../scripts/check-parser-combinators.sh— see Gate A below.cargo coverage— CI-owned;client/public/card-data.jsoncannot be generated here (jq/pnpmunavailable). Substituted by the whole-corpus parse diff under Claimed parse impact, measured over all 35,316 MTGJSON card faces.cargo semantic-audit— CI-owned, same reason. Orcish Farmer's post-fix AST was inspected directly: oneActivatedability,SetBasicLandType { Swamp },duration: UntilNextStepOf { step: Untap, player: Controller }, zeroEffect::Unimplemented, emptyparse_warnings.duration.rsstep axes → the Orcish Farmer test and both upkeep tests fail.turns.rsprune_until_next_upkeep_effectscall → the upkeep continuous-effect test fails on its final assertion; the other two pass.turns.rsprune_upkeep_step_casting_permissionscall → the Elkin Bottle test fails on its final assertion; the other two pass.Gate A
Gate A PASS head=abcf88deb7225cb9738df0e90ee66eeafe68da1d base=9da919f61039068cfe50fb219207f36e81dd57e5
Anchored on
crates/engine/src/parser/oracle_nom/duration.rs:290—parse_until_end_of_next_turn: the existing deadline production composingtag()+ a possessive-pronoun sub-combinator +tag()into aDuration.parse_until_next_stepmirrors that shape one axis wider, and now shares the very same pronoun combinator.crates/engine/src/parser/oracle_nom/duration.rs:126—parse_current_phase_duration: the existing prefix-dispatch nesting (preceded(tag("this "), alt((..)))) that factors a shared head once and dispatches on the remainder — the patternopt(tag("the beginning of "))→ possessor →tag(" next ")→ step follows.crates/engine/src/game/layers.rs:218—prune_until_next_end_step_effects: the existingUntilNextStepOfstep-boundary prune for transient continuous effects, including its two-scope (Controller+AnyTurn)retainshape.prune_until_next_upkeep_effects(:261) is that function one step axis over.crates/engine/src/game/layers.rs:537—prune_end_step_casting_permissions: the existing casting-permission half of a step deadline, including itsexiled_by_ability_controller-before-granted_tokeying.prune_upkeep_step_casting_permissions(:589) is that function one step axis over.crates/engine/src/game/layers.rs:180—prune_controller_end_combat_step_effects: the existing affected-object-scoped member of the same prune family (it readsTargetFilter::SpecificObject), which is the conventionPlayerScope::Controllercarries for the untap deadline.crates/engine/src/game/turns.rs:2648-2654— thePhase::Endarm'sprune_end_step_casting_permissions+prune_until_next_end_step_effectspair; the newPhase::Upkeepcalls are placed the same way relative to that step's triggers.Final review-impl
Final review-impl PASS head=abcf88deb7225cb9738df0e90ee66eeafe68da1d
Lenses applied against
git diff upstream/main...HEAD, with the mechanical ones run as commands: correct seam · most-idiomatic-at-seam · class-vs-single-case · sibling coverage · test adequacy · vacuous-negative reach-guards · new-field threading (none added) · claim-to-test map · fixture path-divergence · coverage honesty · selected authority/provenance · snapshot/latch · serialized-surface contracts · target/scope matrix · nom-mandate (grepover added lines for.contains(",.starts_with(",.ends_with(",.find(",.rfind,.split,.splitn,.strip_prefix,.strip_suffix, string-literal match arms, and hand-builtEffect::Unimplemented { .. }— zero hits) · CR-annotation diff gate (all 15 base numbers resolve indocs/MagicCompRules.txt) · new-bool scan (zero).Two findings were raised and fixed before this commit; both are in the diff:
PlayerScope::Controlleris resolved against different players by the three prunes — the end-step and upkeep prunes gate on the effect'scontroller, the untap prune on the affected object's controller. Unconstrained composition therefore accepteduntil your next untap stepanduntil its controller's next end step, each of which would expire on the wrong player's step. Fixed bystep_deadline_scope, which declines any pair without a matching authority so the clause stays honestlyEffect::unimplemented; pinned bytest_parse_duration_rejects_mismatched_possessor_step_pairings.CastingPermission::PlayFromExile { duration }, not to a transient continuous effect, soprune_until_next_upkeep_effectsalone left the play permission permanently active — strictly worse than theUnimplementedit replaced. Fixed byprune_upkeep_step_casting_permissions, mirroringprune_end_step_casting_permissions; pinned by alayers.rsunit test and byelkin_bottle_play_permission_expires_at_the_controllers_next_upkeep.A third finding was raised, confirmed pre-existing, and disclosed rather than fixed: promoting Grinning Totem to zero
Unimplementedmakes an already-dropped intervening-if ("if you haven't played it") invisible to coverage. It belongs to root cause 2, which is in flight as #6089. See Claimed parse impact for the full statement.Review round 2 — @matthewevans (CHANGES_REQUESTED)
[HIGH] Preserve Grinning Totem's mandatory delayed cleanup instead of silently promoting it to supported. Confirmed by driving the real pipeline (activate → search → exile → advance to my next upkeep): the unplayed opponent-owned card stays in exile and never reaches its owner's graveyard. My earlier reasoning that CR 603.7c would make the sweep a benign no-op was wrong.
Taking Option 2 as directed.
demote_unbound_delayed_sweeps(parser/oracle.rs) keeps the clause honestly unimplemented. It runs as a post-lowering invariant beside the existingscrub_granting_placeholder_descriptionsdegrade net, because several builders emitCreateDelayedTriggerand the honesty requirement is a property of the final tree, not of one grammar arm.Not a card-name special case — the guard is the shape "delayed graveyard move whose swept objects are an unbound anaphor", which is the whole impulse-cleanup class: Grinning Totem, Bank Job, Glimpse the Impossible, Three Wishes, Elkin Lair. Two of those (Bank Job, Glimpse the Impossible) were falsely supported on
mainfor the same reason and now correctly report the gap; see Claimed parse impact for the measured effect and why it lands in the non-fatal coverage-honesty bucket.Deliberately narrow, with both negatives reach-guarded: only inside a delayed trigger (the 77-card "search your library … put it into your graveyard" wording never reaches it) and only an unbound target, so Valakut Exploration's
ExiledBySourcesweep and Necropotence's tracked-set hand recall are untouched.The parser anaphor/stamping work and the shared delayed-trigger dispatch investigation are split out as you asked, not pushed here.
One inherited nuance, deliberately not changed:
prune_controller_untap_step_effectsruns after the untap turn-based action rather than as the step begins (CR 500.4), because that ordering is load-bearing for theCantUntapclass it was written for ("so the permanent skips exactly one untap"). For a basic-land-type change the difference is unobservable — no player receives priority during the untap step (CR 502.4) — so the shared authority is reused as-is rather than re-ordered under an unrelated card's PR.Claimed parse impact
Measured, not estimated: the parser was run over all 35,316 card faces in MTGJSON
AtomicCards(2026-07-27) on the base commit and on this branch's head, fingerprinting each face's complete parsed AST and countingEffect::Unimplemented. Exactly 14 faces differ.Gained support (3) — the
until … next upkeepclause now parses and nothing else in their text is deferred:Lost support (2) — intentional, and the point of review round 2. Both were falsely supported on
mainfor the same reason Grinning Totem was: their delayed sweep silently strands the swept card. The honesty net makes the gap visible:These are
parser_regress("coverage honesty"), notengine_regress, underscripts/coverage-regression-check.sh: the new gap keydelayed_unplayed_exile_sweepis distinct from every handler those cards hadsupported: truefor on the baseline, so no previously-supported handler is lost. That is the script's documented non-fatal bucket — "explicitEffect:*gaps for Oracle text the baseline parse tree had already swallowed into a broader supported node".Duration recorded, support status unchanged (9) — the duration is now correct where it was previously dropped, but an independent second gap in the same clause remains, so none of these is claimed as newly supported:
Permanent→UntilNextStepOf { Untap, Controller }Every card using the three phrasings that already worked (
until your next end step,until the next end step,until [the end of] your/their next turn) is byte-identical across the diff, as are the 35,302 other faces.Scope Expansion
The
layers.rs+turns.rsruntime hunk goes beyond the parser seam the backlog's fix hint names, and it is required rather than incidental:Duration::UntilNextStepOf { step: Phase::Upkeep, .. }had no expiry authority on either the continuous-effect or the casting-permission side. Recognizing the phrase without adding one would have converted four honestEffect::Unimplementedgaps into four permanently-active effects — a silently-wrong outcome strictly worse than the gap, and exactly the class of harm root cause 25 catalogues. The hunk is two new prunes (each a mirror of its existing end-step counterpart), two call sites, and oneAnyTurnarm added to the existing untap prune. No new type, variant, field, trait, or serialized shape; no exhaustive match anywhere in the workspace needed a new arm.Review round 2 added a second, maintainer-directed piece of scope: the coverage-honesty net in
parser/oracle.rs+ the shared detector inoracle_effect/mod.rs. It adds no type, variant or field — only anEffect::unimplementedmarker on a shape that provably cannot work. Per that review, the parser anaphor/stamping fixes and the shared delayed-trigger dispatch bug are not in this PR and will land as a separate engine-implementer-scoped PR with an end-to-end test proving the unplayed card reaches its owner's graveyard.Explicitly not taken here:
until that player's next turn / next end step(1 card each) anduntil its controller's next turnwould need a target-relativePlayerScopewith no current runtime authority.parse_until_next_stepdeclines every such pairing, so those clauses stay honestly unimplemented rather than shipping a wrong deadline.Validation Failures
The
/review-implpass was not context-isolated. This session could not spawn an independent reviewer agent, so the reviewer was the same context that authored the change.AI-CONTRIBUTOR.md§5 check 3 permits this only with the limitation stated in the PR body, andengine-implementerrequires saying so rather than claiming the loop ran clean — hence this section. The pass was a real checklist run against the committed unified diff and did surface the two findings recorded above, both fixed with code before the final commit. Treat the review as author-run and weight maintainer review accordingly.CI Failures
None. All checks passed on the previous head (
afcc611): Rust lint/clippy/parser gate, both test shards, card data (generate, validate, coverage), WASM, Tauri, frontend. The CI parse-diff artifact independently reported the same 10 cards claimed above.Review round 1 — @matthewevans (CHANGES_REQUESTED)
[HIGH] Correct the CR basis for the AnyTurn deadline expiry. Confirmed, not refuted: CR 603.7b reads "A delayed triggered ability will trigger only once—the next time its trigger event occurs", which is authority about delayed triggers, not about when a continuous effect or casting permission expires. Fixed in
367a98b: all 10 citations this PR introduced now read CR 611.2a + CR 500.4 — CR 611.2a fixes what the duration is (the effect lasts as long as stated; "the next <step>" names a step without naming whose it is, hence the first such step), CR 500.4 fixes when it ends (as that step begins). CR 502.3 / CR 503.1 / CR 513.1 remain only as descriptive context naming which step each prune owns, as suggested.Scope note: the fix is limited to the sites this PR introduced. Pre-existing CR 603.7b citations are untouched — those in
turns.rsandeffects/rebound.rsannotate genuine delayed triggers and are correct, andlayers.rs:229predates this branch.Annotations only; no behavior change. Gates re-run against the new head per the AI-contributor flow: Gate A above, full engine suite green (17864 lib / 4134 integration),
cargo fmt --checkandcargo clippy-strictclean, and the newcheck-prelowered-ratchet.shgate passes.Summary by CodeRabbit