Skip to content

Game Engine

Pablo Martínez López edited this page Oct 27, 2026 · 14 revisions

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/.

Why a separate crate

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)"]
Loading
  • Intencion is what a player asks for (Accion).
  • Evento is what the host resolved (Cambio). It is what is stored, sent to the others and replayed.
  • Actor is Jugador(id) or Mesa (events the host generates on its own: the sparring reaction).

The model (modelo.rs)

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, BTreeSet and Vec, never HashMap: 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 to POS_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.
  • zonas is serialised as a list of pairs (zonas_como_lista) because a RefZona cannot 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 from siguiente_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.

From the app's deck to the table (preparar.rs)

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.layout token or 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). In iniciar the 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.layout or the design's card.layout planar/phenomenon → Plano; scheme → Plan;
  • MarcaCarta::Opcional and the Maybeboard → out; MarcaCarta::Banquillo and Sideboard → Banquillo; of the design variants, one (same rule as model::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.

Game lists (lista.rs)

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. comandantes holds 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) and FueraDeIdentidad. 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.
  • AvisoLista is tagged with "tipo"; the screen words each one with t().

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.

The action catalogue (accion.rs)

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).

Randomness (azar.rs)

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 }; aplicar derives 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_azar lists the actions that get a seed.

Resolve and apply

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_por and 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::Lanzar from the command zone adds one to the commander's cast count; Poner does 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 and VERSION_CATALOGO does 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 (except no_endereza) and draws, except on the very first turn with primero_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.

Who sees what (visibilidad.rs, vista.rs)

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, unless Variantes.mano_privada_en_equipo is 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_por shows (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.

Log, undo, redo and replay (registro.rs)

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 a Cambio::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): SinLimite in solitaire and local games (also «undo up to this line», Deshacer { hasta }); UltimaPropia online: 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 from k on, a restart (deshecho: None) drops everything before it, and Restaurar events 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. Reiniciar is a Restaurar without deshecho that clears the undo stack.

Fingerprint (huella.rs)

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.

Sparring (sparring/)

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 comparing antes and despues): 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_LLEGA 0.42, BASE_LANZA 0.072, BASE_PASA 0.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): Apagado 0, Suave 0.45, Realista 1, Despiadado 1.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 with Despiadado), 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.rs plays 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 (Suave 1–3, Realista 2.5–5, Despiadado 4.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 from InfoCarta.funciones (extraTurn, tutor, draw, wipe, anthem, protection, removal, tokens, recursion), ×0.6 tokens, at most 2.5. Anything that only makes mana (a ramp noncreature, or a ramp creature 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 in TIERRAS_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 and noApunta cards 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 with include_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 flags exilia, gratis, noApunta, monocolor, mvMin, mvMax, paga. They go by name (CartaFantasma.scryfall_id is empty): the app looks them up on Scryfall when it shows one.
  • Explanations: por_que (key = the reaction type, with ronda, ritmo, and carta + peso or criaturas) and base (reparto, ritmo, amenaza, criaturas, intensidad, probabilidad, modelo), always key + parameters; the screen writes them (features/mesa/sparring/textos.ts).

Teams, turn order, deaths and encounters (phase G)

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, Extorsion and VidaATodos use it. With Equipo.vida_compartida = Some(vida), Vida adds to the team's life and a player's life reads the team's (aplicar::vida). preparar::iniciar mounts the pairs of Two-Headed Giant (30 life each pair) and refuses an equipo that 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 and PonerEnMarcha / AbandonarPlan move them; with Planechase the planes start with one active and Planeswalk rotates 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 of Partida.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), at limite_veneno (10) or at limite_comandante (21) from one commander; Causa is Vida, Veneno or Comandante { 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 with Accion::Eliminar { jugador, por }. Eliminar records Eliminacion { 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 the QuedarseCon { 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 with aplicar.rs); None if 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, terminado when someone reaches necesarias, or when games run out, primero_siguiente (the loser starts the next) and cambiar(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 the Partida: the session keeps it, so Accion and Cambio do not change (and VERSION_CATALOGO neither).

The summary of a game (resumen.rs, phase H)

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.

Tests

cargo test -p mesa:

  • tests/acciones.rs: every catalogue action with before → event → after; an exhaustive match (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, wrong seq, 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 a Restaurar, en(n) for every n, London mulligan cases, nobody starts until all keep, fingerprint stability.
  • tests/preparar.rs: sections from the written list, marks as cuentan, 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.rs unit 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 with PB_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), Apagado never 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 in sparring/amenaza.rs (only strong lands weigh something; rocks and dorks weigh less; commander and functions weigh more) and sparring/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 its por, shared team life and the keep-by-default of an absent player.

Where to start when extending

  • New action: add the variant at the end of its group in Accion (with TS), its case in aplicar.rs::accion_ and in resolver.rs::permisos if it touches cards, necesita_azar if it is random, raise VERSION_CATALOGO and write tests/catalogo/v<N>.json, add it to nombre and TODAS in tests/acciones.rs with its test, and to the random generator in tests/comun/mod.rs. Run pnpm bindings.
  • New per-card or per-player state: add it to Carta/Jugador and to VistaCarta/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.rs and the bands of tests/sparring.rs (run it with -- --nocapture to see the mix). A new phantom card: an entry in assets/sparring/cartas.json with its real name (it is looked up on Scryfall by name).
  • Never use HashMap in the model, the clock or outside randomness in the crate.

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