Skip to content

Statistics

Pablo Martínez López edited this page Oct 21, 2026 · 4 revisions

Statistics

English · Español

Deck statistics (mana curve, colours, types, functions, draw probabilities, prices, format legality, construction problems, Commander bracket estimate) are computed entirely in Rust. The dialog only paints a DeckStats value. The core is pure counting over a list of StatInput; the app layer gathers Scryfall data (cache first, network for what is missing or stale) and feeds it in.

Source: src-tauri/crates/proxy-core/src/stats/ (mod.rs, funciones.rs, mana.rs, prob.rs), src-tauri/src/commands/stats.rs, src/features/stats/, and the cuentan rule in src-tauri/crates/proxy-core/src/model.rs.

Statistics summary

Architecture

flowchart LR
    UI["StatsDialog (7 tabs)"] -->|"call deck_stats(cards, kind)"| CMD["commands/stats.rs: calcular"]
    CMD --> CU["model::cuentan(cards)"]
    CU --> CACHE["scryfall cache (SQLite)"]
    CACHE -->|"missing / older than 24 h / no oracle text"| NET["/cards/collection, 75 per batch"]
    CMD --> IN["Vec<StatInput>"]
    IN --> CORE["proxy_core::stats::stats()"]
    CORE --> DS["DeckStats"]
    DS --> UI
    LIB["biblioteca.rs detalles_de"] -->|"calcular_sin_red (cache only)"| CMD
Loading

Boundary rule (Decision): the core (proxy-core) has no Tauri, no network, no database. stats() is a pure function (&[StatInput], Option<DeckKind>) -> DeckStats, which makes it trivially testable. Everything impure (cache reads, HTTP, price fallback) is in commands/stats.rs.

The cuentan rule and setAside

model::cuentan(cards) -> (Vec<&DeckCard>, Apartadas) decides which physical copies count for deck size, copy limits, statistics and the exported deck list. Marks come from DeckCard.marca (MarcaCarta::{Variante, Opcional, Banquillo}; set in the preview menu, see Live Preview). All marked cards still print; the mark only changes what is counted.

Mark Counted?
none yes
Opcional (optional) no (opcionales)
Banquillo (sideboard) no (banquillo)
Variante (design variant) If a copy of the same card (case-insensitive trimmed name) exists without a mark: none of the variants count. If all copies of that name are variants: exactly one counts (the first). The rest go to variantes.

Apartadas { variantes, opcionales, banquillo } (+ total()) is returned and copied into DeckStats.setAside by calcular (s.set_aside = apartadas), because stats() itself only sees the already-filtered input and leaves set_aside at its default. The summary tab shows a notice when setAside is non-zero. The same cuentan is used by proxy_core::decklist::exportar so the exported list matches the statistics (Export). Test: las_marcadas_no_cuentan in model.rs, and las_cartas_marcadas_no_cuentan_para_el_mazo in commands/stats.rs.

Collecting data (src-tauri/src/commands/stats.rs)

Entry points:

  • deck_stats(cards: Vec<DeckCard>, kind: Option<DeckKind>) -> AppResult<DeckStats> (async Tauri command; uses the real Scryfall client).
  • calcular_sin_red(db, cards) (crate-visible): same computation with SinRed, a CardApi whose every call fails with Network, so only the cache is used. The deck library (biblioteca.rs detalles_de) uses this to compute colours, price and so on without network and quickly (Deck Library).
  • calcular(db, api, cards, kind) is generic over CardApi so tests inject a fake Scryfall.

Steps in calcular:

  1. cuentan(cards).
  2. For each counted card that links to a Scryfall card (id_scryfall: Scryfall and Token sources, and Custom with a scryfallId; blanks and unlinked customs have none), look it up in the cache (scryfall::cache::leer). A cached card is "fresh" if fetched less than 24 h ago (PRECIOS_MS) AND keywords.is_some() (a marker that the entry was stored after the app started keeping oracle text; older entries are refetched). Stale or missing ids go into a request list; stale data is still used meanwhile.
  3. Request the list through /cards/collection in batches of 75 (POR_LOTE), saving results back to the cache. On any error (offline) the loop stops with a warning log and the stats are computed from what the cache has: the dialog never fails because of the network.
  4. English price fallback. Scryfall gives no prices to non-English printings. For each non-English card with neither EUR nor USD (foil prices count, see below) and a known set, look up the English printing with the same set and collector number (cache::leer_impresion(set, number, "en"), fresh within 24 h, otherwise requested by Identifier::SetNumber). entrada() then uses the English printing's prices only when the card itself has none (test con_precio_propio_no_se_usa_el_ingles).
  5. Price choice (precios()): eur else eur_foil, usd else usd_foil (some printings exist only in foil).
  6. Build one StatInput per counted card that is not a blank: from the Scryfall card (entrada(): colours from the card or its first face, texto_completo() as oracle text, coste() as mana cost of the front face, power/toughness fallback to the first face, game_changer, legalities...). Tokens: Token source, or a linked card whose type line starts with Token, or a custom card from an external source with a token type line. Cards without Scryfall data become an empty StatInput with only id, name and is_token: counted as "unknown".
  7. stats(&entrada, kind); then set set_aside.

The core: stats() (proxy-core/src/stats/mod.rs)

Input and output

StatInput (not exported to TS) holds one copy: id, name, cmc, mana_cost, colors, color_identity, type_line, oracle_text, keywords, produced_mana, power, toughness, rarity, set, set_name, released_at, price_eur, price_usd, legalities, game_changer, is_token. DeckStats (exported with #[ts(export)], camelCase) is a large struct; the groups:

Group Fields
Kind and size kind, detectedKind, setAside, cards (copies with data, no tokens), deckSize (all non-token copies, including unknown), lands, nonlands, tokens, unknown, unknownCards, tokenCards
Curve averageCmc (no lands), averageCmcWithLands, medianCmc, totalManaValue, curve: TramoCurva[8] (cmc 0..7+, byType in TIPOS_CURVA order, permanents, spells)
Colours colors: ColorMana[6] (W U B R G C: cards, pips, landSources, otherSources), colorIdentity, colorCount (0..5 colours, non-lands), xSpells
Types types, supertypes, subtypes (top 30), keywords (top 30), creatures { count, averagePower, averageToughness, power[0..7+] }, rarity, sets, oldest, newest
Functions roles: Rol[14] (key, count, cards)
Prices prices { totalEur, totalUsd, withoutEur, withoutUsd, cards, byType, buckets } (buckets <1, 1-5, 5-20, 20-50, 50+ in EUR)
Probability probability { deckSize, lands, openingLands[0..7], expectedLands, landDrops[1..8], cards, roles }
Construction recommendedLands, cheapRampDraw, legality[9 formats], problems, bracket

Rules worth knowing

  • Unknown vs token vs counted. A token goes to tokenCards and is excluded from everything else. A non-token with neither type line nor cmc is unknown (counts towards deckSize and probabilities, nothing else).
  • Main type (tipo_principal): looks only at the front face and before the em dash, and takes the FIRST match in this priority order: Creature, Planeswalker, Battle, Instant, Sorcery, Artifact, Enchantment, Land, else "Other". Consequences visible in the code: "Artifact Creature" is a Creature; an "Artifact Land" is classified Artifact (so it is not counted as a land by es_tierra, which tests tipo_principal == "Land"); a "Land Creature" is a Creature.
  • Curve excludes lands; costs of 7 or more share the last bucket; permanents are everything that is not Instant or Sorcery.
  • Pips (mana.rs leer): counts coloured symbols of the front-face cost; hybrid {U/B} counts for both colours, Phyrexian {G/P} for its colour; {X} sets a flag (xSpells). Split cards {1}{R} // {1}{U} count both halves.
  • Mana sources: from produced_mana, lands go to landSources, non-lands to otherSources. The "Mana" tab warns when a colour's share of pips exceeds its share of land sources by more than 10 points (AVISO_FUENTES = 0.1 in src/features/stats/mana.tsx).
  • Deck kind (deducir): fewer than 50 non-token cards is Limited; 90 to 110 cards where at most two non-basic, non-"any number of copies" names repeat is Commander; otherwise Constructed. The user can override the detected kind in the dialog (kind argument); detectedKind always reports the deduced one.
  • Recommended lands (tierras_recomendadas): Karsten-style formula. Commander: 31.42 + 3.13 * avgCmc - 0.28 * cheap; Constructed (60): 19.59 + 1.90 * avgCmc - 0.28 * cheap; Limited: the 60-card value scaled by 40/60. cheap = non-land cards with cmc <= 2 that are ramp or draw (cheapRampDraw). Rounded to one decimal.
  • Problems: copies (more than 1 copy in Commander or 4 in Constructed, excluding basic lands and cards with "any number of cards named" or "up to" text, sin_limite_de_copias); size (Commander must be exactly 100, Constructed at least 60, Limited at least 40).
  • Legality: for the 9 formats in FORMATOS (standard, pioneer, modern, legacy, vintage, commander, pauper, historic, brawl) a card is a problem unless its status is legal or restricted. A format is legal only if there are cards and no offenders; an empty deck is legal in nothing (un_deck_vacio_no_es_legal_en_nada).
  • Prices: per-name price taken from the first copy; totals sum every copy; list sorted by count * price (EUR, else USD). Rounded to cents.
  • Bracket (Commander only, Bracket): level 4 if any mass land denial or more than 3 Game Changers; else level 3 if any Game Changer, 2 or more extra-turn cards, or more than 3 tutors; else 2. It cannot detect combos, and bracket 1 depends on theme so it is never returned. The tab texts in src/features/stats/textos.ts (BRACKETS) say so.

Functions (funciones.rs)

14 keys in FUNCIONES: ramp, draw, removal, wipe, counter, tutor, protection, recursion, tokens, anthem, lifegain, mill, extraTurn, landDenial. A card can have several. They are deduced by regular expressions on the English oracle text (FUENTES, compiled once in a LazyLock), after normalizar lowercases and strips reminder text in parentheses. It is a heuristic: orientative, like a person classifying by eye.

  • ramp: non-lands that produce mana (produced_mana not empty) or match a ramp pattern (fetch land to battlefield, extra land drop, Treasure, cost reduction). Lands are never ramp.
  • removal and wipe use alguno_ajeno: a match with the named group tuyo ("you control") is ignored, so "exile target creature you control" (a blink) is not removal.
  • tutor (es_tutor): "search your library for" something that is not a land type.
  • lifegain/mill also accept the keywords Lifelink/Mill.
  • todos_los_patrones_compilan is a test guard: patterns that fail to compile would be silently dropped (compilar uses filter_map), so keep that test green when adding patterns.

Probabilities (prob.rs)

Hypergeometric distribution with log-binomials (ln_comb). exacta(total, successes, n, k) is P(X = k); al_menos is P(X >= k); por_vistas(total, copies) is P(at least one copy) after 7, 8, ... 7 + ROBOS (12) cards seen; values rounded to 4 decimals (redondear). Probabilidades:

  • openingLands[k]: P(exactly k lands in the opening 7), k = 0..7. expectedLands = 7 * lands / deckSize.
  • landDrops[t] for turn t = 1..8: P(at least t lands by turn t): play (on the play) sees 7 + t - 1 cards, draw sees 7 + t.
  • cards and roles: bySeen curves per card name (sorted by name) and per function with at least one card. The calculator in the dialog indexes bySeen by turn: indice(turn, onThePlay) = 0 for the opening hand, turn - 1 on the play, turn on the draw.
  • deckSize here is every non-token copy (including unknown ones), so unlinked custom cards still shorten the odds correctly. No mulligan is modelled (the UI says so).

Screen: src/features/stats/

StatsDialog (stats-dialog.tsx) opens when useUi.dialogo.tipo === "estadisticas" (button in the preview toolbar, disabled for an empty deck). On open (and whenever the deck's cards or the chosen kind change) it calls deck_stats with the card list from the deck store; a vigente flag discards late answers. It shows a spinner (and keeps showing the previous result while refreshing), a deck-kind selector ("Automatic: ", Commander 100, Constructed 60, Limited 40), and a EUR/USD toggle that only affects display.

Tab File Shows
Summary resumen.tsx Figures (cards, lands vs recommended, average cost, mana value, price, bracket or opening-land odds), setAside notice, unknown-card list, copy/size problems, mana curve (by type or permanents vs spells), colours, tokens used
Mana mana.tsx Pips vs land sources per colour, low-sources warning, colour-count distribution
Types tipos.tsx Types, rarity, creatures (average power/toughness, power histogram), subtypes, keywords, sets and date range
Functions funciones.tsx Count per function with expandable card lists
Probabilities probabilidades.tsx Opening-hand land distribution, land drops by turn, draw calculator (function or card, play/draw, turn)
Prices precios.tsx Total, average, most expensive, without price, by type, price buckets, card table sorted by total price
Legality legalidad.tsx Per-format legality with offenders, construction problems, Commander bracket details

Shared pieces are in piezas.tsx (Cifra, Bloque, Lista, ListaCartas). Prices are formatted by formatoMoneda(lang, moneda) (textos.ts) and the release dates in tipos.tsx by toLocaleDateString, both with localeDe(lang) (src/lib/idiomas.ts), so they follow the interface language. ListaCartas and the price table have a "select these cards in the deck" button: seleccionarEnDeck(ids, text) selects those copies in the preview, closes the dialog and announces it (this is why CartaCuenta/CartaPrecio carry copy ids). textos.ts holds the display names and colours (COLORES, TIPOS, RAREZAS, FUNCIONES, CLASES_MAZO, BRACKETS, formatoMoneda) and all strings go through t(); Rust returns keys, never prose. Screenshots: docs/presentacion/en/estadisticas-{resumen,funciones,probabilidades,precios}.jpg.

Tests

  • Core: stats/mod.rs tests (cuenta_curva_colores_tipos_y_precio, probabilidades_del_deck, la_clase_de_mazo_y_sus_problemas, el_bracket, las_tierras_de_karsten, el_tipo_principal, un_deck_vacio_no_es_legal_en_nada), funciones.rs (rampa, robo_eliminacion_y_barrido, tutores_y_lo_demas, casos_de_un_deck_real, todos_los_patrones_compilan, copias_sin_limite), prob.rs (la_hipergeometrica, por_vistas_crece, mas_cartas_de_las_que_hay), mana.rs (lee_los_simbolos).
  • App: commands/stats.rs tests with a fake Scryfall (pide_de_una_vez_solo_lo_que_no_esta_fresco, los_precios_viejos_y_la_cache_de_antes_se_vuelven_a_pedir, sin_red_se_cuenta_con_la_cache, mas_de_setenta_y_cinco_cartas_van_en_varios_lotes, una_impresion_sin_precio_usa_el_de_la_inglesa, fichas_propias_y_huecos, las_cartas_marcadas_no_cuentan_para_el_mazo, un_deck_vacio_no_pide_nada).
  • Frontend: src/features/stats/__tests__/textos.test.ts.

Where to start when extending

  • New statistic: add the field to DeckStats (with TS derive), compute it in stats() with a test, run pnpm bindings, add the UI in the right tab and the strings (Spanish source in t(), translations in src/i18n/<code>/ of every language).
  • New function category: add the key to FUNCIONES, a pattern list to FUENTES/Patrones, a pares entry in funciones(), and an entry in FUNCIONES of textos.ts.
  • New data needed from Scryfall: add it to StatInput, fill it in entrada(), and remember that cached cards lacking it must be refetched (see the keywords.is_some() freshness marker).

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Frontend · Frontend

Reference · Referencia

Clone this wiki locally