Skip to main content

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.

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.

Stage 1 — Fetch from Moxfield

moxfield_client.fetch_deck_raw() makes a single GET request to the unofficial Moxfield endpoint:
https://api2.moxfield.com/v2/decks/all/{deck_id}
The deck ID is parsed out of any full 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:
{
    "name":        "Cyclonic Rift",
    "scryfall_id": "8e2e1b6a-...",
    "quantity":    1,
    "board":       "mainboard"
}
The deck’s format is read from 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:
  1. Scryfall ID lookup — matches cards.scryfall_id exactly. This works when Moxfield’s chosen printing happens to be the same card Scryfall selected as the oracle representative, but that is not guaranteed.
  2. Case-insensitive name lookup — matches LOWER(cards.name) = LOWER(entry["name"]). This is the primary path for most cards in practice.
The function returns a (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:
SELECT *
FROM cards
WHERE price_usd IS NOT NULL
  AND json_extract(legalities, '$.' || :format_key) = 'legal'
ORDER BY price_usd ASC
Every card with a known USD price that is legal in the deck’s format is returned in one round-trip. The results are wrapped in a CandidatePool object, which does three things at construction time:
  • Sorts all rows by price_usd ascending and caches a parallel list of prices for fast binary search.
  • Pre-computes a _keywords_set (a Python set) for every card from its Scryfall keywords array.
  • Pre-tokenizes oracle_text into a set of meaningful game terms for every card (see Scoring for tokenization details).
These pre-computations happen once per deck run, not once per card comparison.

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:
  1. Deck exclusion — the candidate’s oracle_id must not already appear anywhere in the deck.
  2. Color identity — the candidate’s color identity mask must be a subset of the original’s (bitmask AND check).
  3. Type overlap — the candidate must share at least one core card type with the original.
Each surviving candidate is scored against the original using the four-term weighted formula described on the Scoring page. Candidates are sorted by score descending and the top N are returned.
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:
{
    "deck_name":           "My Commander Deck",
    "format":              "commander",
    "total_cards":         99,
    "matched":             97,
    "unmatched":           ["Mana Crypt"],
    "no_price":            ["Shahrazad"],
    "no_suggestions":      ["Island"],
    "suggestions":         [...],
    "best_savings_pct_avg": 42.3
}
This dict is then rendered in one of several ways depending on how the tool is invoked:
Flag / interfaceOutput
(default)Rich terminal table with color-coded savings and score columns
--jsonPretty-printed JSON to stdout
Cards are displayed commander-first, then sorted by original price descending, so the highest-value swap opportunities appear at the top.
CandidatePool is constructed once per deck run, not once per card. The json_extract legality filter in Stage 3 evaluates across all ~38,000 Scryfall Oracle cards a single time. Without this design, the same filter would re-run for every card in the deck — which is what pushed the original pipeline runtime to ~15 seconds for a 90-card deck. Loading the pool once and reusing it brings that down to a manageable time.

Build docs developers (and LLMs) love