refactor(parser): Plan 05b T8-A1 — the first two producer conversions (U0-22, U0-26) - #6715
Conversation
… (U0-22, U0-26)
The first consumers of A0's widened `AbilityShellIr`. Two recipe-R1 sites, both
with **no stages**, converted from "lower, then stamp the lowered def" to
"stamp the shell, then lower".
* **U0-22** — CR 719.3c Case `"Solved — {cost}: {effect}"`. Composes its
restriction vec implicit-first (`IsSolved`, then the parsed constraints),
reproducing the site's original push-then-extend order.
* **U0-26** — CR 702.193a-b Power-up. Composes its vec parsed-first (constraints,
then `OnlyOnce`) — the **reverse** of U0-22. A0 deliberately carries ONE
ordered `Vec` composed by the site rather than separate implicit/parsed fields
precisely so this asymmetry survives conversion instead of being normalized.
It also stamps `cost_reduction` explicitly from the keyword definition, so
`shell.stages` stays EMPTY: `ShellStage::ExtractCostReduction` would overwrite
that value AND strip a node out of the `sub_ability` chain.
Both sites called `parse_effect_chain_with_context`, so both take the
`parse_ability_ir_with_context` wrapper. The mode is inherited by NAME, not by
judgment — crossing the wrappers silently moves ~113 die-roll cards (§7.1).
U0-22's `ParseContext` reset stays at the call site: it is a parser side effect
with parse-time ordering, not part of the CR 602.1 activation envelope.
CR-faithfulness: **by construction**, for the whole class of text each
recognizer handles — no property of any card's text participates.
`parse_effect_chain_with_context(t,k,cx)` IS
`lower_ability_ir(&parse_ability_ir_with_context(t,k,cx))`
(`oracle_effect/mod.rs`, `parse_effect_chain_with_context`), and phase-A
`ability_ir_at(l, ir)` IS `ability_at(l, lower_ability_ir(&ir))` (`oracle.rs`).
The old site computed `stamp(lower_ability_ir(ir))`; the converted site computes
`lower_ability_ir(ir_with_shell)`, and the shell reproduces `stamp` in the same
order. `activation_restrictions` is applied with `extend`, so lowered-then-site
ordering is preserved exactly as `push`/`extend` did.
CR numbers grep-verified against docs/MagicCompRules.txt: 702.193a (:5423,
"Activate this ability only once"), 702.193b (:5425, generic-mana reduction),
719.3c (:6086), 602.1a (:2516), 602.1b (:2519), 602.5d (:2549), 602.2b (:2531),
601.2f (:2468).
`ability_ir_at`'s `#[allow(dead_code)]` is DELETED, not moved — A0 said this
tranche retires it, and it now has a real producer.
Gates: `Gate P PASS` (oracle.rs unmoved at 16 — the marker text lives in
consumer arms and emitter bodies, not producer call sites), skill-doc `✓`,
Gate G + Gate A PASS, `cargo clippy -p engine --lib -- -D warnings` clean,
`cargo nextest run -p engine --lib` 17844 passed / 6 skipped, and **zero
snapshot churn** (no `*.snap.new`; `git status` shows only `oracle.rs`).
… remediation)
The §5.3 non-vacuity probe on T8-A1 measured a real gap, so this lands the
missing guard rather than reporting a clean run.
**What the probe measured.** With a temporary `panic!` at the CR 719.3c
`"Solved — {cost}: {effect}"` recognizer, **zero of the 17844 `--lib` tests
fired.** Only two tests in the whole repo reach it (`case_solve_condition`), and
their fixture is `"Solved — {T}: Add {R}."` — no trailing activation
instruction, so `strip_activated_constraints` yields an **empty**
`constraints.restrictions` and the fixture is degenerate on exactly the axis
T8-A1 touches. Consequences, both measured, not argued:
* deleting the implicit `ActivationRestriction::IsSolved` entirely stayed
**green** across every reaching test;
* so did swapping the restriction order.
The sibling `case_of_the_crimson_pulse` snapshot is a near-miss: its Solved
clause is a *triggered* ability, so it never passes `find_activated_colon`. It
passes unchanged under both perturbations, which is why it could not stand in.
**The fixture.** Case of the Stashed Skeleton, verbatim Oracle text from
MTGJSON `AtomicCards`. It is chosen for being NON-degenerate: the trailing
"Activate only as a sorcery." makes `constraints.restrictions` non-empty, so the
snapshot pins both halves of the vector **and their order** — implicit
`IsSolved` first (CR 719.3c, MagicCompRules.txt:6086), parsed `AsSorcery` second
(CR 602.5d, :2549). That order is the one property T8's shell conversion could
silently normalize, because the Power-up recognizer composes the same vector in
the opposite order and a "tidying" refactor would unify them.
**Watched red, not assumed.** Under a pure order swap the new snapshot fails
with `"IsSolved"` relocated after `"AsSorcery"`; under the drop it fails with
`"IsSolved"` absent. A gate never watched go red is not a gate.
This also closes the `--lib` reachability hole: the recognizer now has coverage
in the default lib run, not only in the integration binary.
Additive only — two NEW `*.snap` files, **no existing snapshot changed**, so the
tranche's zero-churn property is preserved.
4 real cards reach this recognizer today (Case of the Burning Masks, Filched
Falcon, Stashed Skeleton, Uneaten Feast).
📝 WalkthroughWalkthroughOracle activated-ability handlers now build and emit ChangesOracle ability IR routing
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant SolvedHandler
participant PowerUpHandler
participant AbilityIRParser
participant DocEmitter
participant ParsedAbilities
SolvedHandler->>AbilityIRParser: Parse effect into AbilityIr
PowerUpHandler->>AbilityIRParser: Parse effect into AbilityIr
AbilityIRParser-->>SolvedHandler: Return IR
AbilityIRParser-->>PowerUpHandler: Return IR
SolvedHandler->>DocEmitter: Emit shell fields
PowerUpHandler->>DocEmitter: Emit shell fields and cost reduction
DocEmitter->>ParsedAbilities: Lower and emit ability
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.rs`:
- Around line 4636-4651: Replace the Power-up assignment to
ir.shell.cost_reduction with the shared mana-cost modification model so the
source permanent’s full mana cost, including colored and colorless symbols, is
reduced and resolved centrally. Preserve the SourceEnteredThisTurn condition and
avoid routing through ShellStage::ExtractCostReduction if that would alter the
sub_ability chain. Update the annotation to remove CR 302.6, retaining only
citations whose rule text defines the cost reduction.
🪄 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: 1fc9eb8b-8969-4b7f-a33b-2f270a518cf1
⛔ Files ignored due to path filters (2)
crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snapis excluded by!**/*.snap,!**/snapshots/**crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_lowered.snapis excluded by!**/*.snap,!**/snapshots/**
📒 Files selected for processing (2)
crates/engine/src/parser/oracle.rscrates/engine/src/parser/oracle_ir/snapshot_tests.rs
| // CR 702.193b + CR 602.2b + CR 601.2f + CR 302.6: the activation cost's | ||
| // generic mana is reduced by the source's mana value if it entered this turn. | ||
| def.cost_reduction = Some(CostReduction { | ||
| // | ||
| // Stamped explicitly from the keyword definition, which is why | ||
| // `shell.stages` stays EMPTY here: this is the one site in the | ||
| // family that does not derive the reduction from the chain, and | ||
| // `ShellStage::ExtractCostReduction` would both overwrite this | ||
| // value and strip a node out of the `sub_ability` chain. | ||
| ir.shell.cost_reduction = Some(CostReduction { | ||
| mode: crate::types::statics::CostModifyMode::Reduce, | ||
| amount_per: 1, | ||
| count: QuantityExpr::Ref { | ||
| qty: QuantityRef::SelfManaValue, | ||
| }, | ||
| condition: Some(ParsedCondition::SourceEnteredThisTurn), | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Model Power-up reduction as a mana-cost reduction, not a mana-value generic reduction.
CostReduction is generic-mana-only, but Power-up reduces the activation cost by the permanent’s mana cost. A {1}{R} permanent must reduce {1} generic and {R}, whereas SelfManaValue records a generic reduction of 2; colored/colorless components and excess handling are therefore wrong. Reuse or extend the shared cost-modification model so it preserves mana symbols and is resolved centrally. Also remove CR 302.6 from this annotation— that rule is about summoning sickness, not Power-up cost reduction. (media.wizards.com)
As per path instructions, engine rules implementations must faithfully encode the Comprehensive Rules and use citations whose rule bodies describe the code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/engine/src/parser/oracle.rs` around lines 4636 - 4651, Replace the
Power-up assignment to ir.shell.cost_reduction with the shared mana-cost
modification model so the source permanent’s full mana cost, including colored
and colorless symbols, is reduced and resolved centrally. Preserve the
SourceEnteredThisTurn condition and avoid routing through
ShellStage::ExtractCostReduction if that would alter the sub_ability chain.
Update the annotation to remove CR 302.6, retaining only citations whose rule
text defines the cost reduction.
Source: Path instructions
Parse changes introduced by this PR✓ No card-parse changes detected. |
Plan 05b tranche T8-A1 — the first two producer conversions through A0's ability shell.
Follows #6714 (A0), which built the mechanism this consumes.
Two sites, both recipe R1, both with no stages: U0-22 (CR 719.3c, activated
Solved) andU0-26 (CR 702.193a-b, Power-up).
Two commits, deliberately separable: the conversion touches only
oracle.rs(+47/−26); the §5.3remediation guard is purely additive.
The conversion
The identity is already in the tree, so this is a rewrite rather than a redesign:
Both sites' original entry point was
parse_effect_chain_with_context(&effect_text, AbilityKind::Activated, &mut ctx), so both takeparse_ability_ir_with_context— chosen by nameper §7.1, not by re-deriving the mode at the call site. That is the entire point of A0's mode-pinned
wrappers.
ability_ir_at's#[allow(dead_code)]is deleted, not moved — A0 assigned it to whichevertranche added the first producer, and this is that tranche.
ShellStage's own allow stays; A2 owns it.Restriction order is preserved, and the two sites disagree on purpose
These are exact reverses of one another, and both reproduce their original
push/extendsequence.This is why A0 carries one ordered
Veccomposed by the site rather than separate"implicit"/"parsed" fields — a split shell would silently normalize the asymmetry away. Each site now
carries a comment pointing at the other, so a future tidying refactor has to read the asymmetry before
unifying it.
U0-22's
ParseContextreset stays at the call site, before the parse, untouched: it is a parser sideeffect with different timing, not part of the CR 602.1 envelope.
U0-26 stamps
cost_reductionexplicitly from the keyword definition and leavesstagesempty.Listing
ShellStage::ExtractCostReductionthere would both overwrite the keyword-defined value andstrip a node out of the
sub_abilitychain. Commented in place.§5.3 non-vacuity: two null results reported as findings, one of them remediated
A reachability probe ran first, because a green perturbation is ambiguous between "identical" and
"never executed".
Reachability (temporary
panic!at both sites): U0-22 fires in zero of 17,844--libtestsbut is reached by 2 integration tests; U0-26 by 1
--liband 10 integration tests. The--libzero initially read as "wholly untested" and that reading was falsified by the integration run —
recorded here because the narrower true statement is the useful one.
Null result 1 — wrong
ChainLoweringModeat both sites: no divergence. The two modes differ onlyby
parse_face_down_pile_ir/try_parse_exile_pile_shuffle_cloak, and no Solved or Power-up fixturereaches either. The mode axis is extensionally inert at these two sites, so mode preservation here
rests entirely on the by-construction argument and §7.1's name-matching rule — not on any test. §5.3's
suggested probe ("watch full-pool regeneration go red on die-result cards") does not apply here; the
~113 die-roll cards §7.1 warns about are unreachable from these recognizers. A2/A3 should not expect a
red from it either, and should re-run the probe per-tranche rather than inheriting this null.
Null result 2 — deleting U0-22's implicit
IsSolved: no divergence. That is a genuine semanticchange (a Solved ability becomes activatable while unsolved) and nothing caught it. Root cause,
measured: the only fixture reaching U0-22 is
"Solved — {T}: Add {R}."— no trailing activationinstruction, so
constraints.restrictionsis empty, making the fixture degenerate on exactly theaxis this conversion touches. Its assertions target
is_solved, not the activation envelope. Thesibling
case_of_the_crimson_pulsefixture is a decoy: its Solved clause is triggered, so it neverpasses
find_activated_colon.Perturbation that did go red — U0-26's
cost_reductionblanked:The remediation (second commit)
A two-layer snapshot for Case of the Stashed Skeleton, whose Oracle text was taken verbatim from
the card pool:
Chosen for being non-degenerate: the trailing activation instruction makes
constraints.restrictionsnon-empty, so the fixture reaches the two-element arm and pins both halvesand their order:
A card with only one restriction would witness that the field is populated while saying nothing about
sequencing — and sequencing is the property at risk. Watched go red on a pure order swap:
It lives in
oracle_ir/snapshot_tests.rsrather thantests/integration/on purpose: as an inline#[cfg(test)]src module it runs under--lib, which is what closes the measured reachability hole,and it reuses the existing
parse_two_layerharness next to its near-miss sibling. No new test binaryis created. 4 pool cards reach this recognizer (Case of the Burning Masks / Filched Falcon / Stashed
Skeleton / Uneaten Feast); 37 reach Power-up.
Gates
Snapshot churn: ZERO existing snapshots changed.
git diff --name-status <base>..HEADis 2 A /2 M, both modified files are source; every snapshot in the diff is an add, for a card that had no
prior fixture.
find . -name '*.snap.new'→ empty.oracle.rsstayed at 16 on the ratchet, which is correct and worth restating: all 16 occurrencesare consumer match arms, emitter method bodies and prose — none at producer call sites. The metric
tracks helper deletion, not producer conversion, so a tranche that converts producers can and does
leave it unmoved. Ledger not edited; no count rose.
Harvest (§5.4)
qualify it as applicable only to sites whose effect text can reach the two
WithContext-onlyrecognizers.
case_of_the_crimson_pulseis a decoy fixture — its name and Case/Solved shape imply coverageof the Solved family, but it exercises only the triggered branch. Worth a note so nobody else
counts it as coverage for U0-22.
permanent's mana cost, and 702.193b splits colored/colorless from generic. The implementation
reduces generic by
QuantityRef::SelfManaValue— the total mana value. For a{2}{R}{R}source that is 4 generic rather than 2 generic + 2 red. Pre-existing and unchanged here; fixing it
inside a byte-identity tranche would contaminate the gate.
// CR 702.193b + CR 602.2b + CR 601.2f + CR 302.6— CR 302.6 is the summoning-sickness rule and does not describe a cost reduction. The number exists;
the text does not support the use. Left verbatim to keep the diff surgical.
if !constraints.restrictions.is_empty()wrapped anextend, whichis already a no-op on an empty vec — provably behavior-identical.
needs-designlabel was pessimistic. Both weremechanical under A0's shell; recipe R1 worked exactly as specified.
Known limitation
Full-pool
card-dataregeneration was not run locally — Tilt owns it and §5.0 says read it, don't runit. Byte-identity here rests on the by-construction argument, zero snapshot churn, and 17,845 green
lib tests plus the integration binary. CI's card-data job is the empirical check.
CR numbers grep-verified against
docs/MagicCompRules.txtbefore entering code, text checked ratherthan existence alone: 602.1, 602.1a, 602.1b, 602.5d, 719.3c added; 702.193a, 702.193b, 602.2b, 601.2f,
302.6 verified as preserved context lines.
Summary by CodeRabbit
Bug Fixes
Tests