Use when modifying the card data pipeline — adding new fields to card exports, changing Oracle parser output shape, updating card database loading, modifying the coverage report, adding synthesis functions, or debugging why a card's data looks wrong at runtime.
Install
npx skillscat add phase-rs/phase/add-card-data-pipeline Install via the SkillsCat registry.
Card Data Pipeline
Hard rules — all pipeline work must respect these (see CLAUDE.md § Design Principles):
- CR-correctness is non-negotiable. The pipeline is the bridge between Oracle text and engine types. Parser output must faithfully represent the Comprehensive Rules semantics of each card. If a synthesis function or parser pattern produces an incorrect typed representation, the engine will enforce wrong rules. Verify every new pattern against the relevant CR section and annotate the code.
- Build for the class, not the card. Every parser pattern, synthesis function, and export field must handle a category of cards. A synthesis function that works for one card but mishandles the next card with the same keyword pattern is a defect. Test with multiple representative cards from the class.
- Test the building block. Every new parser pattern needs a unit test. Every new synthesis function needs a test verifying its output. Run
cargo coverageafter changes to verify coverage improvements. Runcargo insta reviewif snapshot tests are affected.
The card data pipeline converts raw MTGJSON card data into the typed card-data.json consumed by the WASM frontend and multiplayer server. Understanding this pipeline is essential when adding new ability types, fields, or synthesis steps — a change to the export format must propagate through all loading paths.
Before you start: Run the pipeline end-to-end once to see what it produces: cargo run --release --bin oracle-gen -- data --stats > /tmp/test-export.json 2>/tmp/stats.txt && head -1 /tmp/stats.txt
CR Verification Rule: Every CR number in annotations MUST be verified by grepping
docs/MagicCompRules.txtbefore writing. Do NOT rely on memory — 701.x and 702.x numbers are arbitrary sequential assignments that LLMs consistently hallucinate. Rungrep -n "^701.21" docs/MagicCompRules.txt(etc.) for every number. If you cannot find it, do not write the annotation.
Pipeline Flow
MTGJSON (AtomicCards.json, ~30k cards)
↓ download: scripts/gen-card-data.sh
data/mtgjson/AtomicCards.json
↓ parse: crates/engine/src/database/mtgjson.rs
HashMap<String, Vec<AtomicCard>>
↓ process: crates/engine/src/database/synthesis.rs (build_oracle_face)
│ For each card face:
│ ├─ build_card_type() → CardType
│ ├─ parse_oracle_text() → ParsedAbilities { abilities, triggers, statics, replacements }
│ └─ synthesize_all() — runs all synthesis functions (see function for current list)
↓ export: crates/engine/src/bin/oracle_gen.rs
client/public/card-data.json (~49 MB in the current export)
↓ consume (three loading paths):
├─ WASM: CardDatabase::from_json_str() [browser, via engine-wasm]
├─ Server: CardDatabase::from_export() [phase-server, multiplayer]
└─ CI: CardDatabase::from_export() [coverage-report binary]Quick Commands
Regenerate card data:
cargo run --release --bin oracle-gen -- data --stats > client/public/card-data.jsonLook up a specific card's exported data:
jq '.["card name in lowercase"]' client/public/card-data.json
# Example:
jq '.["lightning bolt"]' client/public/card-data.jsonCheck if a card has unimplemented abilities:
jq '.["card name"] | {abilities: [.abilities[]? | select(.effect.type == "Unimplemented")], triggers: [.triggers[]? | select(.mode == "Unknown")]}' client/public/card-data.jsonRun coverage report:
cargo coverage # Standard-legal cards
cargo run --bin coverage-report -- data --all # All cards
cargo run --bin coverage-report -- data --ci # CI mode (exits 1 on gaps)Key Files
Data Source — MTGJSON
crates/engine/src/database/mtgjson.rs — Deserialization types for MTGJSON format.
pub struct AtomicCardsFile {
pub data: HashMap<String, Vec<AtomicCard>>, // name → faces
}
pub struct AtomicCard {
pub name: String,
pub mana_cost: Option<String>, // "{2}{R}" format
pub colors: Option<Vec<String>>, // ["R", "G"]
pub types: Vec<String>, // ["Creature"]
pub subtypes: Option<Vec<String>>, // ["Elf", "Warrior"]
pub supertypes: Option<Vec<String>>, // ["Legendary"]
pub power: Option<String>, // "3" or "*"
pub toughness: Option<String>,
pub text: Option<String>, // Oracle text
pub keywords: Option<Vec<String>>, // ["Flying", "Ward"]
pub layout: Option<String>, // "normal", "transform", "split", etc.
pub legalities: Option<HashMap<String, String>>,
pub identifiers: Option<Identifiers>, // includes scryfall_oracle_id
}Synthesis — crates/engine/src/database/synthesis.rs
build_oracle_face(mtgjson, oracle_id) → CardFace — Converts one MTGJSON face into a typed CardFace:
build_card_type()— maps type strings toCardType { supertypes, core_types, subtypes }parse_oracle_text()— runs Oracle parser →ParsedAbilitiessynthesize_all()— runs all keyword-implied synthesis (seesynthesize_all()for the current registry). Eachsynthesize_*function takes&mut CardFaceand adds abilities/triggers/statics that the keyword implies but Oracle text doesn't make explicit.
Oracle Loader — crates/engine/src/database/oracle_loader.rs
Handles MTGJSON-specific loading concerns: card layout detection, face splitting for DFCs/split cards, and legality normalization. Delegates to synthesis.rs for the per-face build pipeline.
Export Binary — crates/engine/src/bin/oracle_gen.rs
Orchestrates the full export:
- Load MTGJSON via
load_atomic_cards() - For each card:
build_oracle_face()per face - Map layout to
CardLayoutenum (Single, Split, Transform, etc.) - Normalize legalities
- Flatten to
HashMap<String, CardExportEntry>(key = lowercase face name, value = flattenedCardFace+legalities) - Serialize to JSON → stdout
Card Database — crates/engine/src/database/card_db.rs
Three loading methods:
| Method | Used By | What It Loads |
|---|---|---|
from_mtgjson(path) |
oracle_gen, tests |
Full parse — runs Oracle parser on raw MTGJSON |
from_export(path) |
phase-server, coverage-report |
Pre-processed card-data.json with flattened face entries + legalities |
from_json_str(json) |
engine-wasm (browser), deck validation tests |
Same as from_export but takes string input |
Internal structure:
pub struct CardDatabase {
pub cards: HashMap<String, CardRules>, // Populated by from_mtgjson
pub face_index: HashMap<String, CardFace>, // Populated by all loading paths
pub legalities: HashMap<String, CardLegalities>,
pub errors: Vec<(PathBuf, String)>,
}Card Face — crates/engine/src/types/card.rs
The serialized unit in card-data.json:
pub struct CardFace {
pub name: String,
pub mana_cost: ManaCost,
pub card_type: CardType,
pub power: Option<PtValue>,
pub toughness: Option<PtValue>,
pub loyalty: Option<String>,
pub defense: Option<String>,
pub oracle_text: Option<String>,
pub non_ability_text: Option<String>,
pub flavor_name: Option<String>,
pub keywords: Vec<Keyword>,
pub abilities: Vec<AbilityDefinition>,
pub triggers: Vec<TriggerDefinition>,
pub static_abilities: Vec<StaticDefinition>,
pub replacements: Vec<ReplacementDefinition>,
pub color_override: Option<Vec<ManaColor>>,
pub scryfall_oracle_id: Option<String>,
pub modal: Option<ModalChoice>,
pub additional_cost: Option<AdditionalCost>,
pub casting_restrictions: Vec<CastingRestriction>,
pub casting_options: Vec<SpellCastingOption>,
}Coverage Report — crates/engine/src/bin/coverage_report.rs
Checks each card face for:
Keyword::Unknown(_)→ unsupported keywordEffect::Unimplemented { .. }→ unsupported effectTriggerMode::Unknown(_)→ unsupported trigger- Unrecognized
StaticMode→ unsupported static
Outputs JSON summary: { total_cards, supported_cards, coverage_pct, cards: [...], missing_handler_frequency: [...] }
Important current behavior:
coverage-reportloadscard-data.jsonviaCardDatabase::from_export(), not raw MTGJSON- Without
--all, it filters tostandard-cards.txt - It strips known-benign MTGJSON keyword mismatches (bare parameterized keywords like
Keyword:Wardand action-keyword noise likeKeyword:Scry) before computing final manifest coverage --write-warning-patterns <path>writes clustered parser warnings ranked by likely shared fix--warning-pattern '<pattern>'emits a compact JSON drilldown for one exact parser warning pattern--warning-detector <detector>emits a compact JSON drilldown for a broader swallowed-clause detector family--warning-fullincludes the fullparse_detailstree and exportedCardFaceJSON in drilldown output
CI mode (--ci): exits 1 if any Standard-legal cards have gaps.
Coverage Module — crates/engine/src/game/coverage.rs
pub fn unimplemented_mechanics(obj: &GameObject) -> Vec<String>Checks a game object at runtime for unsupported mechanics. Used by the frontend to show amber "!" badges on cards with partial support.
Checklist — Modifying the Pipeline
Adding a New Field to Card Export
When a new feature needs data that isn't currently exported:
crates/engine/src/types/card.rs—CardFacestruct
Add the field. Use#[serde(default)]for backward compatibility — existing card-data.json files must still deserialize.crates/engine/src/database/synthesis.rs—build_oracle_face()
Populate the new field during card processing.crates/engine/src/bin/oracle_gen.rs— Usually no changes needed (serializesCardFaceautomatically via serde).Regenerate card-data.json:
cargo run --release --bin oracle-gen -- data --stats > client/public/card-data.jsonclient/src/adapter/types.ts— TypeScript types (if frontend needs the field)
Add the optional field to the card type definition.
Adding a New Synthesis Function
When a keyword or ability implies game mechanics that Oracle text doesn't make explicit:
crates/engine/src/database/synthesis.rs— newsynthesize_*()function
Pattern: takes&mut CardFace, checks for the triggering condition (keyword, type, etc.), adds abilities/triggers/statics to the face.crates/engine/src/database/synthesis.rs— call fromsynthesize_all()
Add the call insynthesize_all()alongside existing synthesis calls. This is automatically invoked bybuild_oracle_face().Test: Add a test in
synthesis.rsverifying the synthesis produces the expected output.
Modifying the Oracle Parser Output Shape
When a parser change affects the structure of ParsedAbilities:
Update all three loading paths —
from_mtgjson,from_export, andfrom_json_strall need to understand the serializedCardFaceshape. If you add/rename fields, verify both raw-MTGJSON loading and flattened export loading.Audit serialized consumers — If the parser output shape changes an enum or serialized ability field, verify WASM JSON loading, server
from_export, coverage-report loading, and any AI/community scenario fixtures that store the changed enum. Add serde defaults, fixture migrations, or compatibility only when deliberately needed.Regenerate card-data.json — Always regenerate after parser changes.
Update coverage report — If the parser now recognizes previously-unimplemented patterns, coverage numbers will change. Run the report to verify improvements, and update any benign-keyword filtering in
coverage_report.rsif the mismatch profile changed.Coverage honesty gate — Accepting full Oracle text while dropping a rules-bearing rider, continuation, restriction, granted ability, replacement, or sentence tail is a hard failure unless an
Effect::unimplemented,TriggerMode::Unknown, equivalent strict-failure marker, or unchanged unsupported coverage keeps the card red. Coverage improvements must list shipped semantics separately from residual unsupported riders.Update snapshot tests —
crates/engine/tests/oracle_parser.rshasinstasnapshots that must be updated:cargo insta review.
CI Coverage Gate
The coverage report checks card support across format-based legality data. There is no separate manifest file — coverage is computed from the cards' legality fields in card-data.json.
WASM Loading Path
Understanding how card data reaches the browser:
- Build time:
oracle-genproducesclient/public/card-data.json - Runtime: Frontend fetches
/card-data.jsonvia HTTP - WASM init:
load_card_database(json_str)incrates/engine-wasm/src/lib.rs - Parse:
CardDatabase::from_json_str()→ deserializes flattened export entries intoface_index+ normalizedlegalities - Storage: Thread-local
CARD_DB: RefCell<Option<CardDatabase>> - Usage:
initialize_game()resolves deck card names viaface_indexlookup
Important: The WASM bridge receives the JSON as a string from JavaScript, not as a file path. Any changes to the JSON structure must round-trip through serde correctly.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Missing #[serde(default)] on new CardFace fields |
Existing card-data.json fails to deserialize | Always default new optional fields |
| Changing field types without regenerating card-data.json | Runtime deserialization panic | Always regenerate after type changes |
Adding synthesis but not calling from build_oracle_face() |
Synthesis exists but never runs — abilities missing from export | Add the call |
| Not updating TypeScript types | Frontend can't access new fields, or gets wrong types | Update adapter/types.ts |
Modifying CardFace but only testing from_mtgjson path |
from_export and from_json_str may fail differently |
Test all three paths |
| Parser accepts a full sentence but drops a semantic rider | Coverage claims support while the engine enforces incomplete rules | Preserve an explicit Unimplemented/Unknown/strict-failure marker or implement the whole rules-bearing clause |
| Parser enum shape changes but AI/community fixtures are stale | Serialized scenario loading fails even though parser/card-data checks pass | Migrate fixtures or provide an intentional compatibility path |
| Coverage looks wrong after parser improvement | Manifest filtering or benign-keyword stripping hides real result | Check coverage_report.rs filtering logic |
| Forgetting to regenerate after parser changes | Card-data.json contains stale parsed data | Run oracle-gen after any parser modification |
| Parser produces new support but coverage still looks wrong | Manifest filtering or benign MTGJSON keyword mismatches hide the real result | Check coverage_report.rs filtering before assuming the parser regressed |
Self-Maintenance
After completing work using this skill:
- Verify references with the check below
- Update the synthesis function list if you added a new one
- Update the CardFace fields if the struct changed
Verification
rg -q "fn build_oracle_face" crates/engine/src/database/synthesis.rs && \
rg -q "fn synthesize_basic_land_mana" crates/engine/src/database/synthesis.rs && \
rg -q "fn synthesize_equip" crates/engine/src/database/synthesis.rs && \
rg -q "fn synthesize_changeling_cda" crates/engine/src/database/synthesis.rs && \
rg -q "fn synthesize_all" crates/engine/src/database/synthesis.rs && \
rg -q "fn from_export" crates/engine/src/database/card_db.rs && \
rg -q "fn from_json_str" crates/engine/src/database/card_db.rs && \
rg -q "fn from_mtgjson" crates/engine/src/database/card_db.rs && \
rg -q "struct CardFace" crates/engine/src/types/card.rs && \
rg -q "fn parse_oracle_text" crates/engine/src/parser/oracle.rs && \
rg -q "fn unimplemented_mechanics" crates/engine/src/game/coverage.rs && \
test -f crates/engine/src/bin/oracle_gen.rs && \
test -f crates/engine/src/bin/coverage_report.rs && \
test -f crates/engine/src/database/synthesis.rs && \
echo "✓ add-card-data-pipeline skill references valid" || \
echo "✗ STALE — update skill references"