Skip to content

Parseo de la lista del mazo

Pablo Martínez López edited this page Oct 20, 2026 · 1 revision

Parseo de la lista del mazo

English · Español

Proxy Builder convierte el texto que pega el usuario (o una URL de Archidekt) en una lista estructurada de líneas y, más tarde, convierte el mazo terminado de nuevo en texto para Moxfield, Arena, MTGO o texto plano. Ambos sentidos viven en el crate puro proxy-core (src-tauri/crates/proxy-core/src/decklist/), así que no usan red, ni Tauri, ni base de datos; la capa de Tauri solo los envuelve en comandos.

Límites: esta página cubre leer y escribir el texto de la lista. Buscar las cartas (Scryfall, idiomas, imágenes) es Integración con Scryfall; lo que ocurre con las cartas resultantes dentro del mazo es Modelo del mazo.

Ficheros

Fichero Responsabilidad
src-tauri/crates/proxy-core/src/decklist/mod.rs parse(), ParsedDecklist, ParsedLine, Section, LineKind, LineError; la gramática.
src-tauri/crates/proxy-core/src/decklist/exportar.rs exportar() y FormatoLista: mazo -> texto para Moxfield / Arena / MTGO / texto plano.
src-tauri/src/commands/decklist.rs Comandos de Tauri parse_decklist, deck_list_text, deck_list_save.
src-tauri/src/archidekt.rs id_de_url(), desde_json(): importación de un mazo público de Archidekt por URL.
src-tauri/src/commands/cards.rs Comando import_archidekt (hace la llamada HTTP y luego llama a archidekt::desde_json).
src/features/decklist/editor-lista.tsx El cuadro de texto de la lista: llama a parse_decklist 250 ms después de la última pulsación y marca las líneas no válidas.
src/features/decklist/acciones.ts cargarLista(): parse_decklist y luego resolve_decklist.
src/features/export/menu-exportar.tsx El menú «...» junto a «Exportar PDF»: copiar/guardar la lista.

El parser nunca adivina en silencio: cada línea de entrada vuelve como una ParsedLine con un kind, y una línea que no se puede entender lleva un código LineError para que la interfaz pueda marcarla. La interfaz traduce el código; Rust nunca escribe prosa (véase IPC y manejo de errores).

Modelo de datos

Todos los tipos derivan TS con #[ts(export)] y pnpm bindings los genera en src/bindings/.

ParsedDecklist:

Campo Significado
lines: Vec<ParsedLine> Una entrada por línea de entrada (incluidas las líneas en blanco y los comentarios), en orden.
card_count: u32 Suma de qty de las líneas que son Card e included.
invalid_count: u32 Número de líneas Invalid.

ParsedLine:

Campo Significado
line_no Número de línea, desde 1.
raw El texto original, intacto.
kind LineKind: Card, Section, Comment, Empty, Invalid.
qty, name Copias y nombre limpio de la carta (solo tiene sentido para Card).
set, collector Código de set en minúsculas (cmm) y número de coleccionista, si la línea los traía.
foil true si la línea llevaba una marca de foil/etched/comandante (véase «Marcas»).
section Section: Main, Commander, Sideboard, Companion, Maybeboard, Tokens.
included false para el Maybeboard y para las categorías de Archidekt marcadas {noDeck}. Solo se resuelven las líneas incluidas.
error LineError: ZeroQuantity, BadQuantity, TooMany (> 999 copias, MAX_COPIAS), EmptyName.

Section::included_by_default() es true para todo salvo Maybeboard.

Formatos aceptados

La cabecera del módulo documenta la intención: aceptar lo que acepta el sitio de referencia, más las exportaciones de los sitios habituales. En concreto (todo cubierto por formatos_de_arena_moxfield_y_archidekt y sus vecinas en los tests de mod.rs):

Origen Línea de ejemplo
Texto plano Sol Ring, 2x Sol Ring, 3 x Island, 2X Counterspell
Con impresión 1 Sol Ring (CMM) 410, Bird (teoc) (solo set), 1 Llanowar Elves (PLST) DOM-168, 1 Sol Ring (SLD) 2063★
MTG Arena 4 Lightning Bolt (M10) 146, bloque de cabecera About / Name My deck, cabeceras Deck / Sideboard / Commander, prefijo antiguo SB: 2 Duress
Moxfield 1 Fire // Ice (MH2) 290 *F*, *E*, *CMDR*, cabeceras con recuentos como Commander (1)
Archidekt 1x Sol Ring (cmm) 410 [Commander{top}] ^Have,#37d67a^
Doble cara / partidas 1 Delver of Secrets // Insectile Aberration (ISD) 51, 1 Who // What // When // Where // Why

Gramática, paso a paso

parse(texto) recorre texto.lines() una sola vez, arrastrando dos datos de estado entre líneas: la Section actual (empieza en Main) y un indicador en_about (dentro de un bloque «About» de Arena). Cada línea pasa por linea():

  1. Línea en blanco: kind = Empty, reinicia en_about. No cambia la sección.
  2. Comentario o cabecera: si la línea recortada empieza por // o #, se quitan esos caracteres y lo que queda se prueba como cabecera; si no, se prueba la línea entera. Si es una cabecera (seccion_de_cabecera), la línea pasa a ser Section y cambia la sección actual; si empezaba por ///# pero no es una cabecera, es un Comment y la sección no cambia.
    • La comparación de cabeceras pasa a minúsculas, recorta un : final y descarta un recuento (n) final (Commander (1), Sideboard (15)).
    • Palabras reconocidas: deck, main, mainboard, main deck, maindeck, creatures, lands, spells, about -> Main; commander(s) -> Commander; sideboard, side, sb -> Sideboard; companion(s) -> Companion; maybeboard, maybe, considering -> Maybeboard; tokens, token -> Tokens.
    • Por qué también se aceptan cabeceras como comentarios: algunos sitios pegan la cabecera como comentario al copiar una lista como texto (#SIDEBOARD, // Sideboard (15)); un test (cabeceras_escritas_como_comentario) lo fija.
  3. Bloque About de Arena: tras una cabecera About, una línea que empiece por Name es un Comment (el nombre del mazo, no una carta) hasta la siguiente línea en blanco.
  4. Prefijo SB:: SB: (sin distinguir mayúsculas) pone solo esa línea en Sideboard sin cambiar la sección en curso.
  5. Sufijos de Archidekt (quitar_archidekt): se descarta un bloque de etiquetas ^...^ al final y luego se lee una categoría [...] al final. Una categoría que contenga {nodeck} o empiece por maybeboard pone included = false; si empieza por maybeboard además pone la sección en Maybeboard; si empieza por commander o sideboard fija esa sección para la línea.
  6. Marcas: *F*, *E*, *CMDR*, *Foil*, *Etched* (sin distinguir mayúsculas, en cualquier posición) se eliminan y ponen foil = true. Rareza: *CMDR* también pone foil (la misma regex las trata todas); por ahora nada posterior lee ParsedLine.foil (comprobado: ningún consumidor fuera de decklist/mod.rs).
  7. Cantidad (RE_CANTIDAD: dígitos, x/X opcional, luego espacio en blanco y el resto). Sin número inicial, la cantidad es 1. Una línea que parece una cantidad errónea (RE_CANTIDAD_MAL: x3 ..., 3xx ..., un 4 o 4x solos) pasa a ser Invalid con BadQuantity, o EmptyName cuando solo son dígitos/x. Cantidad 0 -> ZeroQuantity; por encima de 999 -> TooMany.
  8. Set y número (RE_SET): name (SET) [number], donde SET son 2-8 caracteres alfanuméricos entre paréntesis. El número es el siguiente token que no es un espacio y puede ser cualquier cosa (410, 2063★, DOM-168, 254p, 38a). Los paréntesis con espacios dentro (por ejemplo, un nombre de carta con un paréntesis) no coinciden y siguen formando parte del nombre.
  9. Limpieza del nombre (limpiar_nombre): las comillas tipográficas pasan a ASCII (', ") y las secuencias de espacios en blanco se colapsan. Un nombre vacío es Invalid/EmptyName.
  10. La línea que sobrevive es una Card con qty, name, set, collector, section e included (a partir del valor por defecto de la sección, posiblemente modificado en el paso 5).

Casos límite y trampas conocidas

  • Cualquier línea entera igual a una palabra de cabecera es una cabecera, incluso sin // ni #. Una carta que se llame literalmente como una palabra de cabecera, sola en su línea, sería interpretada como un cambio de sección.
  • Deducido de la regex: una carta cuyo nombre empieza por un número y un espacio (p. ej. 1996 World Champion) se lee como cantidad 1996 y se rechaza con TooMany; los nombres al estilo 7 Wonders pierden su número. Escribe 1 1996 World Champion en su lugar.
  • 1Island (sin espacio) no es una cantidad: se lee como una carta llamada 1Island con cantidad 1.
  • Los comentarios nunca cambian la sección; las líneas en blanco tampoco (solo un bloque About termina en una línea en blanco).
  • Los finales de línea de Windows los gestiona str::lines(); raw conserva el texto de la línea sin el terminador.
  • El parser es deliberadamente permisivo con la existencia de las cartas: solo comprueba la sintaxis. Un nombre mal escrito se parsea bien y falla después como Unresolved en el resolvedor.

Adónde van las secciones: banquillo, opcional

La Section de una línea es el único sitio donde, en el momento del parseo, se sabe que «esta carta pertenece al banquillo». resolve.rs la copia a las cartas creadas como una marca (DeckCard.marca, tipo MarcaCarta en src-tauri/crates/proxy-core/src/model.rs):

Sección de la línea Marca en las copias resueltas Efecto
Sideboard MarcaCarta::Banquillo Se imprime, pero queda fuera del tamaño del mazo, de las copias y de las estadísticas; se exporta en el banquillo.
Maybeboard MarcaCarta::Opcional Se imprime, queda fuera de los recuentos.
cualquier otra ninguna Cuenta con normalidad.

proxy_core::model::cuentan() es la única función que decide qué cuenta (también gestiona Variante; una variante de diseño cuenta solo si no existe una copia sin marcar del mismo nombre). Detalles: Modelo del mazo y Estadísticas.

Nota: resolve() solo procesa las líneas con kind == Card && included. A través de parse(), una línea del Maybeboard tiene included = false, así que la rama Opcional de resolve.rs solo se alcanza si quien llama pone included = true en una línea así; la interfaz no tiene hoy ningún interruptor para eso (ningún código del frontend lee included). El importador de Archidekt descarta por la misma razón todo lo que el sitio marca fuera del mazo.

Exportar la lista

exportar(deck: &Deck, formato: FormatoLista) -> String construye el texto a partir de las cartas del mazo, no del texto pegado. FormatoLista: Moxfield, Arena, Mtgo, Texto.

Cómo se construye un conjunto de líneas (juntar):

  1. Las secciones no se guardan en las cartas. Se recuperan volviendo a parsear deck.decklist_text (secciones_de) y buscando cada carta por su nombre en minúsculas; una carta de doble cara también coincide por su cara frontal (seccion_de). Una carta ausente del texto pasa por defecto a Main.
  2. Las cartas se filtran por las marcas: cuentan(&deck.cards) elimina las copias opcionales y las variantes sobrantes; las cartas marcadas Banquillo se fuerzan a Sideboard independientemente del texto.
  3. Se omiten las fichas, los huecos, las secciones Maybeboard/Tokens y las cartas sin nombre (va_en_la_lista). Una carta Custom vinculada a una fuente externa (ficha de Cockatrice) es una ficha; las demás cartas propias solo se omiten si su línea de tipo dice ficha/emblema (token/emblem).
  4. Las copias de la misma impresión en la misma sección se fusionan en una línea con un recuento (coincidencia por sección + nombre + set + número de coleccionista). Las impresiones distintas quedan en líneas distintas.
  5. Para las cartas de doble cara, delante es el nombre de la cara frontal (lo que quieren Arena y MTGO); Moxfield y el texto plano conservan el nombre completo A // B.

Salida por formato (fijada por los tests para_moxfield, para_arena, para_mtgo_el_comandante_va_en_el_banquillo, en_texto_plano):

Formato Disposición
Moxfield Comandante + compañero + principal como un solo bloque de N Name (SET) number, luego una línea en blanco, SIDEBOARD: y el banquillo.
Arena Bloques con cabecera Commander, Companion, Deck, Sideboard (los bloques vacíos se omiten), nombres de la cara frontal, (SET) number.
Mtgo N Name con el mazo principal primero, luego una línea en blanco y después comandante/compañero/banquillo juntos (MTGO no tiene casilla de comandante).
Texto N Name, comandante + principal juntos, una línea en blanco y luego el banquillo.

Los códigos de set se pasan a mayúsculas al exportar. Un set sin número imprime solo (SET).

Comandos

Comando Firma Notas
parse_decklist (text: String) -> ParsedDecklist Síncrono y barato; se llama mientras se escribe (con espera de 250 ms en editor-lista.tsx) y de nuevo desde cargarLista().
deck_list_text (deck: Deck, format: FormatoLista) -> String Lo usan las entradas de «copiar la lista» de menu-exportar.tsx.
deck_list_save (deck, format, path) -> () La ruta debe ser absoluta y terminar en .txt (si no, InvalidInput); se escribe con escribir_atomico. La interfaz lo usa para «Descargar para MTGO (.txt)».
import_archidekt (url: String, include_sideboard: bool) -> ArchidektImport { text, list } Véase más abajo.

Importación desde Archidekt

sequenceDiagram
    participant UI as decklist-panel.tsx
    participant C as import_archidekt (cards.rs)
    participant A as archidekt.rs
    participant W as archidekt.com
    UI->>C: url, includeSideboard
    C->>A: id_de_url(url)
    A-->>C: id del mazo (o None -> InvalidInput bad_url)
    C->>W: GET /api/decks/{id}/
    W-->>C: JSON (404/403 -> NotFound)
    C->>A: desde_json(json, include_sideboard)
    A-->>C: Importada { texto, lista, ids }
    C-->>UI: ArchidektImport { text, list }
    UI->>UI: poner el texto en el cuadro de la lista (sin entrada en el historial)
Loading
  • id_de_url acepta archidekt.com/decks/<id>/..., con www., con o sin esquema, y .../api/decks/<id>/. Cualquier otra cosa devuelve None, que el comando notifica como InvalidInput con motivo = bad_url.
  • La llamada HTTP se hace con un reqwest::Client normal (user agent ProxyBuilder/<version>), no a través de fuentes::Externo (véase Fuentes externas); los estados 404/403 se convierten en NotFound y cualquier otro no 2xx en Network.
  • desde_json lee solo los campos que necesita (categories[].includedInDeck, cards[].quantity/categories/card.uid/collectorNumber/edition.editioncode/oracleCard.name). Una carta se omite si todas sus categorías están excluidas del mazo, si está en la categoría Sideboard e include_sideboard es falso, o si la cantidad es 0. Las categorías Commander y Sideboard fijan la sección de la línea.
  • Produce un texto de líneas "{qty} {name} ({set}) {number}", lo vuelve a parsear con decklist::parse (para que «lo que ves es lo que se resuelve») y luego restaura las secciones que conocía (el texto en sí no lleva cabeceras). También construye ids: HashMap<line_no, uid> con el id exacto de Scryfall de cada impresión, pero import_archidekt devuelve solo text y list; los ids no se envían al frontend. La impresión exacta se conserva igualmente porque la línea generada incluye el código de set y el número de coleccionista, que el resolvedor busca como un identificador SetNumber.
  • Como las cabeceras de sección no están en el texto devuelto, cargar el texto por la vía normal (cargarLista -> parse_decklist) pone todo en Main; las cartas del banquillo importadas de Archidekt solo acaban marcadas como Banquillo si el texto del usuario tiene una cabecera Sideboard. Deducido de desde_json (el texto no tiene cabeceras) y de cargarLista (vuelve a parsear el cuadro de texto).
  • El fixture src-tauri/src/fixtures/archidekt-1.json es un mazo real que usa el_deck_real_de_archidekt_se_convierte_en_lista.

Modos de fallo

Situación Resultado
Línea ilegible LineKind::Invalid + LineError; se muestra en el editor; cuenta en invalid_count. No detiene la carga.
cardCount == 0 cargarLista() muestra «La lista no tiene cartas» y no hace nada.
Ruta incorrecta en deck_list_save AppError { code: InvalidInput, params: { detalle } }.
URL de Archidekt no reconocida / mazo privado o inexistente / red InvalidInput(motivo=bad_url) / NotFound / Network.
JSON de Archidekt mal formado InvalidInput con detalle.

Tests que lo protegen

  • src-tauri/crates/proxy-core/src/decklist/mod.rs (mod tests): formatos, marcas, secciones, comentarios como cabeceras, bloque About, SB:, líneas no válidas, numeración de líneas.
  • src-tauri/crates/proxy-core/src/decklist/exportar.rs (mod tests): los cuatro formatos, fusión, marcas, impresiones distintas.
  • src-tauri/src/archidekt.rs (mod tests): formas de URL, fixture de un mazo real, JSON roto.
  • src/features/decklist/__tests__/acciones.test.ts: el flujo de cargarLista con el lado de Rust simulado.

Por dónde empezar para ampliarlo

  • Nueva sintaxis de lista: añade una regex/rama en linea() y una fila de caso en el test de tabla grande de mod.rs; deja raw intacto y devuelve un Invalid con un LineError en lugar de descartar líneas. Las variantes de error nuevas necesitan una traducción en el frontend (useLang().mensaje).
  • Nuevo destino de exportación: añade una variante de FormatoLista y un brazo de match en exportar(); añade la entrada de menú en src/features/export/menu-exportar.tsx.
  • Nuevo sitio de importación: sigue archidekt.rs (una función pura JSON -> ParsedDecklist, probada contra un fixture) y añade la llamada de red en un comando; si descarga algo más que JSON, pásalo por fuentes::Externo.

Véase también

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