Skip to content

Juego en línea

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

Juego en línea

English · Español

Jugar a la Mesa de juego con otras personas. La partida es de igual a igual sobre iroh (QUIC por clave pública, camino directo y relé de respaldo): una app es el host y ordena cada evento; las demás son invitadas. Un servidor de salas pequeño (Node + TypeScript, un repositorio aparte clonado en server/) solo junta a la gente: identidad sin cuentas, playgroups, invitaciones, salas, señalización, elegir el host por latencia y nombrar otro si se cae. Además hay un código directo (PB1-…) que funciona sin servidor.

Código: src-tauri/src/red/ (red), src-tauri/src/red/servidor/ (cliente del servidor de salas), src-tauri/src/commands/{red,salas}.rs, src/features/mesa/red/ (código directo y estado de la red en la mesa), src/features/salas/ (playgroups y salas), src/stores/{red-store,salas-store}.ts, y el servidor en server/ (src/, docs/protocolo.md, docs/despliegue.md).

Topología y confianza

flowchart LR
  subgraph Partida["Partida (P2P, iroh)"]
    H["App host<br/>ordena los eventos"]
    G1["Invitado"]
    G2["Invitado"]
    S["Espectador"]
    G1 -- "intenciones" --> H
    G2 -- "intenciones" --> H
    H -- "eventos + huella cada 50" --> G1
    H -- "eventos + huella cada 50" --> G2
    H -- "vista de espectador + parches" --> S
  end
  SRV["Servidor de salas<br/>(WebSocket, SQLite)"]
  R["iroh-relay<br/>(propio, con callout)"]
  H -.-> SRV
  G1 -.-> SRV
  G2 -.-> SRV
  R -. "¿dejo pasar esta clave?" .-> SRV
  H <-. "solo sin camino directo" .-> R
Loading
  • Estrella alrededor del host. Los invitados mandan intenciones (Accion con un id); el host las resuelve con el motor (permisos, azar), las aplica y reparte los eventos resultantes en orden de seq. No hay predicción en el cliente: con 20–100 ms entre amigos no se nota, el arrastre ya es local y la predicción complicaría el azar y el deshacer.
  • Cada jugador guarda la partida entera, con lo oculto; Rust solo enseña a la pantalla lo que ese jugador puede ver (mesa::vista con Mirador::Jugador). Así la migración del host es trivial (cualquiera puede seguir) y hay un solo camino de código. Las trampas quedan fuera: un jugador podría leer su copia. Los espectadores nunca reciben la partida, solo su vista (Mirador::Espectador) y sus parches, cada uno con su evento para el registro (ParcheEspectador.evento) solo si todas las cartas que nombra el evento están (o estaban, hasta este evento) en su vista (anfitrion.rs::evento_visible, que lee los campos de carta del JSON del evento con cartas_nombradas); si no, el parche va solo. Así no les llega ni el id de una carta oculta, ni nunca un deshacer (Restaurar, que lleva la partida entera).
  • El servidor no está en el camino de la partida. Si cae, las partidas siguen; solo la migración del host espera a que vuelva.
  • Identidad = la clave de iroh de la instalación (app_data_dir/identidad/clave, ed25519). La misma clave identifica al jugador en P2P y en el servidor; el apodo y el avatar van en app_settings (red.apodo, red.avatar).

Seguridad

Lo que protege una partida, todo comprobado en el código:

  • Cifrado de punta a punta. iroh va por QUIC con TLS 1.3 y cada equipo se identifica por su clave ed25519. Los relés solo pasan bytes cifrados: no pueden leer ni cambiar la partida.
  • El código directo es una contraseña. Lleva un secreto aleatorio de 16 bytes (red/codigo.rs::secreto_nuevo, con la entropía del sistema) que el anfitrión compara en tiempo constante (mismo_secreto). Sin él no entra nadie. El anfitrión puede expulsar un nodo, que ya no puede volver (Anfitrion::expulsar).
  • Solo viajan datos, nunca código. Los invitados mandan intenciones; el motor del anfitrión comprueba cada una con los permisos (un invitado no puede mover cartas de otro ni mirar zonas ocultas).
  • Topes. Tamaño de las tramas (TOPE_HOLA 4 KiB, TOPE_GENERAL 64 KiB, TOPE_PREPARACION 2 MiB, TOPE_PARTIDA 4 MiB), 30 intenciones por segundo por jugador con los ids repetidos descartados, MAX_CARTAS_DECK = 500, 10 jugadores y 20 espectadores, 5 s para saludar.
  • Las imágenes de los demás se piden por su SHA-256, se comprueban contra esa huella, se rechazan por encima de 4 MiB (8 MiB el tapete) o con lados enormes y se vuelven a codificar a JPEG antes de enseñarlas (red/imagenes.rs::guardar, fuentes::limpiar_imagen_con).
  • El chat lo pinta React como texto (sin HTML), con un máximo de 500 caracteres. Los enlaces proxybuilder:// solo rellenan el campo del código; unirse siempre lo pulsa la persona.

Los límites del diseño, dichos claramente:

  • Quien tenga el código puede entrar: pásalo por privado. Además lleva las direcciones directas del anfitrión (sus IP), como cualquier conexión P2P directa.
  • Con una app modificada se pueden hacer trampas. Cada jugador guarda la partida entera (es lo que permite que cualquiera siga) y el anfitrión decide el azar. Un cliente modificado podría leer las cartas ocultas, y un anfitrión modificado, amañar los barajados. Es una trampa contra la partida, no un ataque al equipo de nadie; cerrarlo exigiría barajado criptográfico.

El nodo (red/nodo.rs, red/mod.rs)

Red (en AppState) guarda la clave, el Endpoint (se levanta al hacer falta), el papel P2P de ahora (Sala::Anfitrion / Sala::Invitado), el cliente del servidor de salas y la sala del servidor en la que se está (Salon). ALPN:

ALPN Para qué
proxy-builder/mesa/1 el protocolo de la partida (un stream bidireccional por conexión; los demás streams son peticiones de imágenes)
proxy-builder/img/1 imágenes, por una conexión directa (la malla)
proxy-builder/ping/1 la malla de la sala: una conexión ligera solo para medir latencia y camino

Relés: Relay::N0 (los públicos de n0), Relay::PropioYN0(url) (producción cuando haya dominio: RELE_PROPIO y, detrás, los de n0, de modo que iroh se queda con el mejor y un relé propio caído no deja a nadie sin conexión; mapa_con_n0), Relay::Propio(url) (en debug, PB_RELAY=<url>, solo ese) o Relay::Ninguno (red local, pruebas). Mientras RELE_PROPIO sea None, se usan solo los de n0. Los certificados del relé se comprueban con las raíces del sistema (CaTlsConfig::system(), feature platform-verifier), como hacen reqwest y el cliente del servidor. En debug, PB_RELAY lo cambia (none o una URL).

El protocolo de la partida (red/protocolo.rs)

Tramas de un u32 big-endian con la longitud y JSON (decisión: JSON basta por tamaño, se depura a simple vista y las mismas formas sirven para los ejemplos y las repeticiones guardadas). Los topes se miran antes de leer: 4 KiB para el Hola (que va el primero, en 5 s), 64 KiB en general, 2 MiB para una Preparacion (un deck preparado, validado por validar_deck), 4 MiB para los mensajes con la partida entera. Los datagramas (≤ 1.200 bytes, no fiables a propósito) llevan la latencia y el movimiento en vivo; véase Latencia, reloj y movimiento en vivo.

  • Al host (AlHost): Hola {protocolo, version_app, version_catalogo, rol, apodo, secreto, ultimo_seq, epoch}, Preparacion {deck}, Intencion {id, accion}, Pide {desde_seq}, PideBanquillo, CambiaBanquillo {sale, entra}, Adios.
  • Del host (DelHost): Sala {jugadores}, Bienvenida {epoch, yo, partida, nodos}, Evento {epoch, evento, confirma, huella}, Rechazo {id, motivo}, VistaEspectador, ParcheEspectador, Cerrada, Traspaso {jugador, sucesor} (el host se va a propósito, véase «El traspaso del anfitrión sin servidor»), Banquillo {vista}, BanquilloNo {motivo} y Encuentro {encuentro, primero}. PROTOCOLO es 2: un host y un invitado con protocolos distintos se rechazan en el Hola.

Los mensajes de ejemplo para otros clientes se generan en src-tauri/src/red/fixtures/ (PB_FIXTURES=1). Las versiones de la app y del catálogo tienen que coincidir o el host contesta Rechazo {motivo: "version"}.

Un detalle que dio guerra: los ids del motor (IdCarta, IdJugador, IdEquipo) son claves de mapas dentro de la Partida; dentro de un enum etiquetado por dentro (DelHost, Cambio::Restaurar) serde guarda el JSON en un búfer y esas claves llegan como texto. Por eso los ids se leen de un número o de un texto con el número (mesa::ids, test de_clave_de_un_mapa_dentro_de_un_enum_etiquetado); el formato no cambia.

Host e invitado (red/anfitrion.rs, red/invitado.rs, mesa/sesion.rs)

  • Host (Anfitrion): deja entrar a quien trae el secreto (código directo) o a los nodos de la sala del servidor (permitir_solo); admite como mucho 10 jugadores (con él; más allá, llena) y 20 espectadores; con la partida empezada, un jugador solo vuelve a entrar con el nodo con el que jugaba (a uno nuevo le contesta empezada), y los espectadores pueden entrar en cualquier momento; guarda la sala (apodo, papel, deck); limita las intenciones a 30 por segundo y tira los ids repetidos; difundir manda cada evento a los jugadores (con confirma para la intención de quien la mandó y la huella de la partida cada 50 eventos) y su parche a los espectadores (la vista entera tras un deshacer). El host es el jugador 0, los demás se sientan por orden de llegada, y su sesión es una normal con ModoDeshacer::UltimaPropia (deshacer solo la última acción propia, si nadie hizo nada después).
  • Invitado (Invitado): guarda la partida entera desde la Bienvenida y aplica los eventos en orden de seq (Sesion::recibir): un hueco pide los que faltan (Pide), un evento que no encaja o una huella distinta pide la partida entera. Las intenciones sin confirmar se reenvían al reconectar (el host tira las que ya aplicó). Si se corta, lo intenta 5 veces (1, 2, 4, 8, 16 s) y la pantalla enseña «Reconectando…»; no guarda la partida (la guarda el host).
  • Espectador (red/mirada.rs, AppState.mirada): sin sesión, solo la vista y sus parches; mesa_accion contesta Rejected {motivo: "espectador"} (y el host contesta espectador a cualquier intención suya). Lee el chat, porque le llegan los eventos, pero no puede escribir en él: no tiene asiento.

La pantalla no sabe si juega en local o en red: se suscribe al mismo canal (MensajeMesa), más Rechazo {motivo} para una intención que el host no hizo. Los avisos de la red (AvisoRed: sala, empezada, reconectando, conectada, perdida, cerrada, rechazada, más Enlaces, Ausentes, Fantasma y Senal, abajo, y BuscandoAnfitrion, NuevoAnfitrion y Mayoria del traspaso del anfitrión) llegan como red://aviso.

Latencia, reloj y movimiento en vivo

red/latencia.rs. Todo va en datagramas de la conexión de la partida (proxy-builder/mesa/1): perder uno no importa porque el siguiente lo sustituye. Nada de esto se guarda ni entra en el registro ni en la repetición.

  • Latido y eco (cada 2 s): un jugador manda Latido {t} y el anfitrión responde Eco {t, t_host}. De ahí salen el RTT de la propia app y el desfase del reloj t_host − (t + RTT/2), como mediana de las últimas 10 muestras (un solo paquete lento no lo mueve). El temporizador del turno cuenta desde la hora del anfitrión (Turno.desde_ms la pone él), así que la pantalla lee la hora como Date.now() + desfaseMs y todos ven los mismos segundos aunque sus relojes no coincidan.
  • Enlaces (Enlaces, cada 2 s): el anfitrión mide cada conexión (RTT de iroh y si el camino es directo o por relé) y manda la tabla a todos, con los jugadores que faltan (ausentes). Llega como AvisoRed::Enlaces por red://aviso (no es un evento aparte); la etiqueta de estado enseña «{ms} ms · directo» o «{ms} ms · por relé» por jugador.
  • Fantasmas y señales (Fantasma, Senal): al arrastrar una carta o mover el puntero, los demás ven un fantasma con tu nombre y tu color; Alt + clic en una carta o en un jugador avisa a todos con un aro. mesa_fantasma y mesa_senal los mandan; el anfitrión pone quién los envía (nunca se fía del jugador del datagrama), limita a cada jugador a 60 por segundo, tira el que no sea más nuevo que el último del mismo (jugador, clase) —la hora de salida es estrictamente creciente, así que dos movimientos en el mismo milisegundo no se pisan— y los reparte. Fantasma lleva el tablero (tablero) porque una carta solo tiene sentido en el tablero de su dueño y, si se arrastran varias a la vez, las demás tras la primera en mas (como mucho MAX_MAS = 8, opcional para que los datagramas viejos se lean; el anfitrión lo recorta y a un espectador solo le llega el datagrama si todas las cartas del grupo son visibles para él). En la pantalla que lo recibe, las cartas arrastradas se pintan en pila junto al puntero en el tablero que se mira y en la miniatura de ese jugador (FantasmasMini), y una carta que alguien arrastra no se pinta en su sitio de antes (CartaMesa, las miniaturas): vivo-store guarda lo que va en el aire (fantasmas, enVuelo) y lo recién soltado (soltadas, 0,8 s, oculto solo mientras la carta siga donde estaba, para que no parpadee antes de que llegue el evento del movimiento; la carta que alguien acaba de soltar se sigue pintando donde la soltó, Soltada.donde, CartaSoltada, hasta que llega su movimiento, así que en las demás pantallas ni desaparece un momento ni se desliza). Marcada con marcarArrastradas, la carta no se vuelve a animar (FLIP) desde donde estaba cuando llega el movimiento: se mueve y acaba donde toca. A un espectador solo le llega un fantasma si la carta es visible para él (visibilidad::ve con Mirador::Espectador); la carta oculta de otro se pinta como un dorso genérico. Es un ajuste (Ajustes > Mesa de juego > «En red, ver cómo mueven los demás y mostrarles mi cursor») y la pantalla limita lo que manda (features/mesa/en-vivo/limitador.ts, 20 por segundo al arrastrar y 10 el cursor).

Ausentes y expulsiones (fase G)

  • Quién falta. El anfitrión sabe desde cuándo se cortó la conexión de cada jugador (Anfitrion::ausentes, de ausente_desde); solo cuentan los jugadores que no están expulsados. La tabla de enlaces (Enlaces, cada 2 s) lleva ausentes: Vec<Ausente { jugador, hace_ms }> (con serde(default), así una app sin el campo sigue leyéndola); en la pantalla del propio anfitrión, vigilar_ausentes lanza AvisoRed::Ausentes una vez por segundo cuando la lista cambia. La pantalla guarda useRed.ausentes con el momento en que se enteró (visto) y cuenta desde el hace_ms del anfitrión más lo que ha pasado desde entonces. Lo que la pantalla hace con eso (el botón «Saltar el turno de X» a los 30 s) está en Mesa de juego.
  • Mulligan. Si un jugador sigue ausente en el mulligan pasado un minuto (ESPERA_MULLIGAN), vigilar_ausentes le pide al motor quedarse_por_defecto y lo aplica con Sesion::suplir (una acción hecha como el jugador ausente, que solo puede la sesión del anfitrión): se queda con su mano, como dice el plan, y la partida puede empezar. Su mano se queda después en la mesa.
  • Expulsar. Anfitrion::expulsar(nodo) mete el nodo en expulsados, le manda Rechazo { motivo: "expulsado" }, cierra su conexión (código 4) y cualquier Hola posterior suyo se rechaza; en una partida ya empezada devuelve su asiento y el anfitrión aplica Eliminar { jugador, por: None } con Sesion::suplir. Quién puede pedirlo: con el servidor de salas, quien creó la sala, con sala_expulsar { clave }; el servidor recuerda a los expulsados de la sala (expulsados: no pueden volver con sala_unirse, error expulsado), avisa al expulsado con sala_fuera { motivo: "expulsado" } y a los demás con sala_expulsado { clave }, y el anfitrión (que puede ser otro) hace el resto en red/servidor/sala.rs::expulsar_en_host. Con un código directo, el anfitrión llama él mismo a expulsar_en_host (Rejected { motivo: "no_anfitrion" } para cualquier otro). Comandos: salas_sala_expulsar(clave) desde la sala de espera y salas_sala_expulsar_jugador(jugador) desde la mesa (busca el nodo del asiento en Invitado.nodos o Anfitrion::nodos). El expulsado ve «Quien creó la sala te ha expulsado de ella.».

Imágenes (red/imagenes.rs)

  • Las propias (cartas subidas, retocadas o diseñadas, tapetes, reversos propios) viajan por su SHA-256. El invitado se las pide al host por la misma conexión (un stream por imagen); el host, si no la tiene, se la pide a su dueño y se queda una copia. Nadie necesita la dirección de nadie más que la del host.
  • Las de Scryfall no viajan: cada app las baja de cards.scryfall.io (añadido a fuentes::PERMITIDOS), como mucho 4 a la vez.
  • Se traen cuando la pantalla pide pbimg://…/m/… y el fichero no está (el manejador del protocolo llama a imagenes::asegurar): lo que está a la vista llega primero.
  • Al llegar: tope de tamaño (4 MiB una carta, 8 MiB un tapete), la huella y el saneado con fuentes::limpiar_imagen_con (guarda la proporción y siempre JPEG 85; 745 × 1040 para cartas, 2560 × 1600 para tapetes). La saneada va donde iría la propia (app_cache_dir/mesa/<2 hex>/<sha256>.jpg) y los bytes recibidos a app_cache_dir/mesa/r/ (son los que dan la huella, para pasárselos a otro). Solo se sirven imágenes de la partida en curso.

Originales en mi idioma (mesa/originales.rs)

Por jugador y solo en su pantalla: no se manda a nadie. Se activa desde el menú contextual de un jugador (su panel de vida y las fichas de jugador, vida-jugador.tsx y estado-red.tsx: «Cartas originales en mi idioma»), por tablero, y la ficha enseña un icono de idioma mientras está activo. Activado, mesa_originales recibe las cartas que la pantalla quiere (hasta 1.000), busca cada una con vínculo a Scryfall por set y número en el idioma de la interfaz (CardApi::card_lang, con la inglesa de respaldo) y baja sus caras a app_cache_dir/mesa/s/<id>-<cara>.jpg, servidas como pbimg://…/m/s/…. mesa://originales contesta carta a carta y el mesa-store cambia origen.caras de las cartas del dueño (respuesta vacía = no hay otra impresión y la carta se queda como está). Las propias sin vínculo y los tapetes no se tocan. Rust no sabe «cómo lo veo» a propósito: el modo vive en la pantalla.

Código directo (sin servidor)

«En red…» en la entrada de «Jugar» (red-dialog.tsx): crear una partida (red_crear) y pasar el código, o pegar uno para jugar o mirar (red_unirse, un trabajo que antes prepara el deck). El código PB1-<base64url> lleva el EndpointAddr del host y un secreto de 16 bytes (red/codigo.rs, comparación en tiempo constante). El host empieza con los que mandaron deck (red_empezar; los que no, rechazados con sin_deck; en red no hay sparring). El invitado elige si juega o solo mira («Solo mirar») y, para jugar, el deck y la lista para jugar elegidos en su propia entrada de «Jugar»; la lista se comprueba con el formato de esa entrada. La partida toma el formato y las opciones de la entrada del host; las variantes (equipos, Archenemy, Planechase) van apagadas, no hay «al mejor de» y el primer jugador se sortea. Salir de la mesa es salir de la partida; si el host sale de la mesa, la partida se cierra para todos. Si en cambio el host se cae (app cerrada, sin red), los demás siguen sin él: véase «El traspaso del anfitrión sin servidor», abajo.

Ajustes de la sala de espera. Antes de empezar, el anfitrión elige en el mismo diálogo (SalaDeEspera en red-dialog.tsx) lo que también elige una partida local: el orden de los asientos, los equipos (ninguno, por equipos con fuego amigo y vida compartida, Dos Cabezas, un archienemigo contra todos), el «al mejor de» 1, 3 o 5 (solo en los formatos con banquillo), Planechase y quién empieza. Los controles son los de la partida local (ajustes-partida.tsx: AjustesDePartida, FilaEmpieza, SelectorDeEquipo, FilaOrdenable) y la configuración la arma el mismo construirConfig. Lo propio de la sala: el anfitrión siempre ocupa el primer asiento (su Anfitrion.mi_jugador y su Sesion.yo son 0) y a los invitados se les arrastra por debajo; config-sala.ts (pura, con tests) dice quién se sienta (el anfitrión y los jugadores con deck, no los espectadores ni los que aún preparan) y conserva el orden cuando llega alguien (al final) o se va (sincronizarAsientos, que también mueve «quien empieza» y el archienemigo). Cada fila de la sala lleva EnSala.id (la conexión; el anfitrión, 0) para que lo elegido nombre personas y no posiciones. red_empezar(config, invitados) recibe la configuración con el anfitrión primero y, tras él, lo elegido de cada invitado (color y equipo) en el mismo orden que invitados; empezar_partida toma los nombres y los decks de la sala, y si los ids no son exactamente los jugadores con deck de ese momento (alguien entró o se fue entre medias) lo rechaza con sala_cambio y no empieza nada. Sin equipos valen las opciones elegidas al preparar la mesa; con equipos, las de fábrica del formato. El formato y la lista para jugar son los de crear la partida: los invitados prepararon sus decks para ellos.

El traspaso del anfitrión sin servidor (red/sucesion.rs)

Con el código directo nadie vigila la partida desde fuera, así que lo hacen los propios jugadores. El servidor de salas no usa nada de esto: allí elige el servidor (véase «El servidor de salas»).

  • Todos preparados. Cada NodoJugador lleva el asiento, el nodo y la dirección (direccion, el EndpointAddr en JSON). Cada invitado manda la suya en el Hola, y el host solo la guarda si coincide con el nodo de la conexión. Los jugadores reciben la lista en la Bienvenida y los espectadores en su primera VistaEspectador. Además, cada jugador tiene la partida entera y el secreto no cambia, así que el código sigue valiendo con quien siga.
  • Buscar al siguiente (buscar, en el bucle del invitado). Tras INTENTOS_ANTES = 3 intentos con el host de antes (1 + 2 + 4 s, cada uno con un tope de ESPERA_INTENTO = 3 s), el invitado prueba con los demás jugadores por orden de asiento, sin el que se cayó, ESPERA_CANDIDATO = 10 s cada uno. El jugador al que le llega su turno pasa a anfitrión con su copia (hacerse_anfitrion: Anfitrion::desde con el mismo secreto, vigilar_ausentes, atender y Mesa::ser_anfitrion, que en la app pone Sala::Anfitrion con el código nuevo). Los espectadores también buscan, pero nunca pasan a anfitrión.
  • Mayoría. Un host de código directo solo deja jugar con más de la mitad de los jugadores conectados, contándose él (Anfitrion::recuento, mayoria, minoria; los expulsados no cuentan). Mientras no la tiene, rechaza toda acción con Rejected sin_mayoria, también las suyas. vigilar (lo lanza atender) lo para tras PARAR_TRAS = 2 s con menos de la mitad conectados, y lo pone en marcha tras ACTIVAR_TRAS = 4 s con más de la mitad. Ponerse en marcha tarda más que pararse a propósito, para que nunca jueguen dos hosts a la vez. Dos grupos que no se ven no pueden tener los dos más de la mitad, así que la partida nunca se parte en dos.
  • Juntarse. Un host parado prueba cada CEDER_CADA = 5 s a unirse a otro (ceder). Se une a un host de un asiento anterior aunque también esté parado, y a uno de un asiento posterior solo si ya juega (Hola.solo_si_activo, que se rechaza con no_activo), así que nunca se unen dos el uno al otro. Al unirse, suelta a sus invitados (Anfitrion::soltar, conexiones cerradas con el código 6), que vuelven a buscar al host. Su pantalla sigue con el mismo canal (Sesion::tomar_canal).
  • Dos jugadores. Tras una caída, el que queda nunca pasa de la mitad (1 de 2), así que espera a que vuelva el otro, y la pantalla lo dice.
  • Irse a propósito. Cuando el anfitrión se va (el diálogo «¿Salir de la partida?», red_salir/mesa_salir, o cerrar la ventana, que ahora llama antes a salirDeRed()), Sala::cerrar llama a Anfitrion::irse. Con la partida empezada, un código directo y al menos un jugador conectado (puede_ceder, también en EstadoRed.puedeCeder para que el diálogo diga «la partida sigue con otro jugador» o «se cierra para todos»), cede la partida en vez de cerrarla: manda a cada conexión DelHost::Traspaso { jugador, sucesor } (el sucesor es el jugador conectado de asiento más bajo), espera 250 ms a que salga y suelta (soltar). Cada invitado se salta los intentos con el host de antes y va directo a buscar con el sucesor el primero de la lista (candidatos), así que nadie pierde tiempo probando con un ausente de asiento más bajo, y todos coinciden en la misma época. El host nuevo apunta al que se fue como ido (Anfitrion::marcar_ido): no cuenta para la mayoría (así con dos jugadores el que queda sigue enseguida, 1 de 1, y con tres, 2 de 2) y ya no es candidato ni destino de ceder (nodos). Si no queda nadie más conectado (solo espectadores, o jugadores caídos), la partida se cierra para todos como antes. Con el servidor de salas nada de esto se usa: elige el servidor.
  • Pantalla. AvisoRed::BuscandoAnfitrion { candidato } enseña «El anfitrión no vuelve: probando con …», NuevoAnfitrion { jugador, soyYo } un aviso y un red_estado nuevo, y Mayoria { activa, conectados, total } el aviso de que no se juega hasta que vuelvan más de la mitad (estado-red.tsx::AvisoConexion, red-store).
  • Lo que se puede perder: los eventos que hizo el host de antes en sus últimos segundos y que no le llegaron al nuevo. Todos se ponen al día con la partida del nuevo host, porque al unirse siempre llega la partida entera.
  • Tests en red/pruebas.rs: se cae el host y B sigue con C; con dos jugadores, el que queda espera; el host pierde la red, se para, los demás eligen a B y el host de antes se une a B como invitado; un espectador sigue mirando con el host nuevo; el host se va a propósito (con tres jugadores, con dos, con un ausente de asiento más bajo, con un espectador, y sin nadie que lo siga, que cierra la partida). En los tests la unidad de tiempo es de 40 ms en vez de 1 s (sucesion::UNIDAD).
  • Procesos de verdad, red/pruebas_procesos.rs (#[ignore], se pide con --ignored): el binario de pruebas se relanza como anfitrión y dos invitados (nodos de iroh reales, sin relé), juegan unos segundos, el anfitrión o se mata con SIGKILL (sin el cierre limpio de QUIC que sí hacen los tests en un mismo proceso) o se va a propósito (irse). Se comprueba que un invitado pasa a ser anfitrión, el otro lo sigue en la época 2, se sigue jugando y los dos acaban con la misma partida. A un anfitrión matado se le echa de menos al vencer el tiempo de inactividad de QUIC, unos 30 s (medido 30,7 s, tres veces), así que la mesa queda parada ese rato; irse a propósito tarda 0,3 s con el reloj de los tests (1 unidad = 40 ms), unos 4 s con los plazos reales (ACTIVAR_TRAS).

Identidad: exportar e importar

Ajustes > Mesa de juego > «Identidad en red» enseña el id público de la clave y ofrece exportar (red_identidad_exportar(ruta)) e importar (red_identidad_importar(ruta)), ambos por lib/dialogos.ts. El fichero es JSON ({tipo, version, clave, id}, permisos 0600) con la clave secreta en hexadecimal y el id público para comprobar que es la que se quería; al importar se validan el tipo, la versión, el tamaño (≤ 4 KiB) y que la clave corresponda a su id, la clave anterior se queda en identidad/clave.anterior y Red::cambiar_clave cierra el nodo y el cliente del servidor de salas para que vuelvan a levantarse con la clave nueva. Un fichero malo es InvalidInput (motivo: "identidad"); con una partida, una partida mirada, una sala por código o una sala del servidor abiertas responde Busy (motivo: "partida") y no cambia nada. Quien tenga el fichero puede jugar como tú, y el diálogo lo avisa. Así un jugador lleva sus playgroups a otro equipo.

Enlaces de invitación (enlace.rs)

proxybuilder://unirse/<código> abre la app (o la que ya está abierta) y lleva a «Jugar con amigos» con el código puesto en «Unirse con un código». Unirse sigue siendo un clic de la persona: el enlace nunca hace nada por sí solo.

  • Rust escucha (tauri-plugin-deep-link, on_open_url y get_current() para el enlace con el que arrancó la app; tauri-plugin-single-instance con la feature deep-link pasa los argumentos de un segundo arranque al primero en Windows y Linux; macOS lo entrega por el plugin de deep link). El plugin de instancia única no se registra con PB_PERFIL, que existe justo para abrir varias.
  • codigo_de solo admite ese esquema y esa ruta, un código de exactamente 10 caracteres de Crockford tras normalizar (mayúsculas, espacios y guiones perdonados, I/L → 1, O → 0), sin más tramos de ruta y de 200 bytes como mucho; todo lo demás se ignora. El código válido se guarda, la ventana se enseña y se enfoca, y se emite salas://enlace; enlace_pendiente entrega (una vez) el que llegó antes de que la pantalla escuchase.
  • Pantalla: features/salas/enlaces-entrantes.ts (useEnlacesEntrantes, montado por app-shell.tsx) cambia a la pestaña «Jugar» y deja el código en salas-store.enlaceEntrante; entrada.tsx abre el diálogo y UnirseConCodigo rellena el campo. Con una partida en marcha solo enseña un aviso. El panel de invitación (CodigoInvitacion) también copia el enlace (enlace_invitacion, así el formato vive solo en Rust).
  • El esquema se declara en tauri.conf.json (plugins.deep-link.desktop.schemes) y los instaladores lo registran; en Linux y en las compilaciones de depuración de Windows lo hace register_all() al arrancar (el AppImage y pnpm tauri dev no tienen instalador).
  • rust-ini está fijado a 0.21.1 en Cargo.lock: el plugin de deep link no compila con la 0.21.3. Se quita el pin cuando el plugin se ponga al día.

El servidor de salas

Repositorio aparte proxy-builder-server, clonado dentro del checkout de la app en server/ e ignorado por ella (.gitignore): sus cambios no lanzan el CI de la app. Node ≥ 22.13 (node:sqlite), ws, zod; SQLite en un fichero para jugadores, playgroups e invitaciones; las salas viven en memoria. Protocolo en server/docs/protocolo.md: un WebSocket /v1/ws, un objeto JSON por mensaje con su tipo en t, id/re para emparejar peticiones y respuestas, 16 KiB por mensaje, 20 mensajes por segundo, tres mensajes mal formados seguidos cierran la conexión.

  • Identidad sin cuentas: el servidor manda un reto de 32 bytes; la app firma proxy-builder/v1/<nonce> con su clave de iroh (hola). Como mucho 3 conexiones por clave.
  • Playgroups, solo por invitación: dueño, admins y miembros; dueño y admins invitan (7 días de fábrica, con tope de usos opcional) y expulsan; el dueño pasa el puesto antes de irse (el último que se va borra el grupo). Límites: 100 miembros, 50 playgroups por clave, 20 invitaciones vivas. Los códigos son 10 caracteres en base32 de Crockford (da igual mayúsculas, guiones e I/L/O).
  • Salas: desde un playgroup (sus miembros entran por el id) o sueltas (solo con una invitación de sala). Quien la crea elige el formato, el máximo de jugadores (2–10) y si se admiten espectadores (sala_crear); si no, entrar como espectador da sin_permiso, y como mucho hay 10 espectadores. Cada miembro entra como jugador o como espectador (como); con la partida en marcha solo vuelven como jugadores los que jugaban (ya_empezada). Solo quien la creó empieza (sala_empezar; la sala de espera lo deja con al menos 2 jugadores, todos con deck), y una sala en espera se acaba si se va quien la creó. Cada miembro manda su dirección de iroh (sala_direccion) y el nombre de su deck preparado (sala_deck), y todos reciben sala_estado; quien creó la sala puede expulsar a un miembro (sala_expulsar, ver Ausentes y expulsiones).
  • Elegir host (eleccion.ts, pura): el coste de cada candidato es su peor RTT hacia los demás; por relé ×1,5, parejas sin dato 1.000 ms, móvil ×3. Gana el menor; empate por la media y luego por orden de llegada. Se hace al empezar quien creó la sala, con todas las filas de latencia o 5 s después.
  • Migración del host (salas.ts): solo el servidor sube el epoch. El host cambia si (a) su WebSocket se cierra y no vuelve en 4 s; (b) al menos la mitad de los demás jugadores avisa sala_host_caido con el epoch actual y el host no contesta a un sondeo en 3 s; (c) esa mitad sigue avisando 10 s aunque el host conteste al servidor (perdió iroh pero no el WebSocket). Pasa a host el siguiente de la sucesión que esté en línea; el de antes va al final.
  • Reinicios: las apps vuelven a registrar sus salas (sala_unirse con registro) y el servidor adopta el mayor epoch que le traigan; nunca lo baja.
  • Callout del relé: POST /relay/permitir (solo desde la propia máquina, con el token del relé si hay PB_RELAY_TOKEN) contesta 200 true si la clave tiene una conexión abierta o está en una sala viva.

Las pruebas de contrato mantienen los dos lados alineados: la app escribe sus mensajes en src-tauri/src/red/servidor/fixtures/al-servidor.json (PB_FIXTURES=1) y el servidor los valida con sus esquemas zod; la app lee los ejemplos del servidor (server/test/ejemplos/del-servidor.json) y comprueba que los entiende. Cada lado se salta lo del otro si no está.

Las salas en la app (red/servidor/, commands/salas.rs, features/salas/)

  • cliente.rs: el WebSocket (tokio-tungstenite con rustls y el verificador del sistema), el reto firmado, la reconexión (1, 2, 4… hasta 30 s), un ping cada 20 s (nginx corta a los 120 s callado), pedir con 10 s de espera (los errores del servidor como ErrorCode::Server con params.code; sin conexión, ErrorCode::Network sin_servidor) y al_volver (lo que se manda otra vez al reconectar: registrar la sala, la dirección, el deck).
  • sala.rs (Salon): en una sala el nodo lleva su propio aceptador (malla, imágenes y, si es host, la partida), una tarea de malla que cada 10 s abre una conexión ping ligera con cada otro miembro y manda el RTT y el camino (sala_latencias, y salas://enlaces a la pantalla), y un vigía que, mientras el invitado ha perdido al host, manda sala_host_caido cada 2 s.
  • Con sala_host: el elegido abre el Anfitrion (solo entran los nodos de la sala) y empieza la partida, con el formato de la sala, las opciones de fábrica de ese formato y las variantes por defecto, cuando están todos los jugadores con deck, o a los 20 s; los demás se conectan a él con su deck. Con la partida ya en marcha (otro epoch), el host nuevo sigue con su copia (Anfitrion::desde con los nodos de los jugadores) y los demás se reconectan con su último seq. Límite conocido: si el host viejo mandó un evento solo a algunos, gana la copia del nuevo.
  • Pantalla: «Con amigos…» abre «Jugar con amigos» (salas-dialog.tsx): playgroups con su punto de en línea, invitar, unirse con un código, sala nueva; la sala de espera (sala-espera.tsx) enseña el deck de cada uno y cómo llega esta app a él (ms, directo o por relé). Eventos: salas://conexion, salas://playgroups, salas://sala, salas://host, salas://fuera, salas://enlaces.
  • URL del servidor: red::servidor::url(): PRODUCCION (aún no hay: el diálogo dice que no hay servidor y remite al código directo); en debug, PB_SERVIDOR la cambia.

En la mesa, los jugadores (estado-red.tsx::SelectorTablero) cambian de tablero (mesa-store.mirando, tableroDe); la mano de un rival sale como reversos. La barra quita el sparring y, a quien no es host, reiniciar y terminar; los espectadores tienen el registro, el chat (solo para leer: «Los espectadores no pueden escribir.»), los atajos, la disposición de los tableros, la pantalla completa y salir.

Despliegue

server/docs/despliegue.md (probado en local con nginx, una CA de prueba e iroh-relay 1.4.1): unidades de systemd para el servidor (127.0.0.1:8787) y el relé (127.0.0.1:3340, HTTP; nginx hace el TLS), nginx para juego.DOMINIO (/v1/ws, /healthz, /relay/ denegado) y relay.DOMINIO, el token compartido del callout, cortafuegos (solo 80/443) y un timer de copia diaria de SQLite. El descubrimiento de direcciones por QUIC (necesita TLS en el relé) queda apagado.

Varias instancias en un equipo

Solo en debug: PB_PERFIL=<nombre> da a la instancia sus propios datos (app_data_dir/perfiles/<nombre>), caché, datos de la vista web e identidad, sin window-state y con el perfil en el título de la ventana. Con PB_RELAY=none (red local) y, para las salas, el servidor local (cd server && pnpm dev) más PB_SERVIDOR=ws://127.0.0.1:8787/v1/ws. Ver Preparación del entorno de desarrollo.

Lo que falta

  • Pedir imágenes entre invitados por la malla (van por el host).
  • En una partida en red el anfitrión no puede ocupar otro asiento que el primero (el anfitrión es el jugador 0 en Anfitrion, Sesion y el protocolo), y el formato es el de crear la partida. Tras un traspaso del anfitrión no se puede abrir la siguiente partida de un «al mejor de» (sin_mazos): el nuevo anfitrión no tiene los mazos de todos (cada jugador conserva solo el suyo).

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally