Repository navigation
Game Table
English · Español
The «Jugar» tab is a virtual kitchen table to play a deck from the library in solitaire. The screen only paints and sends actions: the game lives in Rust, in a session that wraps the pure engine of the mesa crate (Game Engine), and the screen receives the view and its patches through an ordered channel. This page covers the app side: preparing the images, the session, saving and resuming, the mat, the board geometry, dragging, menus, shortcuts, dialogs, settings and performance.
Source: src/features/mesa/ (screen, with listas/ for game lists), src/stores/mesa-store.ts (the view), src-tauri/src/mesa/ (sesion.rs, preparar.rs, imagenes.rs, guardada.rs, tapete.rs, ejemplo.rs) and src-tauri/src/commands/mesa.rs (commands).
- No rules engine. As in the engine, nothing checks mana, phases or legality. The player moves cards, taps, counts life and counters, rolls dice; the table records it and lets the player undo. Helpers (draw and untap at the start of the turn, commander tax, counters on entering, life warnings) can be turned off.
-
Solitaire only for now.
mesa_acciontakescomo: IdJugador | null(act as another player of a local session) andErrorCode::Serverexists for online rooms, but the screen always sendscomo: null, starts one player and the board paints one side. The disposition module (disposicion.ts) says so: «today there is one board». -
The screen decides nothing. Positions on the battlefield are the engine's
Pos(0–10,000 per axis);disposicion.tsonly converts them to pixels. What a card is, whether it is seen, where it goes after an action: all from Rust.
sequenceDiagram
participant UI as Tablero / menus / shortcuts
participant OP as operaciones.ts + acciones.ts
participant CMD as commands/mesa.rs
participant S as Sesion (mesa/sesion.rs)
participant M as mesa crate
participant ST as mesa-store
UI->>OP: e.g. op.girar(id)
OP->>CMD: call("mesa_accion", { accion, como: null })
CMD->>S: con_sesion(|s| s.accion(accion, como))
S->>M: Registro::procesar(Intencion, &mut Azar, now)
M-->>S: Hecho { evento, tocado } or Rechazo
S->>M: parche(&Partida, &tocado, Mirador)
S-->>ST: Channel MensajeMesa::Parche { parche, evento }
ST->>ST: conParche (version + 1?) else mesa_vista
CMD->>CMD: guardar_si_toca(state, false)
CMD-->>OP: Ok(()) or AppError Rejected { motivo }
OP-->>UI: toast with the reason (mensajeDeError)
-
hacer(accion)(acciones.ts) is the only sender. It resolves totrue/false; a rejection becomes a toast. The result of the action does not come back in the command's return value: it arrives through the channel. That keeps one path for every change (own actions, undo, restart and, later, other players' actions). -
operaciones.tsgathers what can be done to cards (tap, face down, transform, counters, move to a zone, attach, arrows, piles, library actions, undo/redo). Menus, shortcuts and drag all call it, so a behaviour is written once. Every operation acts on the selection when the card is in it (objetivo(id)), otherwise on that card alone. - The channel is a
tauri::ipc::Channel<MensajeMesa>opened bymesa_suscribir(conexion.ts::suscribir). It delivers in order, which a patch stream needs. Messages:Vista { vista, registro }(the whole view: on subscribing, after undo and after restart, with the log that still counts),Parche { parche, evento }(what changed and the event, for the log) andCerrada. -
Sesion::accionsends a fullVistawhen the event is aCambio::Restaurar(undo and restart replace the whole game) and aParcheotherwise. -
mesa-store.ts::conParcheonly accepts a patch whoseversionis the current one plus one; otherwise it returnsnullandconexion.tsasks for the whole view (mesa_vista). It copies only what changed: an untouched card stays the same object, so its memoisedCartaMesadoes not repaint. - A rejection (
mesa::Rechazo) converts toAppErrorRejectedwithparams.motivo= the variant name (impl From<Rechazo> for AppError).src/lib/errores.tshas a text per known reason (NoExiste,SinPermiso,ZonaVacia,FueraDeTurno,Limite,NoSePuede) and the genericRejectedtext for an unknown one, so a newer engine reason never shows a raw code.
EntradaMesa (entrada.tsx) is the tab when no game is open:
- The deck grid comes from
deck_list(covers withurlMiniatura), filtered by name; the most recently opened deck is preselected. Double click starts. - The format is deduced from
deck_details(...).kind(commander,limited, otherwiseconstructed; «Libre» can be chosen by hand) and the options reset toopcionesDeFabrica(formato, 1), a copy of Rust'sOpciones::de_fabricafor one player (keep both in step). The player can change life, mulligan mode (londres,londresPrimeroGratis,amistoso,libre), draw/untap at the start of the turn, «the first player does not draw», commander tax and counters on entering. - The player name is remembered in
localStorage(proxybuilder.mesa.nombre); the colour is the first ofCOLORES_JUGADOR. Variants are off and the sparring intensity isapagado. - «Lista para jugar» («Game list» in the English UI) picks which cards of the deck go to the game (see Game lists below).
- «Tapete de este deck…» opens the mat dialog for the chosen deck.
- With a saved game, a «Partida a medias» card shows its decks and turn with «Seguir la partida» and «Descartar». Starting another game first asks (
¿Empezar otra partida?), because the new game overwrites the saved file.
mesa_empezar(config: ConfigMesa) is a long job (state.jobs.alta(), the AppState::trabajo() guard) that returns the job id at once and emits mesa://progress (MesaProgressEvent { jobId, mazo, mazos, hechas, total }) and mesa://done (MesaDoneEvent { jobId, error }). On the screen, conexion.ts::conProgreso uses lanzarTrabajo with the trabajo-store type "mesa", folds several decks into one 0–1000 scale, and subscribes when it ends. Inside, abrir for each player opens the deck (store::decks::open), checks its list (lista_para_jugar, below), prepares it (deck_de_mesa with that list), adds its mat, then builds Sesion::nueva, saves it once and stores it in AppState::mesa.
A deck can hold more cards than are played: design variants of the same card, alternatives to try other synergies. A game list chooses which copies go to the game; in Commander, the commander (one concrete copy, so the design variant one wants) and 99 more. The deck itself never changes.
-
Data (
mesa::lista, pure, see Game Engine):ListaDeJuego { id, nombre, comandantes, cartas }holds ids of the deck's copies (DeckCard.id).comprobar(cartas, lista, formato)returnsAvisoListawarnings; only the size, a missing commander, more than two commanders and extra copies stop the game from starting (AvisoLista::impide). Colour identity, a card that cannot be a commander, a pair without partner or background and copies no longer in the deck only warn: the data can be missing (an unlinked custom card) and at home people play as they like. -
Storage (
store/listas.rs, tabledeck_listas, migration 0005): apart from the deck, like tags, so the deck's autosave (which rewrites the deck) never overwrites them and they do not change itsupdated_at; deleted with the deck byON DELETE CASCADE. Names are trimmed, single-spaced and capped at 60 characters; an empty one isInvalidInput/lista_sin_nombre. They travel in the.pbdeck(Deck Package Format). -
Card data (
mesa/preparar.rs::cartas_de_lista): the eligible copies (candidatas_de_lista: all but blank slots, tokens, planes and schemes, marked or not) with name, type, colour identity, whether they can be a commander or go in a pair (comandante_por_texto), whether any number is allowed (basic lands andsin_limite_por_texto), the front blob for the thumbnail and the mark. From Scryfall (cache or network, as for the game) or, failing that, from the card's design. -
Starting (
commands/mesa.rs::lista_para_jugar): with no list, the whole deck is played unless more cards count than the format allows (Construccion::pide_lista; only Commander has a maximum), and thenInvalidInputwithmotivo: falta_lista; a list that does not exist or has a blocking warning ismotivo: lista. With a list,secciones_de_mesa(deck, Some(lista))takes only the chosen copies plus tokens, planes, schemes and the copies marked sideboard.mesa_retomarre-reads the list only to know which images to prepare again (a list deleted since is ignored). -
Commands:
mesa_listas(deck)→ListasDeDeck { listas, cuentan }(cuentan: cards that count without a list),mesa_lista_cartas,mesa_lista_comprobar,mesa_lista_guardar,mesa_lista_borrar;JugadorMesa.lista(optional id) inConfigMesa. -
Entrance (
entrada.tsx): «Lista para jugar» («Game list») lists «Todo el deck (n)» (not offered when the deck needs a list) and the saved lists; the first saved list comes selected. «Nueva lista…» and «Editar…» open the editor («New game list» / «Edit game list» in the English UI). In Commander with more than 100 counted cards and no list, a warning explains it and «Solitario» is disabled. -
Editor (
listas/lista-dialog.tsx): commander candidates on the left, the deck's copies on the right grouped by name (filter by name or type, «Solo las elegidas»), an n/100 counter in Commander. Rust checks the list 250 ms after each change and the warnings are listed below in words; a list can be saved incomplete, but blocking warnings keep the game from starting. A new list starts with the unmarked copies (eleccionDeFabrica). -
Choosing without rules (
listas/eleccion.ts, tested): in singleton, choosing a design variant removes the other copies of the same card; choosing a commander takes it (and its variants) out of the other cards, and with two already the new one replaces the second; basic lands are chosen by number (conCopias).impidemirrorsAvisoLista::impide. The rules themselves are Rust's. - The reasons
falta_lista,listaandlista_sin_nombrehave their texts insrc/lib/errores.ts(MOTIVOS.InvalidInput).
preparar.rs::deck_de_mesa turns a library Deck into the engine's DeckDeMesa:
-
mesa::preparar::secciones_de_mesa(deck, lista)decides where each card starts (deck, commander, sideboard, tokens…; with a list, only what it chose; see Game Engine). -
datos_decollects the Scryfall cards of the deck from the cache; ids not cached, or cached withoutkeywords, are asked through/cards/collectionin batches of 75. A network failure is logged and the game is played with what there is. - Each face gets a
RefImagen, tried in this order:- a designed card: painted by the designer engine (
diseno::pintar) atANCHO_MESAwithSalida::Impresion, keyedd-<design fingerprint>; - an untouched Scryfall print (
CardSource::Scryfall, no design, identity edits,ScryfallPng/ScryfallLargeorigin, ahttps://cards.scryfall.io/URL):RefImagen::Scryfall { id, cara, url }plus a local copy; - anything else:
render_rgbaof the blob with the deck's print settings (edge darkening, card size, corners) and no bleed, keyedr-<RenderSpec::key()>. Edits apply to the front only, as on the sheet. The back is the DFC back face, a custom back (blob) or the deck back (default_back, orRefImagen::Generica, the app's back painted by the screen).
- a designed card: painted by the designer engine (
-
info_defills the engine'sInfoCartafrom the Scryfall card: type, cmc in tenths, cost, colours, land/creature/legendary (from the front face of the type line), power, toughness, loyalty, keywords, the functions ofproxy_core::stats::funcionesandbono_fijo. Without Scryfall data a designed card uses its ownCardData(info_de_diseno, English type and rules); otherwise only the type line ofCardMeta. - Cards are prepared in parallel (
rayon) andprogreso(hechas, total)is called per card.
bono_fijo is a regular expression for «equipped/enchanted creature gets +N/+N» that refuses the match when the same sentence says «for each». It is only an offer (see «Attaching» below): many auras condition their bonus, and the player decides.
Why the final image: the table shows exactly what would be printed (crop, edges, the designed frame), and the session never touches the blob store. Why Scryfall prints are kept apart: in online games the other players can download them from cards.scryfall.io instead of receiving the bytes; a Propia image is named by the SHA-256 of its JPEG bytes for the same reason.
| What | Path (under app_cache_dir) |
|---|---|
Prepared face (Propia { sha256 }), JPEG 85 at 745 px |
mesa/<2 hex>/<sha256>.jpg |
Scryfall print (Scryfall { id, cara }) |
mesa/s/<id>-<cara>.jpg |
Index key → RefImagen
|
mesa/i/<clave>.json |
| Reduced copies served to the screen | renders/m/<sha256 or s-id-cara>-<width>.jpg |
The index is what makes the second game with the same deck paint nothing: a face whose key is already indexed and whose JPEG exists is reused (de_indice). PINTADAS counts real paints for the test tres_cartas_tres_imagenes_y_la_segunda_vez_nada. Everything is cache: if it is deleted, the next start (or resume) prepares it again.
The screen asks for them through pbimg:// (src/lib/image-url.ts::urlMesa): /m/<sha256>/<width> and /m/s/<scryfall id>/<face>/<width>, widths in protocol.rs::ANCHOS_MESA (146, 244, 488, 672, 745, 1000, 1920). A width of 745 or more serves the stored file; smaller widths are reduced once and cached under renders/m/. protocol.rs::leer_ruta only accepts a 64-hex SHA-256, a 36-character hex-and-dash Scryfall id and face 0 or 1 (las_rutas_de_la_mesa), so a path cannot escape the folders. RefImagen::Generica has no URL: imagen-carta.tsx paints DorsoGenerico.
Widths used: battlefield cards at the «quality» setting (146/244/488), raised to 244 above 120 px and to 488 above 200 px on screen; hand and zones 244; large view 488; mat 1920.
Sesion holds the ConfigMesa, the prepared decks (mazos, to restart with the same list), the engine Registro (with ModoDeshacer::SinLimite), its own Azar, the channel, yo (always IdJugador(0)), sin_guardar and guardada_ms. It lives in AppState::mesa: Mutex<Option<Sesion>>.
- Seeds come from the system entropy (
getrandom; the clock if that fails): one for dealing the game, another for the session'sAzar. -
Accion::Reiniciar(«Otra partida») is done by the session: a newPartidawith the same config and decks and a fresh seed, thenRegistro::reemplazar. -
AppState::ocupado()is true while a game is open (hay_partida()), even if the user is on another tab. Then blob garbage collection andupdate_installreturnBusy: an update restarts the app in the middle of a game (testcon_partida_la_app_esta_ocupada).update_installchecks the game first and addsmotivo: partida, shown as «Hay una partida abierta: termínala o sal de la mesa para actualizar.» (errores.ts::MOTIVOS.Busy): the update waits until the game ends or the player leaves the table (Releases and Auto-Updates). -
mesa_info_carta(carta)(«Inspeccionar») first checks withmesa::visibilidad::vethat the viewer may see the card (Rejected/SinPermisootherwise), then returnsReglasCartafrom the Scryfall cache (oneCaraReglasper face) or, without it, from the card'sInfoCarta(no rules text). -
mesa_origen_scryfall(scryfallId)builds anOrigenCartafor a token or a real card that is not in the deck: the card from the cache or the network, its images downloaded tomesa/s/, andinfo_de.
The game in progress is one file: app_data_dir/partidas/actual.json.gz, a gzip (flate2 with zlib-rs) of PartidaGuardada { versionCatalogo, config, mazos, inicial, eventos, guardadaMs }, written atomically (store::blobs::escribir_atomico). It is the engine log, not a snapshot: resuming replays it with Registro::reconstruir, so the undo history survives and the fingerprint is the same (guardar_y_seguir_deja_la_misma_huella).
When it is written:
- at the start of every game;
- after an action, when 50 events are unsaved (
CADA) or when something is unsaved and 20 s passed since the last save (CADA_MS). The 20 s check runs when an action arrives (there is no timer), so idle time adds nothing to lose; - forced (
mesa_guardar) when the tab unmounts, before «Editar este deck», and when the window closes (confirmar-cierre.tsxcallsconexion.ts::guardarboth when checking and when closing); -
mesa_salir(true)(«Dejarla para luego»).
A failed save is logged and never blocks play. mesa_salir(false) («Terminarla», «Descartar», «Salir» after the end) closes the session and deletes the file.
mesa_estado returns EstadoMesa { abierta, deckIds, guardada }; guardada (ResumenGuardada: deck names, deck ids, turn, round, save time) only when no game is open. An unreadable file is logged and ignored, so it never prevents starting another game. A file written by a newer catalogue (versionCatalogo > VERSION_CATALOGO) is refused with Unsupported.
mesa_retomar is a job too: if any image of the saved decks is missing from the cache (faltan_imagenes), it prepares those decks again (same keys, same images; decks deleted since are skipped), then rebuilds the session.
Lifetime of a game on the screen:
- Switching tab keeps the session open in Rust.
MesaScreencallsmesa_estadoon mount and, if a game is open and the screen is not subscribed, subscribes again; the new channel starts with a full view. - «Salir de la mesa» asks: «Terminarla» or «Dejarla para luego».
Sesion::cerrarsendsCerradaand the store empties. - Restarting the app opens no game: the saved one is offered at the entrance.
- Closing the window with a game open asks (reason «Hay una partida en la mesa: se guarda y podrás seguirla al volver.»,
Pendientes.partidainsrc/lib/cierre.ts), and saves it either way.
A background image per deck, or a general one for decks without their own. Sources (OrigenTapete): a file (imported to the blob store), the art_crop of a Scryfall card (downloaded into the blob store as ImageOrigin::Upload) or a blob already stored. Tapete { blob, velo (0–90 %), desenfoque (0–20 px) } is stored as JSON in app_settings (mesa.tapete.<deck id> or mesa.tapete) and its blob is protected from garbage collection in blob_refs with owner_kind = 'mesa' and owner_id = the deck id or general. Why not owner_kind = 'deck': store/decks.rs rewrites a deck's deck references on every save and would drop it. Deleting a deck deletes its mat references and setting (store::decks::delete).
At the start of a game, tapete::de_deck picks the deck's mat or the general one and para_mesa reduces it to 1920 px wide as a prepared image (Propia, index key t-<blob>), which travels in DeckDeMesa.tapete and reaches the screen as VistaJugador.tapete. The dialog previews veil and blur. On the board, tablero.tsx::Tapete (given the deck id) reads the stored mat itself with mesa_tapete_get (the deck's, or else the general one), draws its blob through urlArte(blob, 2000) and applies its velo (opacity of the black layer) and desenfoque (filter: blur). It reads it again whenever usePantalla.tapeteCambiado goes up: tapete-dialog.tsx calls avisarTapete() after setting, removing or adjusting a mat, so a change made during a game shows at once. Only without a stored mat does it fall back to the image in the view (VistaJugador.tapete) under a 35 % veil. Why both: the view's mat is the one fixed at the start of the game, which is what other players would see; the stored one is the player's current choice with its screen settings.
flowchart TB
subgraph Board["Tablero (one player)"]
F["Battlefield: free positions 0–10,000"]
subgraph Strip["Bottom strip: 22 % of height, 90–220 px"]
H["Hand in a fan"]
Z["Command · (Sideboard) · Library · Graveyard · Exile"]
end
end
T["Mat + veil"] -.-> Board
B["Bar (top centre)"] -.-> Board
L["Life, turn helpers (bottom right)"] -.-> Board
P["Stack (left), phantom (top right)"] -.-> Board
-
disponer(w, h, tamano, conBanquillo)splits the board: the bottom strip is 22 % of the height clamped to 90–220 px; small cards (hand, zones) are the strip minus 26 px high; the zones sit on the right, the hand takes the rest; the sideboard slot only appears when it has cards. A battlefield card ismin(24 % of the field height, 240 px)× the size setting (25–200 %). -
posAPx/pxAPosconvert between the engine's 0–10,000 and pixels of the field, clamped. Because positions are relative, a game looks the same after resizing the window. -
abanicolays the hand out in a fan: overlapping when it does not fit, up to 8° of rotation. - The stack is a column on the left (top card first); arrows are an SVG overlay coloured by player (solid for attack, dashed for target; a player target points to the top centre); coloured «bolitas» are drawn on the field and removed with a double click.
- The phantom (
general.fantasma) is the imagined opponent's life of solitaire: −/+ buttons, commander damage, and a target for arrows.
Engine changes made for this screen (aplicar.rs): a card entering the battlefield without a position takes the first free slot of its row (11 slots 900 apart starting at x = 600, so a card about 8 % wide on a wide screen is never cut at the edge), and drawn cards go to the end of the hand.
CartaMesa is memoised and reads only its own card from the store. It is positioned by its centre with a transform (rotated 90° when tapped, plus the fan angle in the hand). Badges: labels, counters, keyword counters, power/toughness (amber when changed), pile size (×N), «Robada»/«Prestada» when the controller is not the owner, and the text of an ability on the stack. On a tapped card the badges are drawn in a box rotated back by −90°, so they stay upright and readable. A face-down card the viewer knows shows an eye icon (Eye); phased-out cards are faded; highlighted cards glow in one of 16 colours (colores.ts).
pt.ts::ptDe computes the shown power/toughness: the printed values (non-numeric such as * count as 0) plus +1/+1 minus −1/−1 counters plus the «other» and «until end of turn» rows; a fixed value overrides everything.
VistaGrande (vista-grande.tsx, hidden while dragging) shows the hovered card large on the right (width from Settings, both faces for a double-faced card) with a status panel: ability text, counters, power/toughness and where each bonus comes from, keywords, labels and «controlled by X; owned by Y».
The table uses its own pointer events instead of a drag-and-drop library: the battlefield is free positioning with animation between zones, not ordered lists.
- A drag starts after 5 px (
UMBRAL_ARRASTRE). While dragging,usePantalla.arrastredrivesArrastrado, a copy that follows the pointer without waiting for Rust; the originals fade. - On release,
destinoDeSoltarpicks the destination: library top or bottom by which half, graveyard/exile/command/sideboard on top, hand at the end, battlefield at that position. Cards dragged on the battlefield keep their distances (oneMoverper card). Dropping on the zone the cards came from does nothing (except the library and the battlefield). -
flip.tsanimates a card from its last on-screen position to the new one (FLIP), also for moves done by the engine (draw, mill, organise); a position older than 5 s does not count. - Click selects; Ctrl/Cmd/Shift-click toggles; dragging on the empty battlefield draws a selection box (cards whose centre is inside; with a modifier it adds); Ctrl+A selects the battlefield, Esc clears.
- «Adjuntar a…» and «Atacar / objetivo» enter a target mode (
usePantalla.modo): the next click on a card (or on the phantom, for arrows) completes it; Esc or a click on the empty field cancels. - Double click: on the battlefield taps, in the hand plays the card. A click on the library draws; with the pointer over it, keys 1–9 draw that many.
- With «Mantener pulsado abre el menú de la carta», holding the left button 500 ms dispatches a
contextmenuevent and swallows the followingpointerupandclick, so releasing does not pick the item under the pointer.
- Menus (
menus/menu-carta.tsx,menus/menu-zonas.tsx, the life menu invida-jugador.tsx) useContenidoContextual(see Live Preview). The card menu changes by zone (battlefield, hand, stack, command, graveyard, exile) and shows each item's key. Library menu: draw, draw several, mill, exile from the top (face up, face down or hidden), look at top/bottom, scry, surveil, search a card or a land, reveal and cast, manifest, reveal the top, play with the top revealed, wheel, shuffle. Zone menu: view, shuffle all into the library, all to the bottom in random order, exile all. Empty battlefield: untap all, organise, create token, put a «bolita» here, select all, remove my arrows. - Shortcuts (
atajos.ts): one tableATAJOSwith three contexts,carta(pointer over a card, or the selection),biblioteca(pointer over the library) andmesa. The card wins over the library and the library over the table. Keys are ignored in inputs, with a dialog or a menu open. Examples: T tap, R transform, F face down, C counter, +/= and − for +1/+1, X power/toughness, G graveyard, H hand, E exile (Shift+E face down), S shuffle in, B bottom, I inspect, P highlight, A attach, D attack/target, O take one from a pile; on the library D draw, S scry, U surveil, M mill, V view, T tutor, L search a land, H shuffle; on the table U untap all, Ctrl+Z/Ctrl+Y undo/redo, Ctrl+Enter pass the turn, ? the list. A test checks no key repeats in a context. - Dialogs (
dialogos/, one at a time throughusePantalla.dialogo): number and text prompts; counter; power/toughness rows; label;mirar.tsx(look, scry, surveil: spread the cards between top in order, bottom, graveyard and exile and confirm withOrdenar; closing without confirming leaves them);buscar.tsx(search the library with name/type/colour filters, offering to shuffle on close; view graveyard, exile, command or sideboard with the «Antes» divider);revelar.tsx(reveal and cast: discover, cascade, reveal until, reveal N, Jodah, Sunbird's and a custom excavation; first everyone sees the reveal, then the player decides each card and where the rest goes);fichas.tsx(tokens of the deck, quick ones, helper ones such as dungeons, The Monarch or The Ring, any Scryfall search or a real card to copy throughmesa_origen_scryfall, a free token; quantity and «as a pile»);azar.tsx(coins with «Lanzar la moneda», until losing, Krark's Thumbs, quick rolls, N dice of X, the planar die; Rust decides the result);terminar.tsx(who is out, who won and how; final);registro.tsx; the shortcut list;InspeccionDialog(mesa_info_carta). - Without connection a quick token is still created, generic and named (
crearFichawithOrigenFichalibre). - Attaching (
operaciones.ts::adjuntar): after a successful attach, if «Ofrecer el bono…» is on and the aura or equipment has abonoFijoand the target is a creature, a toast offers «Sumar», which adds it to the creature's «other» row (MasXMasY). The engine never adds it on its own. -
mulligan.tsxcovers the board until the player keeps: the hand large, the rule of the chosen mode, and how many cards go to the bottom (alFondo, the same count as the engine'sQuedarseCon). With several players it shows who starts, drawn with the game seed. -
ayudas-turno.tsx: at the start of the player's turn, if the draw or untap helpers are off, their buttons appear for 10 s (with a rad-counter mill reminder); the monarch gets a permanent draw button; withavisarVidaCero, toasts at 0 life, at the poison limit and at the commander-damage limit. -
vida-jugador.tsx: −/+ or the wheel; clicks within 700 ms are merged into oneVidaevent and shown as «40 + 2 = 42». Its menu: damage, gain, drain, life to everyone, poison, commander damage, trackers, monarch, initiative, the city's blessing, day/night, reveal the hand. «Pasar turno» sits next to it. -
barra-mesa.tsx: turn and round with a clock (turno.desdeMs, stopped while paused), pause, undo, redo, dice, tokens, log, mat, shortcuts, full screen (setFullscreen, which needscore:window:allow-set-fullscreenincapabilities/default.json), restart, end, «Editar este deck» (saves, opens the deck in the Deck tab, the game stays saved) and leave. - At the end (
general.fin) an overlay shows the winners and how, with «Ver la mesa», «Salir» and «Otra partida».
Events reach the screen as data and textoDeEvento writes each one with t() in the interface language; Rust never writes prose. Hidden information is written from the viewer's side («draws 2 cards», never which). The store keeps the last 500 events (MAX_REGISTRO). Patches append their event; a full Vista from the channel brings registro, the last 500 (REGISTRO_EN_VISTA) of Registro::vigentes() (the events that still count: without what was undone, without what came before a restart and without the Restaurar events themselves), and recibirVista(v, registro) replaces the list with it. So after undo the undone lines disappear, after «Otra partida» the old game's log is gone, and resuming a saved game shows its log. mesa_vista (asked after a skipped patch) brings no log and the current one stays. In solitaire each line has «Deshacer hasta aquí» (Deshacer { hasta: seq }).
Rust decides every result (Cambio::Dados, Monedas, DadoPlanar, each with a semilla_animacion drawn from the event's seed); the screen only shows it. conexion.ts hands every patch event to useDados.encolar (cola.ts), and tiradaDe turns the change into pieces: one die per die rolled (a d100 is two d10, tens and units, dadosDeTirada), the last batch of coins (with «until losing», the one that lost) or the planar die; at most MAX_PIEZAS = 20 are drawn, the full result is shown anyway. The queue keeps the roll on screen and at most two waiting: rolling very fast drops the ones in between. azar.tsx closes on rolling, so the roll is seen on the table.
capa.tsx (CapaDados, in mesa-screen.tsx) sits between the top bar and the hand (top-[11%] bottom-[26%]), does not take the pointer, and after each roll shows ResultadoAzar (the same block as the dialog) for RESULTADO_MS = 2.2 s; a click dismisses it. Three levels, chosen in settings (mesa.dados):
| Level | Files | How |
|---|---|---|
3D with physics (3d) |
escena3d.ts, fisica.ts
|
three.js (bare, no React wrapper) with a tilted orthographic camera, ambient + directional light with shadows on a ShadowMaterial floor. fisica.ts::simular runs the whole roll with Rapier (@dimforge/rapier3d-deterministic-compat) before showing it, records every frame, and the scene replays the recording. |
Light 3D (css) |
css3d.tsx |
Low poly, flat colour, no WebGL or physics: each face is an element cut with clip-path and placed with matrix3d; the roll is a Web Animations API animation (three random turns and the final pose, with bounces and a blurred shadow). Flat light: each face is darkened by its normal in the final pose. |
2D (2d) |
plano.tsx |
SVG silhouettes; the numbers flicker every 70 ms and settle in 900 ms. |
auto (default) uses 3D when there is WebGL2 and falls back to CSS 3D; if the 3D scene fails (no WebGL, no WebAssembly) it switches to CSS 3D for the rest of the session. With reduced animations (the setting or prefers-reduced-motion) only the result is shown.
How the shown face matches the engine:
-
geometria.tsbuilds the five solids: d4, d6, d8 from vertices; d12 and d20 as duals (dual()tries the six axis permutations so the faces point outwards); the d10 as a pentagonal trapezohedron. Opposite faces add up to n + 1 (on the d10, 0–9 adding up to 9).planoCaragives each face its centre, normal and in-plane axes withw = cross(u, n), so faces are never mirrored (a test checks the signed area). - The physics only says which face ended up on top (
caraLeida, the face whose normal is closest to up; the d4 is read by the face underneath and its numbers sit in the corners,esquinasD4). ThenconResultadoswaps labels so that face carries the engine's value. The CSS level does the opposite: it computes the final pose that puts the result's face towards the viewer (giroFinal). - The physics table is fixed (
ANCHO14 ×FONDO9, gravity −40,HZ60, at most 4 s) and not the board's size, so the same seed gives the same animation on every machine; the scene scales it to the board. A piece is stopped after 12 still frames; one left on an edge (plana< 0.94) gets a small push, up to three times. 300 random rolls stop in 1.7 s on average (2.2 s at most; 20 dice, 3.4 s). -
azar.ts:generador(mulberry32) andgiroAlAzar(a uniform random rotation without trigonometry), so CSS and 2D are deterministic too. - Coins: a cylinder (3D) or two discs with
rotateX(CSS); heads is a crown and tails a cross (simbolos.ts, SVG paths in a 100 × 100 box, used both on the canvas throughPath2Dand in<svg>). The planar die is a d6 with planeswalk on 1, chaos on 6 and blank faces (piezas.ts::VALOR_PLANAR).
Loading: three (about 526 KB) and Rapier (about 4.3 MB, WebAssembly) are loaded with import() the first time someone rolls in 3D, in their own chunks. Rapier needs 'wasm-unsafe-eval' in the CSP's script-src (it compiles WebAssembly; it does not allow eval of JavaScript). In development vite.config.ts lists both in optimizeDeps.include, otherwise Vite discovers them on the first roll, optimises them and reloads the page; optimizeDeps.entries is index.html so the dependency scan does not read stray .html files.
src/features/settings/mesa.tsx edits useUi().mesa (PreferenciasMesa), saved in localStorage with the other preferences (proxybuilder.preferencias, key mesa) and merged over MESA_DE_FABRICA on read, so a new setting gets its default.
| Setting | Field | Default |
|---|---|---|
| Offer the bonus of auras and equipment when attaching | ofrecerBono |
on |
| Show the phantom's life in solitaire | fantasma |
on |
| Holding the button opens the card menu | clicSostenido |
off |
| Size of battlefield cards | tamanoCartas |
100 % (25–200) |
| Size of the large view | vistaGrande |
320 px (200–500) |
| Status panel next to the large view | panelEstado |
on |
| Quality of battlefield images | calidad |
244 (146 low, 488 high) |
| Dice and coins (Automatic, 3D with physics, Light 3D, 2D) | dados |
auto |
| Reduce animations | reducirAnimaciones |
off |
| Shadows | sombras |
on |
| Frame counter | fps |
off |
| Player counters shown next to life | trackers |
none (chosen from the life menu) |
«Modo rendimiento» sets MODO_RENDIMIENTO: quality 146, no shadows, reduced animations, no status panel.
- Patches, not views: Rust sends only touched cards and zones, and the store keeps the identity of everything else;
CartaMesaismemoand subscribes to its own id. - Selectors never return a fresh empty array (
NINGUNA) or a fresh list withoutuseShallow, because zustand would see a change on every read and repaint (or loop). - Images are prepared once at their final size and requested at the width they are drawn at, with
loading="lazy"anddecoding="async". -
fps.tsx:ContadorFps(setting) shows frames per second.VigiaFpsmeasures every second and, after 120 consecutive seconds under 30 fps (a hidden window does not count), offers «Bajar la calidad» once, unless everything is already at the minimum. Animations are skipped when the setting or the system (prefers-reduced-motion) asks for it (useSinAnimaciones).
- Cards known to the viewer travel with their image; hidden ones carry only their back (
caraVisiblefalls back todorso). Looking at the library top (scry) does not show it face up on the table; only «play with the top revealed» does. - A selected or hovered card that disappears in a patch (or is missing from a new full view) is dropped from the screen state, so a later shortcut never acts on a card that is gone. A full view that brings a log (undo, restart) always clears the hovered card, because ids are reused across those.
- Dropping cards on the zone they came from does nothing (except the library and the battlefield), so the hand cannot be reordered by dragging.
-
src-tauri/src/mesa/ejemplo.rsis an ignored test that creates an example deck of designed cards without network, to try the table and take screenshots on a machine without Scryfall (PB_DATOS=… PB_CACHE=… cargo test --lib deck_de_ejemplo -- --ignored).
- Rust:
mesa::sesion::tests(50 random actions give the same view by patches as in full, a rejection arrives with its reason, restart deals again with the same list, an open game makes the app busy),mesa::guardada::tests,mesa::preparar::tests(three cards give three images and the second time none),protocol::tests::las_rutas_de_la_mesa. - Engine fixture:
src-tauri/crates/mesa/tests/fixtures.rswritessrc/stores/__tests__/fixtures/mesa-parches.json(a first view, 60 patches and the final view; rewritten withPB_FIXTURES=1 cargo test -p mesa --test fixtures).src/stores/__tests__/mesa-store.test.tsapplies them and must reach the final view; it also checks version jumps, object identity, selection cleanup and that a view with a log replaces it while one without keeps it.tests/registro.rsof the engine checksvigentes(). The fixtures folder is excluded from Biome. - Lists:
mesa::listatests,tests/preparar.rs::con_una_lista_solo_va_lo_elegido,store/listas.rs(save, read, replace, delete, gone with the deck),mesa/preparar.rs::una_disenada_sabe_si_puede_ser_comandante,package.rs::las_listas_para_jugar_viajan_con_los_ids_nuevos,src/features/mesa/listas/__tests__/eleccion.test.ts. - Dice:
src/features/mesa/dados/__tests__/geometria.test.ts(faces, opposite sums, reading, label swap, the d4 corners, the d100, no mirrored faces) andfisica.test.ts(the same seed gives the same recording; 300 rolls stop in time, stay on the table and read the engine's face). - Screen:
src/features/mesa/__tests__/(arrastre.test.tsgeometry and drop targets,atajos.test.ts,buscar.test.ts,pt.test.ts),src/lib/__tests__/errores.test.ts(rejection reasons),src/lib/__tests__/cierre.test.ts,src/stores/__tests__/ui-store.test.ts(table settings merged with defaults).
- A new action on the screen: add it to the engine first (Game Engine), run
pnpm bindings, add its operation inoperaciones.ts, its menu item and, if it has one, its key inATAJOS, and its sentence intextos-eventos.ts::FRASES(theRecord<Accion["que"], string>type makes a missing one fail to compile). - A new rejection reason: its text in
errores.ts::MOTIVOS.Rejected. - A new die or piece: its solid in
geometria.ts(with the tests of faces and orientation), its piece incola.ts/piezas.tsand its silhouette inplano.tsx::SILUETA. - A new setting:
PreferenciasMesaandMESA_DE_FABRICAinui-store.ts, thenfeatures/settings/mesa.tsx. - New text: through
t()with the Spanish source in themesaarea of the 15 dictionaries (src/i18n/<code>/mesa.json), with the game terms of the «Términos de la mesa de juego» table insrc/i18n/GLOSARIO.md(Internationalization).
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
- Icons · Iconos
- 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