Skip to content

Statistics

Pablo Martínez López edited this page Oct 20, 2026 · 5 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). 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(), English in src/i18n/en/).
  • 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

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally