Tracking issue. Two audits of the M19 catalog and the ability IR, run against the real
set data rather than against the implementation's own tests. Every finding is now a child
issue; this one carries the method, the headline numbers, the sequencing, and the list of
things that were checked and found correct, which lives nowhere else.
Method
Every one of the 299 files in crates/sage-engine/data/catalog/ was matched by name to its
MTGJSON M19.json record (315 printings) and diffed mechanically on mana cost, types,
supertypes, subtypes, power/toughness, loyalty, and colours. Then each of the 267 definitions
carrying an ability block was read side by side with its Oracle text. Every finding that
depends on engine semantics was checked against the code before being reported.
Baseline: main at 02efe72. All of it passes make check.
Children
Part 1 — card text accuracy: 17 of 299 cards are wrong
| Issue |
What |
Scope |
| #820 |
Fifteen cards do not match the printed card — 12 wrong mana cost / P/T / subtypes / colours, 8 wrong costs, amounts, or trigger scopes, 5 both. Data only, one PR. |
Medium |
| #821 |
Two cards the IR cannot currently say — Resplendent Angel is missing an entire ability, Skyrider Patrol asks for two targets where the card names one. Needs vocabulary, not JSON. |
Medium |
| #822 |
Nothing gates the catalog against real printed data. build.rs checks shape; the behavioural tests assert against whatever the definition says and therefore cement the bug. |
Medium |
The tests are the sharp end of #822. tests/m19_granted_abilities.rs:169 is named
issue_740_gift_of_paradise_enchants_a_land_and_gains_two_life and asserts life + 2 for a
card that gains three — the name, the assertion, and the comment all encode it, and
docs/generated/test-coverage.md shows the card as covered.
Part 2 — mechanic implementation
The engine is unusually disciplined about purity, and the scripted escape hatch is genuinely
empty — every bundled card is data. The findings are all one problem: the IR grows by naming
phrasings instead of by composing axes, so the cost of the next card is paid in enum variants
and exhaustive match arms rather than in JSON. Effect is at 82 variants against 86 Effect::
arms in sage-server/src/rules_text/effects.rs alone.
The codebase already states the right principle. ObservedActivation's doc comment:
A small product of two independent filters rather than a closed list of named classes, for
the reason PermanentCount is one: the two questions a card asks about an activation … vary
independently, and a variant per pairing would grow the vocabulary once per card.
Every child below is a place where that principle was not applied.
| Issue |
What |
Evidence |
Scope |
| #823 |
ActivatorScope cannot say "you". Sarkhan's Whelp fires on an opponent's Sarkhan. The gap is not in exclusions.json, whose neighbouring entry asserts the activator filter is complete. |
1 card, 0 correct answers available |
Small |
| #824 |
One permanent filter for the non-targeting selectors. MassAffects, StaticAffects, DestroyAffects, PermanentCount all answer "which permanents?" and none composes. DestroyAffects is two variants for one card and disappears. |
9 mass scopes used by exactly 1 card each |
Large |
| #825 |
Collapse TargetSpec's permanent arms onto that filter. ~29 variants, nearly all a product of axes already modelled. |
20 of 36 catalog selectors used by exactly 1 card |
Large |
| #826 |
One Amount, not a ByCount and a ByAmount twin per verb. 11 variants for 6 verbs, with holes where no M19 card happened to need the pairing. |
CreateToken already solved this and its doc says why |
Medium |
| #827 |
One subject and one modification payload for pump / grant / restrict. The matrix is 9 variants with 4 empty cells — and the empty self-row keyword cell is why #821's Resplendent Angel is missing. |
chromium_the_mutable = 3 effects for 1 sentence |
Large |
| #828 |
Decompose the one-card bundled verbs. sacrifice_chosen_per_player bundles four rules steps and owns the only special case in Effect::target_groups; may_cast_exiled_this_way silently also bottoms a library. |
60 of 121 catalog kind values used by exactly 1 card |
Large |
#825's cost table, for scale — one new TargetSpec variant today touches, all wildcard-free:
| Site |
Arms |
sage-engine/src/resolve.rs — legality |
31 |
sage-engine/src/actions/targeting.rs — candidates |
27 |
sage-server/src/view/prompt.rs — the posed English |
29 |
sage-server/src/rules_text/words.rs |
86 |
Sequencing
#820 ──▶ #822 data first, then the gate that holds it
│
├──▶ #824 ──▶ #825 selectors: engine half, then the half that
│ generates the English a player reads
├──▶ #826 ──▶ #827 verbs: amount axis, then subject axis
│ └── supersedes #821 if scheduled first
└──▶ #828 after the above; before another set is authored
#823 independent, small, any time
#824–#828 are behaviour-preserving refactors. The catalog JSON changes shape, no card's rules
change, and the existing behavioural suites are the safety net for all five.
Related
Checked and found correct
Recording these so the next audit does not re-open them. Each looked wrong on a reading of the
card and is right in the code:
| Card |
Apparent problem |
Why it is fine |
chaos_wand |
no effect bottoms the non-cast cards |
MayCastExiledThisWay does it — which is #828's complaint about the verb, not a card bug |
fell_specter |
player_ref: target_player on a trigger the card does not target with |
named_subjects in triggers.rs:587 fixes the discarder from the event |
amulet_of_safekeeping |
target: spell_on_stack for "counter that spell" |
same mechanism, SelfBecomesTarget arm |
doublecast |
target: spell_on_stack for "copy that spell" |
delayed.rs:136 pre-fills the target (CR 603.7c) |
skilled_animator |
no "for as long as this creature remains" duration |
that is Animate::until_end_of_turn's false default |
runic_armasaur |
no "that isn't a mana ability" filter |
structural — a mana ability never reaches the stack (CR 605.3a) |
enigma_drake |
base power 0 where the card prints * |
the catalog convention: base 0 plus defined_power, reading the graveyard |
Also verified clean: all 14 keyword abilities across all 299 cards match the printed keyword
line, with exactly one exception (mistcaller's phantom flash, in #820); and every card the
catalog treats as vanilla really is vanilla.
Tracking issue. Two audits of the M19 catalog and the ability IR, run against the real
set data rather than against the implementation's own tests. Every finding is now a child
issue; this one carries the method, the headline numbers, the sequencing, and the list of
things that were checked and found correct, which lives nowhere else.
Method
Every one of the 299 files in
crates/sage-engine/data/catalog/was matched bynameto itsMTGJSON
M19.jsonrecord (315 printings) and diffed mechanically on mana cost, types,supertypes, subtypes, power/toughness, loyalty, and colours. Then each of the 267 definitions
carrying an ability block was read side by side with its Oracle text. Every finding that
depends on engine semantics was checked against the code before being reported.
Baseline:
mainat 02efe72. All of it passesmake check.Children
Part 1 — card text accuracy: 17 of 299 cards are wrong
build.rschecks shape; the behavioural tests assert against whatever the definition says and therefore cement the bug.The tests are the sharp end of #822.
tests/m19_granted_abilities.rs:169is namedissue_740_gift_of_paradise_enchants_a_land_and_gains_two_lifeand assertslife + 2for acard that gains three — the name, the assertion, and the comment all encode it, and
docs/generated/test-coverage.mdshows the card as covered.Part 2 — mechanic implementation
The engine is unusually disciplined about purity, and the scripted escape hatch is genuinely
empty — every bundled card is data. The findings are all one problem: the IR grows by naming
phrasings instead of by composing axes, so the cost of the next card is paid in enum variants
and exhaustive match arms rather than in JSON.
Effectis at 82 variants against 86Effect::arms in
sage-server/src/rules_text/effects.rsalone.The codebase already states the right principle.
ObservedActivation's doc comment:Every child below is a place where that principle was not applied.
ActivatorScopecannot say "you". Sarkhan's Whelp fires on an opponent's Sarkhan. The gap is not inexclusions.json, whose neighbouring entry asserts the activator filter is complete.MassAffects,StaticAffects,DestroyAffects,PermanentCountall answer "which permanents?" and none composes.DestroyAffectsis two variants for one card and disappears.TargetSpec's permanent arms onto that filter. ~29 variants, nearly all a product of axes already modelled.Amount, not aByCountand aByAmounttwin per verb. 11 variants for 6 verbs, with holes where no M19 card happened to need the pairing.CreateTokenalready solved this and its doc says whychromium_the_mutable= 3 effects for 1 sentencesacrifice_chosen_per_playerbundles four rules steps and owns the only special case inEffect::target_groups;may_cast_exiled_this_waysilently also bottoms a library.kindvalues used by exactly 1 card#825's cost table, for scale — one new
TargetSpecvariant today touches, all wildcard-free:sage-engine/src/resolve.rs— legalitysage-engine/src/actions/targeting.rs— candidatessage-server/src/view/prompt.rs— the posed Englishsage-server/src/rules_text/words.rsSequencing
data preserves the wrongness.
wrong is a worse state to sit in than one field being written twice —
AGENTS.mdmakesplaying the merge criterion.
Amount.#824–#828 are behaviour-preserving refactors. The catalog JSON changes shape, no card's rules
change, and the existing behavioural suites are the safety net for all five.
Related
exclusions.jsonand prohibit denylists. engine: an observed activation cannot say "you" #823, engine: one permanent filter for the non-targeting selectors #824, engine: collapse TargetSpec's permanent arms onto the shared filter #825, and engine: decompose the one-card bundled effect verbs #828 each dropor narrow entries in that file; coordinate rather than duplicate.
(nothing filters an observed activation by whether it was a loyalty ability).
Checked and found correct
Recording these so the next audit does not re-open them. Each looked wrong on a reading of the
card and is right in the code:
chaos_wandMayCastExiledThisWaydoes it — which is #828's complaint about the verb, not a card bugfell_specterplayer_ref: target_playeron a trigger the card does not target withnamed_subjectsintriggers.rs:587fixes the discarder from the eventamulet_of_safekeepingtarget: spell_on_stackfor "counter that spell"SelfBecomesTargetarmdoublecasttarget: spell_on_stackfor "copy that spell"delayed.rs:136pre-fills the target (CR 603.7c)skilled_animatorAnimate::until_end_of_turn'sfalsedefaultrunic_armasaurenigma_drake0where the card prints*defined_power, reading the graveyardAlso verified clean: all 14 keyword abilities across all 299 cards match the printed keyword
line, with exactly one exception (
mistcaller's phantomflash, in #820); and every card thecatalog treats as vanilla really is vanilla.