MTG Cheaper Deck processes a Moxfield deck through five discrete stages: fetch, match, pool-load, score, and output. The design is deliberately offline-first — once the local Scryfall database is populated, the only network call is the single request to Moxfield at the start of the run. There are no per-card API calls during analysis, which keeps the full pipeline fast even for ~90-card Commander decks. Each stage hands a narrow, well-typed result to the next, so failures are easy to isolate and the scoring logic never needs to worry about network state.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/invvd/mtg-cheaper-deck/llms.txt
Use this file to discover all available pages before exploring further.
Stage 1 — Fetch from Moxfield
moxfield_client.fetch_deck_raw() makes a single GET request to the unofficial Moxfield endpoint:
moxfield.com/decks/… URL, or used directly if a bare ID is provided. The raw JSON response is then passed to parse_deck(), which walks the boards structure and yields one entry per card slot across the commanders, mainboard, and companion boards. The result is a flat list of dicts, each with four keys:
raw.get("format"), defaulting to "commander" if the field is absent. This format key is passed downstream to filter the candidate pool for legality.
Stage 2 — Match Against Local DB
matching.match_deck_cards() resolves each Moxfield card entry to a row in the local cards table. It tries two strategies in order:
- Scryfall ID lookup — matches
cards.scryfall_idexactly. This works when Moxfield’s chosen printing happens to be the same card Scryfall selected as the oracle representative, but that is not guaranteed. - Case-insensitive name lookup — matches
LOWER(cards.name) = LOWER(entry["name"]). This is the primary path for most cards in practice.
(matched, unmatched) tuple. Each entry in matched is the original Moxfield dict merged with a "local" key holding the full database row as a plain dict. Cards in unmatched are logged as warnings and skipped for the rest of the pipeline — they do not appear in suggestions output.
Stage 3 — Load the Candidate Pool
scoring.load_candidate_pool(format_key) runs a single SQL query against the local database:
CandidatePool object, which does three things at construction time:
- Sorts all rows by
price_usdascending and caches a parallel list of prices for fast binary search. - Pre-computes a
_keywords_set(a Pythonset) for every card from its Scryfallkeywordsarray. - Pre-tokenizes
oracle_textinto a set of meaningful game terms for every card (see Scoring for tokenization details).
Stage 4 — Score and Rank Alternatives
For each matched card in the deck,suggest_alternatives() is called with the card’s local row, the shared CandidatePool, the desired top_n count, and the set of all oracle_id values already in the deck.
The function narrows the candidate pool with pool.cheaper_than(price), which uses bisect.bisect_left on the pre-sorted price list to return only cards strictly cheaper than the original in O(log n) time. It then applies three hard filters before scoring:
- Deck exclusion — the candidate’s
oracle_idmust not already appear anywhere in the deck. - Color identity — the candidate’s color identity mask must be a subset of the original’s (bitmask AND check).
- Type overlap — the candidate must share at least one core card type with the original.
Cards without a USD price are skipped entirely at this stage — they are collected in the
no_price list in the final report and shown as a dim notice in the terminal output. They are never evaluated as originals or candidates.Stage 5 — Output
build_report() in main.py assembles the scored suggestions into a single report dict:
| Flag / interface | Output |
|---|---|
| (default) | Rich terminal table with color-coded savings and score columns |
--json | Pretty-printed JSON to stdout |