Skip to content

Motor de la mesa

Pablo Martínez López edited this page Oct 28, 2026 · 15 revisions

Motor de la mesa

English · Español

La mesa de juego (solitario, sparring y partidas en línea) funciona sobre un motor puro y determinista: el crate mesa. La pantalla y la sesión de la app que lo rodean se describen en Mesa de juego. Lleva la cuenta de dónde está cada carta y qué se le ha hecho, apunta cada acción como un evento, decide el azar y le dice a cada jugador qué puede ver. No es un juez: no hay reglas de Magic (ni maná, ni fases, ni objetivos legales, ni combate). Solo comprueba que la acción tenga sentido (la carta existe, el actor puede tocarla) y ofrece ayudas que se pueden apagar.

Código: src-tauri/crates/mesa/src/ (lib.rs, ids.rs, error.rs, modelo.rs, formato.rs, preparar.rs, lista.rs, accion.rs, azar.rs, resolver.rs, aplicar.rs, visibilidad.rs, registro.rs, resumen.rs, huella.rs, vista.rs, muerte.rs, ausencia.rs, encuentro.rs, sparring/{mod,reacciones,amenaza,cartas}.rs, assets/sparring/cartas.json) y sus tests en src-tauri/crates/mesa/tests/.

Por qué un crate aparte

Decisión: el motor es su propio miembro del workspace (members = [".", "crates/proxy-core", "crates/mesa"] en src-tauri/Cargo.toml) y depende de proxy-core solo para leer los decks. Imprimir y jugar son mundos distintos; un crate aparte compila y se prueba solo (cargo test -p mesa) y proxy-core no crece. Como proxy-core, no tiene Tauri, ni red, ni base de datos, ni reloj, ni azar de fuera (tests/registro.rs::huella_estable_y_sensible busca en el código SystemTime, Instant:: y rand::). Se compila con opt-level = 2 también en desarrollo ([profile.dev.package.mesa]) porque cada acción copia y compara la partida entera y los tests de propiedad juegan miles.

Un solo registro de eventos sirve para el solitario, la red, deshacer y la repetición:

flowchart LR
    I["Intencion { actor, accion }"] -->|"resolver(&Partida, &Intencion, &mut Azar, ahora)"| E["Evento { seq, actor, t_ms, cambio }"]
    E -->|"aplicar(&mut Partida, &Evento)"| P["Partida"]
    P -->|"vista(&Partida, Mirador)"| V["VistaMesa"]
    E -->|"Tocado"| PA["parche(&Partida, &Tocado, Mirador)"]
    PA --> PV["ParcheVista"]
    E --> R["Registro (eventos, foto cada 50)"]
Loading
  • Intencion es lo que pide un jugador (Accion).
  • Evento es lo que resolvió el host (Cambio). Es lo que se guarda, se manda a los demás y se repite.
  • Actor es Jugador(id) o Mesa (lo que genera el host por su cuenta: la reacción del sparring).

El modelo (modelo.rs)

Partida guarda todo lo que hay en la mesa: id, formato, variantes, opciones, jugadores (orden de asientos = orden de turnos), equipos, cartas: BTreeMap<IdCarta, Carta>, zonas: BTreeMap<RefZona, Vec<IdCarta>>, turno, flechas, bolitas, designaciones, pausada, fantasma, sparring, mulligan, seq, siguiente_id y fin.

Invariantes y su porqué:

  • Solo BTreeMap, BTreeSet y Vec, nunca HashMap: el JSON de una partida sale siempre idéntico y su huella se puede comparar entre equipos.
  • Las posiciones son enteros: Pos { x: u16, y: u16 } de 0 a POS_MAX (10.000) dentro del tablero del controlador de la carta. Igualdad exacta y sin diferencias de coma flotante entre equipos (decisión D-A3). La pantalla solo las convierte a píxeles.
  • zonas se guarda como lista de pares (zonas_como_lista) porque un RefZona no puede ser clave de un objeto JSON.
  • Orden de las zonas: en la biblioteca (y en los mazos de planos y planes) el índice 0 es la de arriba; en las demás (cementerio, pila…) la de arriba es la última (aplicar.rs::arriba_es_inicio).
  • Los ids son números pequeños (IdJugador(u8), IdCarta(u32), IdEquipo(u8)). Las fichas y las copias reciben uno nuevo de siguiente_id; un id nunca se reutiliza.

RefZona es De { jugador, zona } (Biblioteca, Mano, Campo, Cementerio, Exilio, Mando, Banquillo, Planes, PlanesEnMarcha) o Comun { zona } (Pila, Planos, PlanoActivo).

Una Carta lleva su estado en la mesa: cara, girada, boca_abajo, fase_fuera, no_endereza, contadores, palabras_clave (contadores de palabra clave), etiquetas (≤ 4, ≤ 20 caracteres), pt (las tres filas del diálogo de fuerza/resistencia: otros, hasta_fin_de_turno, fijo), adjunta_a, pos, z, vista_por, resaltada, es_ficha, copia_de, cantidad (un montón de fichas iguales es una sola carta con cantidad > 1), agrupada_bajo (un montón de cartas de verdad, o lo exiliado agrupado bajo la carta que lo exilió), llego_en_ronda (el separador «Antes» del cementerio y el exilio) y habilidad (una habilidad en la pila: desaparece al salir de ella).

OrigenCarta es lo que la app sabe de una carta al empezar: el id de la DeckCard, el nombre, una RefImagen por cara, el dorso, el id de Scryfall y una InfoCarta (tipo, cmc ×10 para no usar coma flotante, coste, colores, tierra/criatura/legendaria, fuerza, resistencia, lealtad, palabras clave, funciones, bono fijo de auras y equipos, si es comandante). RefImagen es Scryfall { id, cara, url } (los demás la bajan de cards.scryfall.io), Propia { sha256, ancho, alto } (la imagen final preparada por la app) o Generica (el reverso de la app).

formato.rs tiene Formato (Commander, Constructed, Limited, Libre), Variantes (ModoEquipos::{Ninguno, Equipos { fuego_amigo }, DosCabezas}, archenemy, planechase) y Opciones con Opciones::de_fabrica(formato, variantes, jugadores): Commander 40 vidas y mulligan Londres con el primero gratis, el resto 20 y Londres; Two-Headed Giant 30 por equipo; el archienemigo 40 (VIDA_ARCHIENEMIGO); primero_no_roba encendido con 1–2 jugadores y apagado con 3 o más (como Playgroup); todas las Ayudas encendidas; todos_tocan_todo y campo_ajeno_protegido apagados (el segundo lleva #[serde(default)], así las partidas guardadas antes lo traen apagado).

Del deck de la app a la mesa (preparar.rs)

secciones_de_mesa(&Deck, Option<&ListaDeJuego>) decide dónde empieza cada carta, con la lista escrita (proxy_core::decklist::{secciones_de, seccion_de}, ahora públicas) y las marcas:

  • fichas (CardSource::Token, una propia de otra fuente, una línea de tipo de ficha, una carta propia cuyo diseño es una ficha (card.layout token o una línea de tipo con «Token»), una mazmorra (línea de tipo «Dungeon»), o la sección Tokens) → Seccion::Ficha (fichas que se pueden crear, no cartas de la partida): no cuentan para el tamaño del mazo ni para el límite de la lista para jugar y no se ofrecen al elegir una lista (candidatas_de_lista). En iniciar, las fichas que se ofrecen a cada jugador (Jugador.fichas) se deduplican por nombre e imagen, así que las copias iguales salen una vez y las variantes de una ficha (mismo nombre, otra imagen) salen cada una;
  • CardMeta.layout o el card.layout del diseño planar/phenomenon → Plano; scheme → Plan;
  • MarcaCarta::Opcional y la Maybeboard → fuera; MarcaCarta::Banquillo y Sideboard → Banquillo; de las variantes de diseño, una (la misma regla que model::cuentan);
  • Commander → Comandante, Companion → Companero, el resto → Mazo; los huecos en blanco, fuera.

Con una lista para jugar (más abajo), las fichas, los planos y los planes se deciden como arriba, pero de las demás cartas solo van las elegidas: sus comandantes → Comandante, sus cartas → Mazo (o Companero si la lista escrita la tiene en la sección Companion: un compañero elegido sigue empezando aparte), una copia no elegida marcada como banquillo → Banquillo, y todo lo demás se queda fuera (para ellas ya no cuentan ni las marcas ni las secciones de la lista escrita). candidatas_de_lista(&Deck) da las copias que una lista puede elegir: todas menos los huecos en blanco, las fichas, los planos y los planes, marcadas o no.

Seccion vive en mesa (no en proxy-core) porque mesa depende de proxy-core; al revés sería circular.

Listas para jugar (lista.rs)

Un deck puede tener más cartas de las que se juegan (variantes de diseño de una carta, alternativas). Una lista elige las copias que van a la partida; el deck no cambia. Aquí solo el dato y su comprobación, puros; la app junta los datos de cada carta y guarda las listas (Mesa de juego).

  • ListaDeJuego { id, nombre, comandantes, cartas }: ids de copias del deck (DeckCard.id), así se juega la variante de diseño elegida. comandantes lleva uno, o dos con compañero o trasfondo; vacío fuera de Commander.
  • CartaDeLista: lo que hace falta de una copia para elegirla y comprobarla: el nombre en inglés (las copias se cuentan por nombre, así las variantes de diseño son la misma carta), el tipo, la identidad de color, puede_ser_comandante, con_pareja, sin_limite, la huella de la imagen del frente y la marca.
  • Construccion::de(formato): Commander, exactamente 100 y 1 copia, con comandante; Constructed, al menos 60 y 4 copias; Limited, al menos 40 y las copias que se quiera; Libre no tiene reglas (None). pide_lista(n): cuentan más cartas que el máximo (solo Commander tiene).
  • comprobar(cartas, lista, formato) -> Vec<AvisoLista>: YaNoEstan (ids que ya no están en el deck, se ignoran), Tamano { hay, desde, hasta }, SinComandante, DemasiadosComandantes, NoPuedeSerComandante, SinPareja, Repetida { nombre, veces } (sin contar las cartas sin límite) y FueraDeIdentidad. AvisoLista::impide() solo es verdadero con el tamaño, la falta de comandante, demasiados comandantes y las copias repetidas. Decisión: la identidad de color, si la carta puede ser comandante y la pareja solo avisan, porque los datos pueden faltar (una carta propia sin vincular a Scryfall) y en una mesa de cocina se juega como se quiera.
  • comandante_por_texto(tipo, oracle, keywords): puede ir de comandante una criatura legendaria, un trasfondo o «can be your commander»; puede ir en pareja con las palabras clave de compañero, «Friends forever», «Choose a Background», un trasfondo o «Doctor's companion». sin_limite_por_texto: «A deck can have any number of cards named…» o «up to». La app añade por el tipo las tierras básicas.
  • AvisoLista va etiquetado con "tipo"; la pantalla escribe cada uno con t().

iniciar(id, Config, semilla, ahora_ms) reparte la partida para 1–10 jugadores (MAX_JUGADORES): da los ids, pone comandantes y compañeros en el mando (y marca es_comandante), el banquillo aparte, los planos de todos en un mazo común barajado (con el primero boca arriba si hay Planechase), los planes en el mazo de su jugador, baraja cada biblioteca con la semilla, forma los equipos por parejas de asientos en Two-Headed Giant, crea la vida del fantasma en solitario y roba la mano inicial de cada uno. Todos empiezan «decidiendo» el mulligan.

El catálogo de acciones (accion.rs)

Cada acción es un dato. El comentario del módulo tiene una tabla con cada Accion y la captura o sección del inventario de Playgroup de donde sale. Grupos: mover (Mover con un Destino { zona, jugador, sitio }; Sitio = Arriba, Abajo, Indice, XDesdeArriba, EnCampo(Pos), AlAzar, BarajarDentro; ComoMover::{Lanzar, Poner} para el comandante), estado de carta, biblioteca (robar, moler, manifestar, exiliar de arriba, mirar + ordenar para escrutar y vigilar, revelar, excavar + resolver la excavación con el Filtro de los presets, buscar, la de arriba revelada, barajar, rueda), campo entero (enderezar todo, organizar, crear ficha), mulligan, jugadores (vida, drenar, extorsión, veneno, daño de comandante, contadores, designaciones, veces lanzado, revelar), turno, azar (dados, monedas con pulgares de Krark, dado planar), Planechase/Archenemy, pila (Resolver, ApilarDespues), montones, agrupar, acciones de todo el cementerio o exilio, sparring, chat y avisos rápidos (Chat, Aviso: pura comunicación, es_comunicacion; con la partida terminada son lo único que se acepta), fin de partida, deshacer y rehacer. Las señales de prioridad (Senal) van en el grupo de jugadores y para el motor no son comunicación: se pueden deshacer y cuentan como juego.

Serialización: Accion lleva la etiqueta "que" (no "tipo", porque varias variantes tienen un campo llamado tipo), Cambio la etiqueta "tipo", los dos con los campos en camelCase.

🛑 Compatibilidad: un enum con etiqueta de serde rechaza una etiqueta que no conoce, así que una app más nueva rompería a una más vieja. VERSION_CATALOGO sube con cada variante nueva o cambiada; las variantes nunca se cambian ni se quitan. El saludo de la red (fase E) y las repeticiones guardadas (fase H) la llevan. tests/catalogo/v<N>.json guarda eventos escritos con cada versión del catálogo y tests/catalogo.rs comprueba que se siguen leyendo; se escribe una vez (PB_CATALOGO_ESCRIBIR=1 cargo test -p mesa --test catalogo) y no se toca nunca más.

La versión 2 del catálogo añadió TomarControl { cartas, hasta_fin_de_turno } (tests/catalogo/v2.json, escrito con la variante nueva añadida a mano por si el azar no la genera; v1.json no se toca y tiene que seguir leyéndose).

Azar (azar.rs)

Azar es xoshiro256** sembrado con SplitMix64, escrito en el crate (unas 40 líneas) y probado con los vectores de referencia publicados de los dos. Se eligió frente al crate rand (decisión D-A4) porque lo que importa es que dé lo mismo en todos los equipos y versiones. Los enteros sin sesgo van por rechazo (menor_que), los barajados por Fisher–Yates.

La entropía la pone la app (la semilla de la sesión); el motor nunca saca azar por su cuenta. Cómo viajan los resultados:

  • Dados, monedas y dado planar van resueltos dentro del evento (Cambio::Dados { tiradas, semilla_animacion }, Cambio::Monedas, Cambio::DadoPlanar { cara, coste }), porque la pantalla los enseña.
  • El resto de acciones que barajan o eligen al azar llevan una semilla nueva en Cambio::Accion { accion, azar }; aplicar saca todo de ella en un orden fijo. Decisión: una semilla por evento en vez de los órdenes resultantes, así el host y los invitados nunca tienen que coincidir en longitudes intermedias de las zonas (una ficha barajada en una biblioteca desaparece, por ejemplo). El mismo evento da lo mismo en cualquier equipo. resolver::necesita_azar dice qué acciones llevan semilla.

Resolver y aplicar

resolver(&Partida, &Intencion, &mut Azar, ahora_ms) mira los permisos y decide el azar. Permisos de fábrica (la pregunta Q-A1, con el valor que propone el plan): cada jugador toca lo que controla en el campo y la pila y lo suyo en sus zonas; cualquiera toca la vida de cualquiera, las flechas, las señales y el chat; solo el jugador activo pasa el turno (Rechazo::FueraDeTurno); mirar o buscar en la biblioteca de otro se rechaza. Opciones::todos_tocan_todo lo abre todo, como en la mesa de la cocina. Opciones::campo_ajeno_protegido hace lo contrario en el campo: puede_tocar rechaza (Rechazo::SinPermiso) cualquier carta de ahí que el actor no controle, sea de quien sea. La carta de otro se coge con TomarControl (todos pueden, queda en el registro) y, ya cogida, tampoco la toca su dueño hasta que la recupere. Cementerio, exilio y pila siguen abiertos (reanimar del cementerio ajeno, exiliarlo, contrarrestar un hechizo). La partida local y el solitario lo dejan apagado; la sala de espera en red lo enciende de fábrica (tests/campo_protegido.rs). Deshacer, Rehacer y Reiniciar se rechazan aquí: los hace la sesión.

aplicar(&mut Partida, &Evento) -> Result<Tocado, Rechazo> es total: en una partida en red un invitado aplica lo que le manda el host, y eso también es entrada de fuera. Comprueba que el seq siga, trabaja sobre una copia y solo reemplaza la partida si todo fue bien, así un Err la deja intacta (el invitado pide entonces una instantánea nueva). Nada se indexa con []; cada carta, jugador y zona nombrados se comprueban; los números tienen tope (MAX_CANTIDAD, MAX_FICHAS_SUELTAS, MAX_CHAT, MAX_ETIQUETAS, MAX_FLECHAS, MAX_BOLITAS). Tocado (cartas, zonas, jugadores y estado «general» tocados) sale de comparar la partida vieja con la nueva; con él se hacen los parches de la pantalla.

Reglas en cada movimiento (aplicar.rs::mover):

  • Al salir del campo una carta pierde girada, contadores, palabras clave, etiquetas, filas de F/R, adjunto, flechas y su controlador; lo que tenía adjunto o amontonado debajo se queda suelto donde estaba.
  • Las fichas y las habilidades de la pila solo existen en el campo y en la pila: en cualquier otra zona salen de la partida.
  • Al cambiar de zona se reinician vista_por y boca abajo; la mano, la biblioteca, el cementerio, el exilio, el mando y el banquillo son siempre del dueño (una carta robada mandada «a su mano» va a la mano de su dueño); solo el campo puede ser de otro jugador.
  • Con contadores_al_entrar, un planeswalker entra con su lealtad y una saga con un contador de lore.
  • Con impuesto_automatico, ComoMover::Lanzar desde el mando suma uno a las veces lanzado del comandante; Poner no.
  • Una carta que entra al campo sin posición ocupa el primer hueco libre de su fila (pos_libre: 11 huecos separados 900 desde x = 600, así una carta de un 8 % de ancho en una pantalla ancha nunca se corta en el borde; si están todos ocupados, va encima de la última, algo movida). Las cartas robadas van al final de la mano (su «arriba»).
  • Pasar el turno borra la F/R «hasta fin de turno», las flechas y los contadores y contadores de palabra clave temporales (los que tienen un tipo que empieza por ~, modelo::PREFIJO_TEMPORAL: es una convención del tipo, que es texto libre, así que no hay acción nueva y VERSION_CATALOGO no cambia); los turnos extra van primero; la ronda sube al dar la vuelta a la mesa; con las ayudas puestas, el nuevo jugador activo endereza lo que controla (salvo no_endereza) y roba, salvo en el primer turno de todos con primero_no_roba.

Control, regalos y robos. La regla es que un jugador puede hacer lo que quiera, como en una mesa de verdad: lo que está a la vista (campo, cementerio, exilio, mando, pila) es de todos, y solo se guarda lo oculto (resolver::puede_tocar): la mano, la biblioteca y el banquillo de otro no se tocan mientras la carta no se vea (mirada, enseñada). Así cualquiera puede girar, poner contadores, exiliar, destruir o devolver un permanente de otro, contrarrestar su hechizo o reanimar de su cementerio; lo que se mande a una mano, biblioteca o cementerio va al de su dueño; y una copia ficha es de quien la hace. DarControl (dar) y TomarControl (tomar; también hasta_fin_de_turno, para Threaten y similares) cambian quién controla un permanente: aplicar::pasar_a_campo_de lo lleva al campo del nuevo controlador con lo que lleva encima: sus contadores y contadores de palabra clave (no sale nunca del campo, así que no se borra nada), si está boca abajo sigue boca abajo (y solo la ve su nuevo controlador), y lo que tiene adjunto o amontonado y controlaba quien la controlaba (equipos, auras, un montón) va con ella y sigue adjunto. Lo adjunto desde el campo de otro jugador se queda donde está, adjunto. Tomar el control no pide permiso y queda en el registro; Carta.devolver_a (se serializa solo si lo hay, así las partidas anteriores conservan su huella) recuerda a quién vuelve, y pasar_turno la devuelve (si sigue en el campo y ese jugador sigue en la partida). Mirar y Buscar valen para la biblioteca de cualquier jugador (Gonti, Bribery, Praetor's Grasp; lo que se ve solo lo conoce quien mira, hasta DejarDeMirar), y MirarMano { de, si } hace lo mismo con una mano (Thoughtseize, Peek). PorJugador { jugador, accion } hace que algo pase por otro jugador, como si lo hiciera él (robar, moler, descartar al azar o toda la mano, barajar, exiliar su cementerio, enseñar su mano, enderezar, crearle una ficha: la lista permitida es Accion::se_hace_por_otro); los permisos se comprueban como ese jugador y el registro dice «… (lo hace X)». Catálogo v2 (todo en tests/catalogo/v2.json, escrito a mano lo que el azar no genera).

  • Si el jugador activo queda eliminado, el turno pasa solo; durante el mulligan, un jugador eliminado ya no impide empezar.

El mulligan (Mulligan, QuedarseCon { al_fondo }): Londres pone al fondo tantas como mulligans hechos, una menos con LondresPrimeroGratis; Amistoso no pone ninguna y no tiene límite; Libre deja elegir. Nadie empieza hasta que todos los que siguen en pie se quedan su mano; entonces empieza el turno 1 con el primer jugador y las ayudas del principio del turno.

Quién ve qué (visibilidad.rs, vista.rs)

Las reglas están en un solo sitio. Una carta se ve si lo dice su zona o si alguien se la ha enseñado (Carta::vista_por: Nadie, Dueno, Todos, Jugadores(..)):

  • biblioteca, planes y mazo de planos: nadie (con «jugar con la de arriba revelada», todos ven la de arriba);
  • mano y banquillo: el dueño (la mano, todos si la revela y, con equipos, los compañeros del dueño: ve_la_mano_de_un_companero, salvo que Variantes.mano_privada_en_equipo esté encendido; el campo lleva #[serde(default)], así que las partidas guardadas antes siguen viendo la mano de los compañeros; un espectador nunca);
  • campo, cementerio, exilio, mando, pila, plano activo y planes en marcha: todos, salvo lo boca abajo, que solo enseña vista_por (al controlador si la jugó boca abajo, a nadie si se exilió «oculta»).

Mirador es Jugador(id), Espectador o Todo (solo para repetir una partida terminada). vista(&Partida, Mirador) -> VistaMesa y parche(&Partida, &Tocado, Mirador) -> ParcheVista hacen lo que recibe la pantalla. 🛑 De una zona que el mirador no ve solo viaja el número de cartas (ni ids ni orden), así no se puede seguir una carta conocida por su id después de barajar; las que sí conoce (la de arriba revelada, las que está mirando) viajan con su sitio en visibles. Una carta boca abajo que no conoce viaja sin origen, solo con su dorso. VistaCarta::mirada le dice a la pantalla qué cartas está mirando (diálogos de escrutar y buscar).

Una mano que el mirador no puede ver la dibuja la pantalla igualmente como reversos (llega el número; el reverso es el del propio jugador, VistaJugador.dorso), así que una carta de doble cara nunca enseña su segunda cara desde fuera. tests/equipos.rs comprueba que los compañeros (y las parejas de 2HG) se ven la mano, que los rivales y los espectadores no, que la opción lo apaga y que robar llega a la vista de un compañero; el oráculo escrito a mano comun::sin_fugas tiene la misma regla.

parche vuelve a mandar cada carta tocada y cada carta de una zona tocada (y las zonas cuyas cartas conocidas cambiaron, y la mano, la biblioteca y el banquillo de los jugadores tocados, porque revelar los cambia); una carta que el mirador deja de ver va en quitadas. vista::aplicar_parche hace en Rust lo mismo que el store de la pantalla, para los tests.

Registro, deshacer, rehacer y repetición (registro.rs)

Registro { inicial, eventos, fotos, modo } guarda una foto cada CADA_FOTO (50) eventos. Lo lleva el host; un invitado que entra a media partida empieza el suyo con la instantánea que recibe.

  • Deshacer lo calcula el host: la partida tal como estaba antes del evento deshecho (en(seq - 1)) viaja entera en un Cambio::Restaurar { deshecho, partida } y quien lo recibe reemplaza su copia. Sin «el contrario» de cada acción y sin fotos viejas en los invitados (uno que entró tarde no las tiene). Una partida de 10 jugadores son unos 300 KB; deshacer es raro.
  • Rehacer (solo en solitario) vuelve a resolver las intenciones deshechas, así un barajado rehecho da otro orden, como en la mesa. Por eso se guardan las intenciones y no los eventos.
  • Permisos (ModoDeshacer): SinLimite en solitario y en partida local (también «deshacer hasta esta línea», Deshacer { hasta }); UltimaPropia en línea: solo la última acción propia y solo si nadie ha hecho nada después (el chat no cuenta).
  • vigentes() da los eventos que siguen contando, en orden: un deshacer (Restaurar { deshecho: Some(k) }) quita los eventos desde k, un reinicio (deshecho: None) quita todo lo anterior y los propios Restaurar quedan fuera. La app manda los últimos 500 con cada vista entera para que el registro de la pantalla coincida con la partida (tests/registro.rs).
  • en(seq) reconstruye cualquier momento en menos de 50 aplicaciones. Reiniciar es un Restaurar sin deshecho que vacía la pila de deshacer.

Huella (huella.rs)

huella(&Partida): SHA-256 del JSON de la partida, los 16 primeros caracteres hexadecimales. El seq no cuenta: una partida deshecha tiene que ser igual a la que nunca hizo esas acciones, aunque el contador de eventos siga subiendo.

Sparring (sparring/)

El rival fantasma del solitario, a la manera del de Playgroup: sin IA y sin reglas, tablas y un juego de cartas de interacción reales. reaccionar(antes, despues, evento, azar) -> Option<ReaccionSparring> mira la partida antes y después de cada evento del jugador y puede responderle; la sesión de la app convierte la respuesta en un Cambio::Sparring con Actor::Mesa (véase Mesa de juego), así se deshace, se guarda y se repite como todo lo demás. El jugador decide con Accion::DecidirSparring { reaccion, decision, eleccion }: Aceptar la aplica (lo contrarrestado, al cementerio, desde la pila o desde el campo si se jugó sin pasar por ella; lo eliminado o barrido, al cementerio o al exilio si la carta del fantasma exilia; lo devuelto, a la mano; en un sacrificio, la criatura que elige el jugador), TeniaRespuesta y NoAplica solo lo apuntan.

  • Ocasiones (reacciones.rs, comparando antes y despues): el jugador lanza algo (de la mano o el mando a la pila, o un permanente que no es tierra directo al campo) → contrahechizo; llega a su campo un permanente que no es tierra (al resolverse o puesto a mano) → eliminar, devolver o sacrificio (58 : 7 : 3); pasa el turno con criaturas → barrido. Las fichas y las habilidades nunca lo disparan.
  • Probabilidad = base × ritmo de la ronda × intensidad × peso, con un tope del 90 %. Bases: BASE_LLEGA 0,42, BASE_LANZA 0,072, BASE_PASA 0,052. Ritmo (RITMO, 1 = el pico): 0,15, 0,35, 0,6, 0,85, 1, 1, 0,95… desde la ronda 1; los barridos (RITMO_BARRIDO) empiezan hacia la ronda 4 y tienen el pico en la 7–8. Intensidad (factor): Apagado 0, Suave 0,45, Realista 1, Despiadado 1,8. Peso: el de la amenaza (amenaza.rs) o, en los barridos, el de cuántas criaturas hay (0–1 → 1, 2 → 1,3, 3 o más → 2,5). Como mucho una reacción por ronda (tres en Despiadado) y ninguna con otra sin decidir, durante el mulligan, tras el final o con más de un jugador.
  • Las bases se ajustaron para que la mezcla de una partida simulada sea la de Playgroup (eliminar 58 %, contrarrestar 19 %, barrer 13 %, devolver 7 %, sacrificio 3 %): tests/sparring.rs juega 500 partidas con un guion por intensidad (una tierra por ronda, los hechizos que dé el maná, pasar) y comprueba el reparto con ±5 puntos y las reacciones por partida de 10 rondas (Suave 1–3, Realista 2,5–5, Despiadado 4,5–8,5; hoy unas 1,8, 3,4 y 5,6). Son números de partida para afinar jugando, no medidos en partidas reales.
  • Peso de la amenaza (amenaza::peso; Playgroup lo mide con eliminaciones reales y aquí no hay esos datos): 0,55 + 0,15 × valor de maná (hasta 7), ×1,7 el comandante, ×1,2 los planeswalkers, ×1,1–1,4 por cada función de InfoCarta.funciones (extraTurn, tutor, draw, wipe, anthem, protection, removal, tokens, recursion), ×0,6 las fichas, como mucho 2,5. Lo que solo da maná (un ramp que no es criatura o una criatura ramp de valor de maná ≤ 2: rocas y elfos) pesa ×0,7 (Playgroup no las quita nunca; las mesas para las que se hace esta app, sí). De las tierras, solo pesan las muy fuertes (1,5): las legendarias (Gaea's Cradle, Serra's Sanctum, Tolarian Academy…) y las no legendarias de TIERRAS_FUERTES (Cabal Coffers, Field of the Dead, Maze of Ith…, por su nombre en inglés); las básicas y las normales pesan 0 y nunca son objetivo. Que llegue una tierra fuerte es una ocasión como la de cualquier permanente.
  • Límites: en la ronda N el fantasma tiene N manás, salvo las cartas gratis (Force of Will, Deadly Rollick) y las que se abaratan solas (paga, Blasphemous Act); respeta el antimaleficio y el velo (busca el siguiente objetivo por peso o se espera; los sacrificios, los barridos y las cartas noApunta como Council's Judgment no apuntan); no destruye lo indestructible (sí puede exiliarlo); un contrahechizo solo responde a los tipos que puede (responde).
  • Las cartas del fantasma (assets/sparring/cartas.json, cartas.rs, leídas una vez con include_str!; si el fichero no se pudiera leer, el fantasma se calla, nunca un pánico): 128 cartas habituales de Commander (59 eliminaciones, entre ellas Strip Mine, Wasteland, Ghost Quarter, Tectonic Edge, Field of Ruin y Boseiju para las tierras; 29 contrahechizos, 22 barridos, 9 devoluciones, 9 sacrificios), cada una con nombre, coste, valor de maná, tipo, a qué responde (criatura, artefacto, encantamiento, planeswalker, batalla, instantaneo, conjuro, permanente (que no sea tierra), noCriatura, todo, tierra, tierraNoBasica) y las marcas exilia, gratis, noApunta, monocolor, mvMin, mvMax y paga. Van por nombre (CartaFantasma.scryfall_id va vacío): la app las busca en Scryfall al enseñarlas.
  • Explicaciones: por_que (clave = el tipo de reacción, con ronda, ritmo y carta + peso o criaturas) y base (reparto, ritmo, amenaza, criaturas, intensidad, probabilidad, modelo), siempre clave + parámetros; los textos los pone la pantalla (features/mesa/sparring/textos.ts).

Equipos, orden de turnos, muertes y encuentros (fase G)

Todo esto es motor, puro y con tests; la pantalla solo lo pinta (Mesa de juego).

  • Equipos (tests/equipos.rs). Partida::oponentes(j) deja fuera a los eliminados y, con el fuego amigo apagado, a los compañeros; Drenar, Extorsion y VidaATodos lo usan. Con Equipo.vida_compartida = Some(vida), Vida suma a la vida del equipo y la vida de un jugador se lee de la del equipo (aplicar::vida). preparar::iniciar monta las parejas de Two-Headed Giant (30 vidas por pareja) y rechaza un equipo que no existe. Un equipo gana junto (Resultado.ganadores). Formatos (tests/formatos.rs): el archienemigo empieza con 40 y los demás son un equipo sin fuego amigo contra él; los planes van al mazo del archienemigo y PonerEnMarcha / AbandonarPlan los mueven; con Planechase los planos empiezan con uno activo y Planeswalk rota el mazo (se rechaza sin planos); el dado planar cuesta una más cada vez y vuelve a cero con el turno (tiradas_planar); el daño de comandante baja la vida y se cuenta por comandante.
  • Orden de turnos (aplicar.rs::siguiente_asiento, tests/turnos.rs). El orden de asientos es el de Partida.jugadores; Config.primero (None = sorteado con la semilla de la partida) dice quién empieza. Se salta a los asientos eliminados y la ronda sube al pasar por el asiento del primer jugador (esté vivo o no), no por el asiento 0. Con un solo jugador, cada turno es una ronda.
  • Quién está fuera (muerte.rs, tests/muerte.rs). fuera_de_juego(&Partida, j) -> Option<Causa> dice si un jugador aún no eliminado está a 0 vidas o menos (la vida compartida del equipo, si la hay), en limite_veneno (10) o a limite_comandante (21) de un comandante; Causa es Vida, Veneno o Comandante { de, carta } (el dueño de ese comandante), en ese orden de prioridad. Es pura y no tiene evento propio: el motor solo avisa (VistaJugador.fuera_de_juego, también en el parche de un compañero cuando cambió la vida del equipo) y una persona lo confirma con Accion::Eliminar { jugador, por }. Eliminar apunta Eliminacion { ronda, por }, pasa el turno si el eliminado era el activo y termina el mulligan si era el último que faltaba.
  • Ausencia (ausencia.rs). quedarse_por_defecto(&Partida, j) -> Option<Accion> da el QuedarseCon { al_fondo } que mandaría un jugador que aún no ha decidido quedándose con la mano que tiene (con las cartas al fondo que pide la regla del mulligan, toca_al_fondo, compartida con aplicar.rs); None si ya decidió o no es el mulligan. La app lo usa para quedarse con la mano de un ausente (Juego en línea).
  • Encuentros (encuentro.rs, tests/encuentro.rs). Encuentro { al_mejor_de, cerradas } (1, 3 o 5): el marcador se calcula de las partidas cerradas más la de ahora si ya acabó, terminado cuando alguien llega a necesarias o se agotan las partidas, primero_siguiente (empieza la siguiente quien perdió) y cambiar(mazo, banquillo, sale, entra), que comprueba antes de tocar nada que salgan tantas cartas como entran, que las que salen estén en el mazo, las que entran en el banquillo y que ninguna se repita. No es parte de la Partida: lo guarda la sesión, así Accion y Cambio no cambian (ni VERSION_CATALOGO).

El resumen de una partida (resumen.rs, fase H)

resumir(&Registro) -> ResumenPartida cuenta una partida a partir de su registro, porque el orden exacto en que cayeron los jugadores solo está ahí (Eliminacion guarda únicamente la ronda). Puesto { jugador, puesto, ronda, por }: 1 es ganar (compartido por un empate o un equipo) y los demás se ordenan poniendo mejor al último en caer; quien sigue en pie sin resultado va detrás de los ganadores. Da también las rondas, los turnos y el tiempo del primer al último evento que sigue contando (vigentes()). Es puro y no usa reloj. La app lo usa para la pantalla del final, para el diálogo «Terminar» y para el historial (Mesa de juego). Chat y Aviso son comunicación, no juego (es_comunicacion): no se pueden deshacer y no cuentan como «algo hecho después» para deshacer en red. Senal sí cuenta.

Tests

cargo test -p mesa:

  • tests/acciones.rs: cada acción del catálogo con antes → evento → después; un match exhaustivo (nombre) hace que una variante nueva no compile hasta tener su test; permisos, cartas robadas que vuelven a su dueño, salir del campo, ayudas del turno, contadores al entrar.
  • tests/propiedad.rs: 1.000 intenciones al azar para 4 jugadores dos veces con la misma semilla (misma huella, sin panic, las rechazadas no cambian nada); la visibilidad para cada mirador tras cada acción, comparada con las reglas escritas otra vez a mano (comun::sin_fugas), y los parches aplicados sobre la primera vista dan la vista entera del final; 2.000 eventos arbitrarios (actores que no existen, cartas inventadas, seq equivocado, sin semilla) nunca hacen panic y los fallos dejan la partida intacta.
  • tests/registro.rs: deshacer 20 de 300 es como no haberlas hecho, deshacer hasta una línea del registro, rehacer 10, en línea deshacer se rechaza tras una acción de otro, un invitado que entra tarde sigue al host con un Restaurar, en(n) para cada n, casos del mulligan Londres, nadie empieza hasta que todos deciden, estabilidad de la huella.
  • tests/preparar.rs: secciones de la lista escrita, marcas como cuentan, con una lista solo lo elegido (con_una_lista_solo_va_lo_elegido), un plano diseñado al mazo de planos, el reparto y la ida y vuelta en JSON.
  • Tests unitarios de lista.rs: una variante del comandante más 99 vale; de más, sin comandante, dos variantes de la misma carta cuentan como repetidas; la identidad y los demás avisos.
  • tests/resumen.rs: el último en caer queda mejor aunque caigan en la misma ronda, conceder cuenta como caer, el resultado decide los ganadores y un empate comparte el primer puesto, los compañeros de equipo comparten puesto, perder contra el rival del solitario no es ganar y una partida sin acabar.
  • tests/catalogo.rs: las versiones viejas del catálogo se siguen leyendo.
  • tests/fixtures.rs: escribe una vista de ejemplo y sus parches para el test del store de la pantalla (src/stores/__tests__/mesa-store.test.ts); se reescribe con PB_FIXTURES=1 cargo test -p mesa --test fixtures.
  • tests/sparring.rs: 500 partidas con guion por intensidad (reacciones por partida dentro de su banda y el reparto de Playgroup con ±5 puntos), cada reacción dentro de los límites del fantasma (pagable, nunca a una tierra básica o normal, respeta el antimaleficio, el objetivo donde debe estar), Apagado nunca reacciona, con la misma semilla las mismas reacciones, deshacer una eliminación resuelta devuelve la carta, y Gaea's Cradle es objetivo alguna vez y las básicas nunca. Tests unitarios en sparring/amenaza.rs (de las tierras solo pesan las fuertes; las rocas y los elfos pesan menos; el comandante y las funciones pesan más) y sparring/cartas.rs (el JSON se lee entero, sin repetidas, los sacrificios nunca apuntan).
  • tests/equipos.rs, tests/formatos.rs, tests/turnos.rs, tests/encuentro.rs, tests/muerte.rs: fuego amigo y vida compartida, los valores de fábrica y las reglas de cada formato, la regla de la ronda con asientos eliminados, el marcador y el cambio con el banquillo, y cada causa de «fuera» con su por, la vida compartida del equipo y el quedarse por defecto de un ausente.

Por dónde empezar para ampliarlo

  • Acción nueva: la variante al final de su grupo en Accion (con TS), su caso en aplicar.rs::accion_ y en resolver.rs::permisos si toca cartas, en necesita_azar si es al azar, subir VERSION_CATALOGO y escribir tests/catalogo/v<N>.json, añadirla a nombre y TODAS en tests/acciones.rs con su test, y al generador al azar de tests/comun/mod.rs. pnpm bindings.
  • Estado nuevo de carta o de jugador: en Carta/Jugador y en VistaCarta/VistaJugador; decidir si se borra al salir del campo (Carta::limpiar_de_campo).
  • Afinar el sparring: las constantes de arriba de sparring/reacciones.rs y las bandas de tests/sparring.rs (con -- --nocapture se ve el reparto). Una carta nueva del fantasma: una entrada en assets/sparring/cartas.json con su nombre real (se busca en Scryfall por el nombre).
  • Nunca HashMap en el modelo, ni reloj ni azar de fuera en el crate.

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