Repository navigation
Game Engine
English · Español
The game table (solitaire, sparring and online games) runs on a pure, deterministic engine: the mesa crate. The screen and the app session around it are described in Game Table. It keeps track of where every card is and what has been done to it, records every action as an event, decides randomness and tells each player what they may see. It is not a judge: there are no Magic rules (no mana, phases, legal targets or combat). It only checks that an action makes sense (the card exists, the actor may touch it) and offers helpers that can be turned off.
Source: 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) and its tests in src-tauri/crates/mesa/tests/.
Decision: the engine is its own workspace member (members = [".", "crates/proxy-core", "crates/mesa"] in src-tauri/Cargo.toml), depending on proxy-core only to read decks. Printing and playing are different worlds; a separate crate compiles and tests on its own (cargo test -p mesa) and proxy-core does not grow. Like proxy-core it has no Tauri, no network, no database, no clock and no outside randomness (tests/registro.rs::huella_estable_y_sensible greps the sources for SystemTime, Instant:: and rand::). It is compiled with opt-level = 2 in dev ([profile.dev.package.mesa]) because every action clones and compares the whole game and the property tests play thousands.
One event log serves the solitaire game, the network, undo and replay:
flowchart LR
I["Intencion { actor, accion }"] -->|"resolver(&Partida, &Intencion, &mut Azar, now)"| 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 (log, photos every 50)"]
-
Intencionis what a player asks for (Accion). -
Eventois what the host resolved (Cambio). It is what is stored, sent to the others and replayed. -
ActorisJugador(id)orMesa(events the host generates on its own: the sparring reaction).
Partida holds everything on the table: id, formato, variantes, opciones, jugadores (seat order = turn order), equipos, cartas: BTreeMap<IdCarta, Carta>, zonas: BTreeMap<RefZona, Vec<IdCarta>>, turno, flechas, bolitas, designaciones, pausada, fantasma, sparring, mulligan, seq, siguiente_id and fin.
Invariants and why:
- Only
BTreeMap,BTreeSetandVec, neverHashMap: the JSON of a game is always byte-identical, so its fingerprint can be compared across machines. - Positions are integers:
Pos { x: u16, y: u16 }from 0 toPOS_MAX(10,000) inside the board of the card's controller. Exact equality and no floating-point drift between machines (Decision D-A3). The screen only converts them to pixels. -
zonasis serialised as a list of pairs (zonas_como_lista) because aRefZonacannot be a JSON object key. - Zone order: in the library (and the plane and scheme decks) index 0 is the top; in the other zones (graveyard, stack…) the last element is the top (
aplicar.rs::arriba_es_inicio). - Ids are small numbers (
IdJugador(u8),IdCarta(u32),IdEquipo(u8)). Tokens and copies get a new id fromsiguiente_id; an id is never reused.
RefZona is De { jugador, zona } (Biblioteca, Mano, Campo, Cementerio, Exilio, Mando, Banquillo, Planes, PlanesEnMarcha) or Comun { zona } (Pila, Planos, PlanoActivo).
A Carta carries its state on the table: cara, girada, boca_abajo, fase_fuera, no_endereza, contadores, palabras_clave (keyword counters), etiquetas (≤ 4, ≤ 20 characters), pt (the three rows of the power/toughness dialog: otros, hasta_fin_de_turno, fijo), adjunta_a, pos, z, vista_por, resaltada, es_ficha, copia_de, cantidad (a pile of identical tokens is one card with cantidad > 1), agrupada_bajo (a pile of real cards, or exiled cards grouped under the card that exiled them), llego_en_ronda (the «Before» divider of graveyard and exile) and habilidad (an ability on the stack: it disappears when it leaves the stack).
OrigenCarta is what the app knows about a card when the game starts: the DeckCard id, the name, one RefImagen per face, the back, the Scryfall id and an InfoCarta (type, cmc ×10 to avoid floats, cost, colours, land/creature/legendary, power, toughness, loyalty, keywords, functions, fixed aura/equipment bonus, commander flag). RefImagen is Scryfall { id, cara, url } (others download it from cards.scryfall.io), Propia { sha256, ancho, alto } (the final image prepared by the app) or Generica (the app's card back).
formato.rs holds Formato (Commander, Constructed, Limited, Libre), Variantes (ModoEquipos::{Ninguno, Equipos { fuego_amigo }, DosCabezas}, archenemy, planechase) and Opciones with Opciones::de_fabrica(formato, variantes, jugadores): Commander 40 life and London mulligan with a free first one, otherwise 20 and London; Two-Headed Giant 30 per team; the archenemy 40 (VIDA_ARCHIENEMIGO); primero_no_roba on with 1–2 players and off with 3 or more (as Playgroup does); all Ayudas on; todos_tocan_todo off.
secciones_de_mesa(&Deck, Option<&ListaDeJuego>) decides where each card starts, using the written list (proxy_core::decklist::{secciones_de, seccion_de}, made public for this) and the marks:
- tokens (
CardSource::Token, an external custom card, a token type line, a custom card whose design is a token (card.layouttokenor a «Token» type line), a dungeon (type line «Dungeon»), or the Tokens section) →Seccion::Ficha(tokens you can create, not game cards): they do not count towards the deck size nor the game-list limit and are not offered when choosing a list (candidatas_de_lista). Ininiciarthe tokens offered to each player (Jugador.fichas) are deduplicated by name and image, so identical copies appear once and the variants of a token (same name, other image) each appear; -
CardMeta.layoutor the design'scard.layoutplanar/phenomenon→Plano;scheme→Plan; -
MarcaCarta::Opcionaland the Maybeboard → out;MarcaCarta::Banquilloand Sideboard →Banquillo; of the design variants, one (same rule asmodel::cuentan); - Commander →
Comandante, Companion →Companero, the rest →Mazo; blank slots → out.
With a game list (below), tokens, planes and schemes are decided as above, but of the other cards only the chosen ones go: its comandantes → Comandante, its cartas → Mazo (or Companero if the written list has them in the Companion section, so a chosen companion still starts apart), an unchosen copy marked sideboard → Banquillo, everything else stays out (marks and the written sections no longer matter for them). candidatas_de_lista(&Deck) returns the copies a list may choose from: all but blank slots, tokens, planes and schemes, marked or not.
The Seccion enum lives in mesa (not in proxy-core) because mesa depends on proxy-core; the other way round would be circular.
A deck can hold more cards than are played (design variants of one card, alternatives). A list chooses the copies that go to the game; the deck does not change. Pure data and checking here; the app gathers each card's data and stores the lists (Game Table).
-
ListaDeJuego { id, nombre, comandantes, cartas }: ids of the deck's copies (DeckCard.id), so the chosen design variant is the one played.comandantesholds one, or two with partner or background; empty outside Commander. -
CartaDeLista: what is needed of a copy to choose and check it: English name (copies are counted by name, so design variants are the same card), type, colour identity,puede_ser_comandante,con_pareja,sin_limite, the front's image hash and the mark. -
Construccion::de(formato): Commander exactly 100 and 1 copy with a commander; Constructed at least 60 and 4 copies; Limited at least 40, any copies; Libre has no rules (None).pide_lista(n): more counted cards than the maximum (only Commander has one). -
comprobar(cartas, lista, formato) -> Vec<AvisoLista>:YaNoEstan(ids no longer in the deck, ignored),Tamano { hay, desde, hasta },SinComandante,DemasiadosComandantes,NoPuedeSerComandante,SinPareja,Repetida { nombre, veces }(cards without limit excluded) andFueraDeIdentidad.AvisoLista::impide()is true only for size, missing commander, too many commanders and repeated copies. Decision: colour identity, the commander's eligibility and the pair only warn, because the data can be missing (a custom card not linked to Scryfall) and a kitchen table plays as it likes. -
comandante_por_texto(tipo, oracle, keywords): a legendary creature, a Background or «can be your commander» can lead; partner keywords, «Friends forever», «Choose a Background», a Background or «Doctor's companion» can go in a pair.sin_limite_por_texto: «A deck can have any number of cards named…» or «up to». The app adds basic lands by type. -
AvisoListais tagged with"tipo"; the screen words each one witht().
iniciar(id, Config, semilla, ahora_ms) deals the game for 1–10 players (MAX_JUGADORES): assigns ids, puts commanders and companions in the command zone (and marks es_comandante), the sideboard apart, every player's planes in one shuffled common deck (with the first face up if Planechase is on), schemes in their player's deck, shuffles each library with the seed, pairs seats into teams for Two-Headed Giant, creates the phantom life for solitaire and draws everyone's opening hand. Everyone starts «deciding» the mulligan.
Every action is data. The module doc comment has a table of every Accion with the Playgroup screenshot or inventory section it comes from. Groups: moving (Mover with a Destino { zona, jugador, sitio }; Sitio = Arriba, Abajo, Indice, XDesdeArriba, EnCampo(Pos), AlAzar, BarajarDentro; ComoMover::{Lanzar, Poner} for the commander), card state, library (draw, mill, manifest, exile from top, look + order for scry/surveil, reveal, excavate + resolve the excavation with the presets' Filtro, search, top revealed, shuffle, wheel), whole battlefield (untap all, organize, create token), mulligan, players (life, drain, extort, poison, commander damage, trackers, designations, cast count, reveal), turn, randomness (dice, coins with Krark's Thumbs, planar die), Planechase/Archenemy, stack (Resolver, ApilarDespues), piles, grouping, graveyard/exile bulk actions, sparring, chat and quick notices (Chat, Aviso: pure communication, es_comunicacion; after the game ends they are the only actions accepted), end of game, undo/redo. Priority signals (Senal) sit in the players group and are not communication for the engine: they can be undone and count as play.
Serialisation: Accion is tagged with "que" (not "tipo", because several variants have a field called tipo), Cambio with "tipo", both with camelCase fields.
🛑 Compatibility: a serde-tagged enum rejects a tag it does not know, so a newer app would break an older one. VERSION_CATALOGO goes up with every new or changed variant; variants are never changed or removed. The network handshake (phase E) and saved replays (phase H) carry it. tests/catalogo/v<N>.json holds events written with each catalogue version and tests/catalogo.rs checks they still parse; it is written once (PB_CATALOGO_ESCRIBIR=1 cargo test -p mesa --test catalogo) and never touched again.
Catalogue version 2 added TomarControl { cartas, hasta_fin_de_turno } (tests/catalogo/v2.json, written with the new variant added by hand since chance may not generate it; v1.json is untouched and still has to parse).
Azar is xoshiro256** seeded through SplitMix64, written in the crate (about 40 lines) and tested against the published reference vectors of both. It was chosen over the rand crate (Decision D-A4) because what matters is identical output on every machine and version. Unbiased integers use rejection (menor_que), shuffles are Fisher–Yates.
The app provides the entropy (the session's seed); the engine never draws randomness on its own. How results travel:
- Dice, coins and the planar die are resolved into the event (
Cambio::Dados { tiradas, semilla_animacion },Cambio::Monedas,Cambio::DadoPlanar { cara, coste }), because the screen shows them. - Every other action that shuffles or picks at random carries a fresh seed in
Cambio::Accion { accion, azar };aplicarderives everything from it in a fixed order. Decision: a seed per event rather than the resulting orders, so host and guests never have to agree on intermediate zone lengths (a token shuffled into a library vanishes, for example). The same event gives the same result on any machine.resolver::necesita_azarlists the actions that get a seed.
resolver(&Partida, &Intencion, &mut Azar, ahora_ms) checks permissions and decides randomness. Default permissions (open question Q-A1 answered with the plan's default): each player touches what they control on the battlefield and stack and what is theirs in their own zones; anyone touches anyone's life, arrows, signals and chat; only the active player passes the turn (Rechazo::FueraDeTurno); looking at or searching another player's library is refused. Opciones::todos_tocan_todo opens everything, like a real kitchen table. Deshacer, Rehacer and Reiniciar are refused here: the session does them.
aplicar(&mut Partida, &Evento) -> Result<Tocado, Rechazo> is total: in a network game a guest applies what the host sends, which is outside input too. It checks seq continuity, works on a clone and only replaces the game if everything succeeded, so an Err leaves the game untouched (a guest then asks for a fresh snapshot). Nothing is indexed with []; every named card, player and zone is checked; numbers are bounded (MAX_CANTIDAD, MAX_FICHAS_SUELTAS, MAX_CHAT, MAX_ETIQUETAS, MAX_FLECHAS, MAX_BOLITAS). Tocado (touched cards, zones, players and «general» state) comes from comparing the old and new game; it is what the screen patches with.
Rules applied on every move (aplicar.rs::mover):
- Leaving the battlefield a card loses tap, counters, keywords, labels, P/T rows, attachment, arrows and its controller; what was attached to it or piled under it is set loose where it was.
- Tokens and stack abilities exist only on the battlefield and the stack: anywhere else they leave the game.
- Changing zone resets
vista_porand face-down; hand, library, graveyard, exile, command zone and sideboard are always the owner's (a stolen card sent «to its hand» goes to its owner's hand); only the battlefield can be another player's. - With
contadores_al_entrar, a planeswalker enters with its loyalty and a saga with one lore counter. - With
impuesto_automatico,ComoMover::Lanzarfrom the command zone adds one to the commander's cast count;Ponerdoes not. - A card entering the battlefield without a position takes the first free slot of its row (
pos_libre: 11 slots 900 apart from x = 600, so a card about 8 % wide on a wide screen is never cut at the edge; when all are taken it goes on top of the last one, slightly shifted). Drawn cards go to the end of the hand (its «top»). - Passing the turn clears «until end of turn» P/T, arrows and the temporary counters and keyword counters (those whose type starts with
~,modelo::PREFIJO_TEMPORAL: it is a convention of the free-text type, so there is no new action andVERSION_CATALOGOdoes not change); extra turns go first; the round goes up when play wraps around the table; with the helpers on, the new active player untaps what they control (exceptno_endereza) and draws, except on the very first turn withprimero_no_roba.
Control, gifts and theft. The rule is that a player can do whatever they want, as at a real table: what is in view (battlefield, graveyard, exile, command zone, stack) belongs to everybody, and only hidden information is kept (resolver::puede_tocar): another player's hand, library and sideboard cannot be touched unless the card is seen (looked at, revealed). So anyone can tap, add counters to, exile, destroy or return another's permanent, counter its spell or reanimate from its graveyard; whatever goes to a hand, library or graveyard goes to its owner's; and a token copy is the copier's. DarControl (give) and TomarControl (take; also hasta_fin_de_turno, for Threaten and the like) change who controls a permanent: aplicar::pasar_a_campo_de moves it to the new controller's battlefield with what it carries: its counters and keyword counters (it never leaves the battlefield, so nothing is cleared), face down stays face down (and only the new controller sees it), and what is attached or piled on it and was controlled by whoever controlled it (equipment, auras, a pile) goes along and stays attached. What is attached from another player's battlefield stays where it is, attached. Taking control needs no permission and is in the log; Carta.devolver_a (serialised only when set, so older games keep their fingerprint) remembers who gets it back, and pasar_turno returns it (if it is still on the battlefield and that player is still in the game). Mirar and Buscar work on any player's library (Gonti, Bribery, Praetor's Grasp; what is seen is known only by whoever looks, until DejarDeMirar), and MirarMano { de, si } does the same with a hand (Thoughtseize, Peek). PorJugador { jugador, accion } makes something happen for another player, as if they did it (draw, mill, discard at random or the whole hand, shuffle, exile their graveyard, reveal their hand, untap, create a token for them: the whitelist is Accion::se_hace_por_otro); permissions are checked for that player and the log says «… (lo hace X)». Catalogue v2 (all in tests/catalogo/v2.json, written by hand where chance does not generate them).
- If the active player is eliminated the turn passes by itself; during the mulligan an eliminated player no longer blocks the start.
The mulligan (Mulligan, QuedarseCon { al_fondo }): London puts as many cards on the bottom as mulligans taken, minus one with LondresPrimeroGratis; Amistoso puts none and has no limit; Libre lets the player choose. Nobody starts until every non-eliminated player has kept; then turn 1 starts with the first player and the start-of-turn helpers.
The rules live in one place. A card is seen if its zone says so or if someone was shown it (Carta::vista_por: Nadie, Dueno, Todos, Jugadores(..)):
- library, schemes and planes deck: nobody (with «play with the top revealed», everyone sees the top card);
- hand and sideboard: the owner (the hand, everyone if revealed, and, with teams, the owner's teammates:
ve_la_mano_de_un_companero, unlessVariantes.mano_privada_en_equipois on; the field has#[serde(default)], so games saved before it exist keep seeing teammates' hands; a spectator never does); - battlefield, graveyard, exile, command zone, stack, active plane and ongoing schemes: everyone, except face-down cards, which only
vista_porshows (the controller when played face down, nobody when exiled «hidden»).
Mirador is Jugador(id), Espectador or Todo (only for replaying a finished game). vista(&Partida, Mirador) -> VistaMesa and parche(&Partida, &Tocado, Mirador) -> ParcheVista build what the screen receives. 🛑 From a zone the viewer cannot see, only the number of cards travels (no ids, no order), so a known card cannot be followed by its id after a shuffle; the cards the viewer does know (top revealed, the ones being looked at) travel with their index in visibles. A face-down card the viewer does not know travels without origen, only with its back. VistaCarta::mirada tells the screen which cards the viewer is looking at (scry, search dialogs).
A hand the viewer cannot see is still shown by the screen as card backs (the count travels; the back is the player's own, VistaJugador.dorso), so a double-faced card never shows its second face from outside. tests/equipos.rs checks that teammates (and 2HG pairs) see each other's hand, rivals and spectators do not, that the option turns it off and that a draw reaches a teammate's view; the hand-written oracle comun::sin_fugas has the same rule.
parche re-sends every touched card and every card of a touched zone (and the zones whose known cards changed, and the hand/library/sideboard of touched players, since revealing changes them); a card the viewer no longer sees goes in quitadas. vista::aplicar_parche does in Rust what the screen store does, for the tests.
Registro { inicial, eventos, fotos, modo } keeps a photo every CADA_FOTO (50) events. The host keeps it; a guest joining mid-game starts its own from the snapshot it receives.
-
Undo is computed by the host: the game as it was before the undone event (
en(seq - 1)) travels whole in aCambio::Restaurar { deshecho, partida }and receivers replace their copy. No «inverse» of each action and no older photos needed on guests (one that joined late has none). A 10-player game is about 300 KB; undo is rare. - Redo (solitaire only) re-resolves the undone intentions, so a re-done shuffle gives a new order, as at a real table. That is why intentions, not events, are kept.
- Permissions (
ModoDeshacer):SinLimitein solitaire and local games (also «undo up to this line»,Deshacer { hasta });UltimaPropiaonline: only your last action and only if nobody did anything after it (chat does not count). -
vigentes()lists the events that still count, in order: an undo (Restaurar { deshecho: Some(k) }) drops the events fromkon, a restart (deshecho: None) drops everything before it, andRestaurarevents themselves are left out. The app sends the last 500 with every full view so the screen's game log matches the game (tests/registro.rs). -
en(seq)rebuilds any moment in fewer than 50 applications.Reiniciaris aRestaurarwithoutdeshechothat clears the undo stack.
huella(&Partida): SHA-256 of the game's JSON, first 16 hex characters. seq is excluded: an undone game must equal the game that never did those actions, even though the event counter keeps rising.
The phantom opponent of solitaire, modelled on Playgroup's: no AI and no rules, just tables and a pool of real interaction cards. reaccionar(antes, despues, evento, azar) -> Option<ReaccionSparring> looks at the game before and after each event of the player and may answer it; the app session turns the answer into a Cambio::Sparring with Actor::Mesa (see Game Table), so it is undone, saved and replayed like everything else. The player decides with Accion::DecidirSparring { reaccion, decision, eleccion }: Aceptar applies it (countered → graveyard, from the stack or from the battlefield if it was played without the stack; removed/swept → graveyard, or exile when the phantom's card exiles; bounced → hand; edict → the creature the player chooses), TeniaRespuesta and NoAplica only record it.
-
Occasions (
reacciones.rs, by comparingantesanddespues): the player casts something (hand or command zone → stack, or a nonland permanent straight to the battlefield) → counter; a nonland permanent lands on their battlefield (resolving or put there) → removal, bounce or edict (58 : 7 : 3); they pass the turn with creatures → wipe. Tokens and abilities never trigger it. -
Probability = base × rhythm of the round × intensity × weight, capped at 90 %. Bases:
BASE_LLEGA0.42,BASE_LANZA0.072,BASE_PASA0.052. Rhythm (RITMO, 1 = peak): 0.15, 0.35, 0.6, 0.85, 1, 1, 0.95… from round 1; wipes (RITMO_BARRIDO) start around round 4 and peak at 7–8. Intensity (factor):Apagado0,Suave0.45,Realista1,Despiadado1.8. Weight: the threat's (amenaza.rs), or for wipes the creature count (0–1 → 1, 2 → 1.3, 3+ → 2.5). At most one reaction per round (three withDespiadado), none while one is undecided, during the mulligan, after the end or with more than one player. - The bases were tuned so the mix of a simulated game matches Playgroup's (removal 58 %, counter 19 %, wipe 13 %, bounce 7 %, edict 3 %):
tests/sparring.rsplays 500 scripted games per intensity (a land per round, the spells the mana allows, pass) and checks the mix within ±5 points and the reactions per 10-round game (Suave1–3,Realista2.5–5,Despiadado4.5–8.5; today about 1.8, 3.4 and 5.6). They are starting numbers to be tuned by playing, not measured from real games. -
Threat weight (
amenaza::peso, Playgroup measures it from real removals; there is no such data here):0.55 + 0.15 × mana value(capped at 7), ×1.7 for the commander, ×1.2 planeswalkers, ×1.1–1.4 per function fromInfoCarta.funciones(extraTurn,tutor,draw,wipe,anthem,protection,removal,tokens,recursion), ×0.6 tokens, at most 2.5. Anything that only makes mana (arampnoncreature, or arampcreature of mana value ≤ 2: rocks and dorks) weighs ×0.7 (Playgroup never removes them; the tables this app is made for do). Of the lands, only the very strong ones weigh something (1.5): legendary lands (Gaea's Cradle, Serra's Sanctum, Tolarian Academy…) and the nonlegendary ones inTIERRAS_FUERTES(Cabal Coffers, Field of the Dead, Maze of Ith…, by English name); basic and ordinary lands weigh 0 and are never targeted. A strong land landing is an occasion like any permanent. -
Limits: on round N the phantom has N mana, except free cards (Force of Will, Deadly Rollick) and cards that discount themselves (
paga, Blasphemous Act); it respects hexproof and shroud (it picks the next target by weight or holds; edicts, wipes andnoApuntacards such as Council's Judgment ignore them); it does not destroy indestructible permanents (it may exile them); a counter only answers the types it can (responde). -
The phantom's cards (
assets/sparring/cartas.json,cartas.rs, read once withinclude_str!; an unreadable file means a silent phantom, never a panic): 128 real Commander staples (59 removal, among them Strip Mine, Wasteland, Ghost Quarter, Tectonic Edge, Field of Ruin and Boseiju for lands; 29 counters, 22 wipes, 9 bounce, 9 edicts), each with name, cost, mana value, type, what it answers (criatura,artefacto,encantamiento,planeswalker,batalla,instantaneo,conjuro,permanente(nonland),noCriatura,todo,tierra,tierraNoBasica) and the flagsexilia,gratis,noApunta,monocolor,mvMin,mvMax,paga. They go by name (CartaFantasma.scryfall_idis empty): the app looks them up on Scryfall when it shows one. -
Explanations:
por_que(key = the reaction type, withronda,ritmo, andcarta+pesoorcriaturas) andbase(reparto,ritmo,amenaza,criaturas,intensidad,probabilidad,modelo), always key + parameters; the screen writes them (features/mesa/sparring/textos.ts).
All of this is engine, pure and tested; the screen only paints it (Game Table).
-
Teams (
tests/equipos.rs).Partida::oponentes(j)leaves out the eliminated and, with friendly fire off, the teammates;Drenar,ExtorsionandVidaATodosuse it. WithEquipo.vida_compartida = Some(vida),Vidaadds to the team's life and a player's life reads the team's (aplicar::vida).preparar::iniciarmounts the pairs of Two-Headed Giant (30 life each pair) and refuses anequipothat does not exist. A team wins together (Resultado.ganadores). Formats (tests/formatos.rs): the archenemy starts with 40 and the rest are one team without friendly fire against them; the schemes go to the archenemy's deck andPonerEnMarcha/AbandonarPlanmove them; with Planechase the planes start with one active andPlaneswalkrotates the deck (refused with no planes); the planar die costs one more each time and goes back to zero with the turn (tiradas_planar); commander damage lowers life and is counted per commander. -
Turn order (
aplicar.rs::siguiente_asiento,tests/turnos.rs). Seat order is the order ofPartida.jugadores;Config.primero(None= drawn from the game seed) says who starts. Eliminated seats are skipped, and the round goes up when play passes the first player's seat (alive or not), not seat 0. With one player left each turn is a round. -
Who is out (
muerte.rs,tests/muerte.rs).fuera_de_juego(&Partida, j) -> Option<Causa>says whether a player not yet eliminated is at 0 life or less (the team's shared life if any), atlimite_veneno(10) or atlimite_comandante(21) from one commander;CausaisVida,VenenoorComandante { de, carta }(the owner of that commander), in that priority. It is pure and has no event of its own: the engine only reports (VistaJugador.fuera_de_juego, also in the patch of a teammate when the team's life changed) and a person confirms withAccion::Eliminar { jugador, por }.EliminarrecordsEliminacion { ronda, por }, passes the turn if the eliminated was the active player and finishes the mulligan if they were the last missing. -
Absence (
ausencia.rs).quedarse_por_defecto(&Partida, j) -> Option<Accion>gives theQuedarseCon { al_fondo }that a player who has not decided yet would send keeping the hand they have (with the cards for the bottom the mulligan rule asks for,toca_al_fondo, shared withaplicar.rs);Noneif they already decided or it is not the mulligan. The app uses it to keep a hand for an absent player (Online Play). -
Encounters (
encuentro.rs,tests/encuentro.rs).Encuentro { al_mejor_de, cerradas }(1, 3 or 5): the scoreboard is computed from the closed games plus the current one if it ended,terminadowhen someone reachesnecesarias, or when games run out,primero_siguiente(the loser starts the next) andcambiar(mazo, banquillo, sale, entra), which checks before touching anything that as many leave as enter, that those leaving are in the deck, those entering in the sideboard and that none repeats. It is not part of thePartida: the session keeps it, soAccionandCambiodo not change (andVERSION_CATALOGOneither).
resumir(&Registro) -> ResumenPartida counts a game from its log, because the exact order in which players fell is only there (Eliminacion keeps just the round). Puesto { jugador, puesto, ronda, por }: 1 is a win (shared by a tie or by a team), the rest are ordered by the last to fall first; whoever is still standing without a result goes after the winners. It also gives rounds, turns and the time from the first to the last event that still counts (vigentes()). It is pure and takes no clock. The app uses it for the end screen, for the dialog «Terminar» and for the history (Game Table). Chat and Aviso are communication, not play (es_comunicacion): they cannot be undone and do not count as «something done after» for the online undo. Senal does count.
cargo test -p mesa:
-
tests/acciones.rs: every catalogue action with before → event → after; an exhaustivematch(nombre) makes a new variant fail to compile until it is tested; permissions, stolen cards returning to their owner, leaving the battlefield, turn helpers, counters on entering. -
tests/propiedad.rs: 1,000 random intentions for 4 players twice with the same seed (same fingerprint, no panic, rejected ones change nothing); visibility for every viewer after every action, checked against the rules re-written by hand (comun::sin_fugas), and patches applied over the first view equal the full final view; 2,000 arbitrary events (unknown actors, invented cards, wrongseq, missing seeds) never panic and failures leave the game untouched. -
tests/registro.rs: undo 20 of 300 equals not having done them, undo up to a log line, redo 10, online undo refused after another player's action, a late guest following the host through aRestaurar,en(n)for everyn, London mulligan cases, nobody starts until all keep, fingerprint stability. -
tests/preparar.rs: sections from the written list, marks ascuentan, with a list only what it chose (con_una_lista_solo_va_lo_elegido), a designed plane to the planes deck, dealing and JSON round trip. -
lista.rsunit tests: one variant of the commander plus 99 is valid; too many, no commander, two variants of the same card counted as repeats; identity and the other warnings. -
tests/resumen.rs: the last to fall ranks best even in the same round, conceding counts as falling, the result decides the winners and a tie shares first place, teammates share a place, losing to the solitaire rival is not winning, an unfinished game. -
tests/catalogo.rs: old catalogue versions still parse. -
tests/fixtures.rs: writes an example view and its patches for the screen store's test (src/stores/__tests__/mesa-store.test.ts); rewritten withPB_FIXTURES=1 cargo test -p mesa --test fixtures. -
tests/sparring.rs: 500 scripted games per intensity (reactions per game in its band, the Playgroup mix within ±5 points), every reaction within the phantom's limits (affordable, never a basic or ordinary land, hexproof respected, the target where it should be),Apagadonever reacts, same seed same reactions, undoing a resolved removal brings the card back, and Gaea's Cradle is sometimes targeted while basics never are. Unit tests insparring/amenaza.rs(only strong lands weigh something; rocks and dorks weigh less; commander and functions weigh more) andsparring/cartas.rs(the JSON reads whole, no repeats, edicts never target). -
tests/equipos.rs,tests/formatos.rs,tests/turnos.rs,tests/encuentro.rs,tests/muerte.rs: friendly fire and shared life, the factory values and rules of each format, the round rule with eliminated seats, the scoreboard and the sideboard swap, and every cause of «out» with itspor, shared team life and the keep-by-default of an absent player.
- New action: add the variant at the end of its group in
Accion(withTS), its case inaplicar.rs::accion_and inresolver.rs::permisosif it touches cards,necesita_azarif it is random, raiseVERSION_CATALOGOand writetests/catalogo/v<N>.json, add it tonombreandTODASintests/acciones.rswith its test, and to the random generator intests/comun/mod.rs. Runpnpm bindings. - New per-card or per-player state: add it to
Carta/Jugadorand toVistaCarta/VistaJugador; decide whether it is cleared on leaving the battlefield (Carta::limpiar_de_campo). - Tuning the sparring: the constants at the top of
sparring/reacciones.rsand the bands oftests/sparring.rs(run it with-- --nocaptureto see the mix). A new phantom card: an entry inassets/sparring/cartas.jsonwith its real name (it is looked up on Scryfall by name). - Never use
HashMapin the model, the clock or outside randomness in the crate.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia