Formato basado en Keep a Changelog.
- Fase 6: mejoras técnicas, bots y expansiones (ver ROADMAP.md)
- Motor de bots (IA) heurístico con proyección, jugable en partidas locales (pass-and-play) y LAN (issue #47). Un solo motor configurable (
BotConfig/BotWeights: pesos, cantidad de determinizaciones sobre información oculta, temperatura) en vez de dos estrategias fijas separadas — la dificultad sale de variar la configuración sobre el mismo núcleo.GameRules.legalActions(nuevo, no existía antes: el motor solo sabía rechazar una acción ya construida) enumera las acciones legales de un jugador;Determinizermuestrea la información oculta (mazo no revelado + manos rivales) sin dejar pasar nunca la identidad real de una carta rival al evaluador. El host agrega/quita bots desdeLobbyScreenantes de arrancar la partida. Bots en modo online (backend Go,cards_game_service) quedan para una issue futura y separada — detalle completo endocs/ARCHITECTURE.md, sección "Motor de bots"
TurnManager.advancepodía dejar el turno trabado para siempre en un jugador recién eliminado, si moría (Exploding Kitten sin Defuse) en el primer robo de una cadena de Attack de 2+ — bug preexistente del motor, no específico de bots, encontrado por el fuzz test bot-vs-bot de la feature de arriba
- Soporte multi-idioma español/inglés siguiendo el idioma del sistema (issue #45).
flutter_localizations+intl+flutter gen-l10nnativo, sin selector manual ni paquetes de terceros. Cubre todas las pantallas y widgets depresentation/— home, ajustes, auth (login/registro/cuenta), reglas, lobby (único archivo que estaba en inglés, ahora traducido de verdad), pantalla de juego y fin de partida, y todos los overlays de la mesa (Nope, esconder bomba, Favor, See the Future, explosión). Incluye pluralización ICU real donde antes había un "(s)" fijo o un plural sin manejar. Quedan fuera a propósito los mensajes de error deWsServer(sinBuildContexten esa capa), el nombre de jugador por defecto y los nombres de carta — detalle endocs/ARCHITECTURE.md, sección "Localización"
- Login con correo/contraseña vía Supabase Auth (issue #27).
SupabaseAuthServiceganasignUpAndLinkAnonymous(vincula correo/contraseña a la sesión anónima activa conupdateUser, conserva el mismoplayerId/historial en vez de crear una cuenta aparte),signInWithPassword(reemplaza la sesión anónima por la de una cuenta ya existente),signOutyresetPasswordForEmail. Tres pantallas nuevas —AccountScreen,SignUpScreen,LoginScreen— accesibles desde una sección "Cuenta" nueva en Ajustes, solo si Supabase está configurado; la app sigue arrancando 100% invitada por default. Login con Google queda para una issue aparte
- Revancha jugable en modo online (issue #35, Stage C — última de las tres etapas, ver 0.7.3 para las dos anteriores).
cards_game_service(onStartGame) ahora acepta el mismo mensajestart_gamede siempre también con la sala enphaseFinished, no solophaseWaiting— mismo roster, reseteamatchFinalizedpara que la revancha se registre por su cuenta.GameOverScreendeja de mostrar el aviso de "no disponible": el botón queda igual en los dos modos, pero en online_rematch()manda la acción por red en vez de arrancar un motor local — la navegación la dispara el mismo listener deremoteGameProviderque ya usa cualquier no-host
- Un corte de conexión con la sala ya en
phaseFinishedexpulsaba al jugador de la sala (o, si era el host, la cerraba entera) — nadie podía pedir la revancha después de una desconexión momentánea en la pantalla de resultados. Ahora ese caso solo libera la conexión, sin tocar el roster
- Reportado por el usuario, seguía pasando pese al fix de la 0.7.2 (esa era una carrera de timing distinta, solo de modo LAN): en modo online, todos los jugadores menos el host quedaban trabados para siempre en "Repartiendo cartas…". Causa real:
gameNetworkBridgeProvidersolo se activa con unWsServerlocal, yOnlineLobbyRepository.wsServersiempre esnull(la sala vive encards_game_service, no en el dispositivo) — el host nunca retransmitía nada.GameScreen/GameOverScreenahora deciden "quién corre el motor" conisHost && !isOnlineen vez de soloisHost: en modo online, ni siquiera el host correGameEnginelocalmente, refleja el servidor autoritativo igual que cualquier no-host
online_wire_codec.dart: traduce elViewredactado que mandacards_game_service(manos rivales ocultas, sinConfig) alGameStatede Dart, y recodifica las acciones salientes — hacía falta porqueCardType/TurnPhasevan ensnake_casedel lado Go pero el.namenativo de un enum Dart escamelCase, un mismatch que revienta o corrompe en silencio cualquier carta multi-palabra o media docena de fases de turno- Botón "Revancha" deshabilitado con aviso explícito en modo online — el backend todavía no tiene forma de reiniciar una sala terminada
- Trío de gatos a ciegas no resuelve bien en modo online — cards_game_service#9
- Revancha en modo online, sin soporte de backend — cards_game_service#10
- Reportado por el usuario: al iniciar la partida, un jugador no-host podía quedar trabado indefinidamente en "Repartiendo cartas…". Causa:
WsClient.messageses un stream broadcast sin replay, y el host arranca el motor y transmite el primerGameStatecasi al instante (todo local, vía loopback), mientras el cliente remoto recién se suscribe después de navegar y montarGameScreen— un viaje de red real que en modo online puede tardar más que ese primer broadcast, perdiendo el mensaje para siempre.WsClientahora cachea el últimoGameStateMessagerecibido (lastGameState, mismo patrón que ya existía paralastRoom/RoomStateMessage) yRemoteGameNotifier.listenTo()lo aplica de inmediato al suscribirse
- Drag & drop en
PlayerHandWidget, además de la selección por tap: soltar una carta jugable de inmediato (Skip/Attack/Shuffle/See the Future) sobre elDragTargetde mazo/descarte la juega directo, sin pasar por el botón "Jugar". Soltar cualquier otra carta (o una jugable fuera de esa zona) simplemente la selecciona, igual que un tap — Favor y par/trío de gatos siguen su flujo de selección + overlay de objetivo sin cambios
- Mitad cliente del modo online (jugar contra
cards_game_servicedesplegado por Internet, en vez de solo LAN):OnlineConfig(ONLINE_SERVER_URL),WsClient.connectToUripara conectar a una Uri completa con path,OnlineRoomsClient(POST /rooms),OnlineLobbyRepository(nueva implementación deILobbyRepositorycontra el backend remoto) y un selector explícito LAN/Online enLobbyScreen— elegido por el jugador cada vez, no automático según la sesión de Supabase, para no forzar a jugadores de la misma red WiFi a pasar por Internet sin haberlo pedido. El host online ve el código de sala (copiable) en el header en vez de la IP de WiFi; unirse online pide ese código por diálogo en vez de escanear mDNS package:httpcomo dependencia nueva paraOnlineRoomsClient
Verificado a mano contra el backend Go real antes de mergear, además de los tests automatizados contra dobles (WsServer propio, MockClient/mocktail) — pasos en docs/VERIFICATION_LOG.md, sección "Fase 7 — Modo online del lado cliente"
- Mitad cliente de la identidad persistente de Fase 7 (autenticación con Supabase): nueva feature
auth/(SupabaseAuthService,authServiceProvider/authSessionProvider, sign-in anónimo en la primera lectura),JoinRoomMessage.authTokenopcional,WsClientlo manda en el join inicial y en cada reconexión automática, yplayerIdProviderprefiere elplayerIdde la sesión autenticada sobre el UUID de invitado. Sin.envconfigurado (o con Supabase deshabilitado), la app sigue funcionando 100% en modo invitado, sin cambios de comportamiento — verdocs/ARCHITECTURE.md, sección "Autenticación con Supabase", para el diseño completo y qué queda explícitamente fuera de esta fase (conectar acards_game_servicepor Internet,wss://, vincular cuenta anónima a una real) supabase_flutter+flutter_dotenv,.env.exampleySupabaseConfig(isConfigureddecide simain.dartllama aSupabase.initialize)
- Dos bugs del propio boceto de diseño en
docs/ARCHITECTURE.md:currentSession ?? signInAnonymously()mezclabaAuthSessionconFuture<AuthSession>sin tipar correctamente, ySupabaseConfig.url/anonKeyllamaban adotenv.get()sin verificar quedotenv.load()ya se hubiera ejecutado (lanzaba en cualquier test, que nunca corremain())
.envestá declarado como asset enpubspec.yamlpero no se versiona; ambos workflows de CI copian.env.examplea.envantes de correr comandos de Flutter para evitar unasset_does_not_existen un checkout limpio. Los builds de release quedan en modo invitado hasta cablear credenciales reales como paso aparte
- Fuentes reales (licencia OFL) reemplazando el placeholder vacío
ExplodingFont-Regular.ttf:BangersparaAppTextStyles.headline(letras estilo cómic),Baloo2para el resto del tema (title/body/caption/cardLabel, legible en tamaños chicos) — verassets/fonts/ATTRIBUTION.md
- Timer de 30 segundos por turno: si el jugador activo no actúa a tiempo, se le roba una carta automáticamente (mismo efecto que un robo manual, termina el turno con normalidad). El cronómetro es único por turno completo — jugar cartas de acción a mitad de turno (Skip, Attack, Favor, par/trío de gatos, Shuffle, See the Future) no lo reinicia, solo pasar el turno a otro jugador lo hace
TurnTimerBar: cuenta regresiva visual en la mesa de juego, puramente cosmética (igual queNopeWindowOverlay), visible tanto para el host como para los clientes no-host sin cableado adicional
- Diseño responsivo en
GameTableView: árboles diferenciados para portrait y landscape (context.isLandscape) en vez de un únicoColumn/Rowcon reflow — en landscape la mano ya no compite por altura anclada abajo a todo el ancho, pasa a una columna propia junto al bloque mazo/descarte - Ancho de carta de mano y separación mazo/descarte ahora escalan por escenario (
LayoutConstants, phone portrait/landscape y tablet víacontext.isTablet, medido contra el lado corto de la pantalla para que rotar un phone no cruce el umbral por accidente)
CardWidgetpodía desbordar en tarjetas muy angostas (mano en landscape phone): el ícono y la etiqueta ahora van dentro de unFittedBoxque absorbe el desborde vertical sin perder el wrap a 2 líneas del texto
- Animaciones de mesa que faltaban (reportado por el usuario: robar y mezclar no animaban nada):
GameTableViewahora se puede suscribir al mismoStream<GameEvent>que ya usaGameSoundController, fuente necesaria porque un diff deGameStateno alcanza para detectar mezclar (no cambia nada renderizable) ni para distinguir un robo propio de una carta ganada por Favor/pareja/trío - El mazo hace un bamboleo corto al mezclar (
DeckShuffledEvent) y un pulso más corto y distinto al robar (CardDrawnEvent) - La carta recién robada aparece con una animación de entrada (fade + slide) en la mano local, sin confundirse con las que llegan por Favor/pareja/trío
- La pila de descarte transiciona con un fundido al cambiar de carta arriba (antes cambiaba de golpe)
- El resaltado de turno en la HUD de jugadores ahora transiciona suavemente en vez de cambiar de golpe
- La pantalla de fin de partida tiene una entrada escalonada (ganador, ranking, botones)
- Las cartas de la mano (y las candidatas del selector de Favor/trío) no tenían
Keypor id: al quitar una carta del medio, Flutter podía reusar el estado (y la animación de flip en curso) de la carta equivocada
- Animar el robo de carta por Favor/pareja/trío queda para otra tanda: requiere un nuevo
GameEventengame_engine/(con su serialización de red), no solo cambios de UI
- Reportado por el usuario: no quedaba claro en la HUD de jugadores quién tiene el turno.
PlayersHudWidgetahora resalta al jugador activo con un anillo de color y una flecha indicadora sobre su avatar, además del cambio de color de fondo que ya existía
- Trío de gatos jugable: el actor elige a ciegas (boca abajo, sin ver el tipo) una carta de la mano del objetivo, con el mismo mecanismo que se acababa de construir para Favor (
TurnPhase.awaitingCardChoice+ChooseCardAction, pero eligiendo el actor en vez del objetivo). Estaba diferido desde Fase 4 porque el diseño anterior (PlayCatTrioAction.chosenCardIdfijado al jugar la carta) nunca fue viable: el actor no puede ver la mano rival antes de jugar
- Reportado por el usuario: tras jugar Favor, nunca aparecía nada para que el objetivo eligiera qué carta entregar.
ActionProcessorrobaba una carta al azar (_stealRandomCard), igual que un par de gatos, aunquedocs/GAME_RULES.mdya documentaba (incorrectamente) que el objetivo elegía. Ahora una nueva faseTurnPhase.awaitingCardChoice+ChooseCardAction(genérica a propósito, no específica de Favor) espera la elección real del objetivo antes de resolver
SettingsScreenahora lee la versión real del build conpackage_info_plus(appVersionProvider) en vez de un string hardcodeado que había que actualizar a mano en cada commitchore(version)— este es el último bump que lo necesitó
- Eliminado
AppConstants.discoveryPort, sin uso desde la migración a mDNS real de la versión anterior
MdnsAdvertiser/MdnsDiscoverermigrados de un broadcast UDP propio a mDNS/DNS-SD real vía el paquetensd(Bonjour en Apple, NsdManager en Android). De paso resuelve elWifiManager.MulticastLockde Android 10+ (lo manejansd_androidinternamente) y vuelve moot la validación dehostAddresscontra spoofing (la dirección ahora la resuelve el propio protocolo mDNS, no un campo autoreportado)- Sin verificar en un dispositivo real —
nsdes enteramente nativo; los tests mockeanNsdPlatformInterfacey solo cubren la lógica propia.flutter build apk --debugcompila bien, pero eso no prueba que el descubrimiento funcione en la práctica. Ver la nota en ROADMAP.md
MdnsDiscovererconfiaba en elhostAddressautoreportado de un beacon; ahora siempre usa la IP de origen observada por el socket, cerrando el caso de un beacon con una IP mentirosa o mal configurada
LobbyRepositorymezclaba estado de host (_advertiser,_playerCountSub) y de cliente (_discoverer) como campos planos en la misma clase. Se extrajeronHostBeaconSync/ClientRoomDiscovery; sin cambios enILobbyRepositoryni en ningún consumidor
MdnsAdvertiservolvía a anunciar elplayerCountoriginal de la sala cadainterval(~3s), pisando cualquierupdatePlayerCount()posterior — elTimer.periodiccapturaba una única foto del estado en vez de leerlo actualizado en cada tick. De paso,updatePlayerCountya no repiteroomId/hostName(cierra el TODO correspondiente)
docs/ARCHITECTURE.mdseguía describiendo Fase 5 (red/reconexión) como pendiente pese a estar completa hace tiempo, con un diagrama de fases de turno que mostraba transiciones que el motor nunca ejecuta (drawRequired/ended) y un ejemplo de Riverpod con codegen que no coincide con el patrón manual real del proyecto — todo corregidodocs/VERIFICATION_LOG.mdyROADMAP.mdactualizados: el bug del "servidor fantasma" y la música de menú ya no figuran como pendientes, ambos se resolvieron en commits anteriores de esta rama
- Reportado por el usuario: la música de fondo seguía sonando al cerrar o minimizar la app.
Appahora observa el ciclo de vida (WidgetsBindingObserver) y pausa la música enpaused/detached/hidden, reanudándola enresumed.IAudioServicesumapauseMusic()/resumeMusic()
- Este bug se volvió mucho más notorio tras la música de menú agregada en 0.5.7 (antes solo sonaba durante una partida activa)
- El
playerIddel jugador local ahora se persiste conshared_preferences(playerIdProvider), en vez de generarse de nuevo en cada rebuild — sin esto, reconectar tras un crash siempre parecía un jugador nuevo para el host, que empareja las reconexiones porplayerId - Música de menú (
AssetPaths.musicMenu) ahora suena en Splash/Home/Lobby/Ajustes vía el nuevoMenuMusicMixin— antes soloGameScreen/GameOverScreentenían música. A diferencia de esas dos pantallas, no se corta al salir de cada una (todas piden la misma pista yAudioServiceno reinicia una que ya está sonando), así que navegar entre menús no corta ni reinicia la música
HomeScreenySplashScreenpasaron deStateless(Widget)aConsumer(Stateful)Widgetpara poder alojar el mixin de música
MdnsDiscovererno eliminaba nunca una sala descubierta: si el host cerraba la app sin un cierre limpio, la sala quedaba listada en "Unirse a sala" indefinidamente. Ahora se poda cualquier sala cuyo último beacon tenga más destaleAfter(10s por defecto, ~3 beacons perdidos)
- Quitado un TODO obsoleto en
LobbyRepository:_cleanup()ya cancelaba la suscripción que mencionaba, la nota nunca se actualizó
- Revisión de bugs y pendientes documentados de Fase 1 a 5: "Volver al menú" en
GameOverScreenahora llamaleaveRoom()antes de navegar — cierra el "servidor fantasma" reportado en Fase 5 (crear/unirse a una sala nueva sin salir de la anterior dejaba elWsServer/conexión previos vivos y el host nunca veía al jugador nuevo) - El overlay de
See the Futurese le mostraba a los dos jugadores en red, no solo a quien jugó la carta (hallazgo de la verificación de Fase 5); ahoraGameTableViewlo filtra por turno
docs/GAME_RULES.mdsincronizado conROADMAP.md(ya no lista como pendiente la ventana de Nope ni el grace period de reconexión, ambos completos hace tiempo) y actualizado con el estado real del trío de gatos (motor completo, sin UI todavía)
- Pantalla "Cómo jugar" accesible desde el menú principal: explica en criollo el objetivo, el reparto inicial, el turno y cada una de las 13 cartas del juego (con su arte real o placeholder), aclarando que el trío de gatos todavía no está soportado en esta versión
- Reportado por el usuario: el turno se quedaba "pegado" después de que le robaran una carta (Favor/par de gatos).
GameRules.validatesolo chequeaba la fase del turno paraPlayCardAction/PlayFavorAction/NopeAction—DrawCardAction, pares/tríos de gato yDefuseBombActionno tenían ese candado. Un robo que llegaba justo cuando se abría una ventana de Nope (por latencia de red) pasaba la validación igual, y al limpiarpendingActioncancelaba de paso elTimerque iba a resolver el Favor/par pendiente — se perdía sin resolverse. Las cuatro acciones ahora exigen la fase de turno correcta
- Reportado por el usuario: quedarse solo con cartas de gato sueltas (sin pareja) se sentía como que el juego se congelaba.
CardRules.canPlayahora rechaza una carta de gato jugada sola víaPlayCardAction(antes pasaba la validación igual, se descartaba sin efecto y el turno no avanzaba — un agujero negro solo evitado por la UI); y el mensaje de la barra de selección ahora indica explícitamente que se puede tocar el mazo para robar y pasar el turno
- Verificación manual de Fase 5 en 2 emuladores reales (ver
docs/VERIFICATION_LOG.md): estado real jugado y sincronizado en ambos sentidos,InsertBombOverlay/ExplosionOverlay/GameOverScreenconfirmados en el no-host, y el pipeline completo de grace period + eliminación por desconexión probado de punta a punta con una caída real del proceso GameOverScreenno navegaba al no-host cuando el host iniciaba una revancha — se quedaba varado mostrando "no hay ningún resultado de partida". Ahora escucharemoteGameProvidery navega a la partida nueva en cuanto deja de estar enGameFinished
- Recrear una sala sin salir de la anterior en la misma sesión de app dejaba un "servidor fantasma" (el
WsServerviejo nunca se cerraba) — bug real del ciclo de vida del lobby (Fase 3), no de la sincronización de Fase 5; candidato para una futura sesión
- Con las piezas de las versiones 0.4.2 a 0.4.10 (serialización del motor, transporte en
WsServer/WsMessage,ReconnectionManager, eliminación por desconexión en el motor,RemoteGameNotifier, el puente host↔red,GameScreen/GameOverScreensincronizados y la reconexión automática deWsClient) se cierra la Fase 5: los dispositivos no-host ya juegan la partida real sincronizada por WebSocket, no solo el host. - 200 tests totales pasando
Con esto se cierra la Fase 5 — Red y reconexión. Decisión de diseño clave: en vez de forzar un RemoteGameGateway dentro de la interfaz síncrona IGameGateway existente (hubiera obligado a convertir apply()/startGame() a streams y reescribir los tests ya existentes de GameNotifier), se optó por RemoteGameNotifier como clase separada que refleja el mismo GameSessionState — cero riesgo sobre lo construido en Fase 4. Queda pendiente, documentado en ROADMAP.md, todo lo de Fase 6 (mDNS real, bots, modo online, expansiones, publicación).
WsClientreconecta solo con back-off exponencial (1s→16s) tras una caída no solicitada (close()explícito no la dispara);messages/status/roomStreamsiguen funcionando sin que quien los escucha tenga que volver a suscribirse
WsServer.close()no cerraba los sockets ya conectados (HttpServer.close(force: true)no toca las conexiones que ya completaron el upgrade a WebSocket) — se filtraban sin avisar a los clientes del cierre; ahora se cierran explícitamente- 200 tests totales pasando
GameScreen/GameOverScreenya no muestran el placeholder fijo "esperando Fase 5" para los no-host: eligengameProvider/remoteGameProvidersegúnisHosty despachan a quien corresponda; el host activagameNetworkBridgeProvider, el no-host conectaRemoteGameNotifieralWsClientque ya abrió el lobbyPlayersHudWidgetmuestra "Reconectando…" + icono de wifi apagado paraPlayerStatus.disconnected- 198 tests totales pasando
gameNetworkBridgeProvider— conectaGameNotifier(motor real, solo host) conWsServer(red): aplicaActionMessages entrantes, contestaActionRejectedMessagesolo a quien mandó una acción inválida, y retransmite cadaGameState/GameEventcomoGameStateMessage/GameEventMessage; conecta tambiénWsServer.onPlayerDisconnected/onPlayerReconnecteda unReconnectionManagerIGameGateway/LocalGameGateway/GameNotifiergananmarkPlayerDisconnected/markPlayerReconnected
WsServer._onJoindisparabaonPlayerReconnectedpara cualquier unión de unplayerIdya conocido (no solo reconexiones reales tras una caída);GameNotifier.markPlayerDisconnected/markPlayerReconnectedahora omiten la retransmisión si el motor devolvió el mismo estado (no-op), encontrado por el test de integración del puente- 196 tests totales pasando
RemoteGameNotifier/remoteGameProvider— refleja para un dispositivo no-host el mismoGameSessionStateque ya produceGameNotifier, mandando las acciones locales porActionMessagey reflejandoGameStateMessage/GameEventMessage/ActionRejectedMessage; se decidió como clase separada (no unaRemoteGameGateway implements IGameGateway) para no tener que convertirapply()/startGame()a streams ni reescribir los tests existentes deGameNotifierGameNotifierganaapplyAction/rawStates/eliminateForDisconnectpara el puente host↔red (próxima pieza);IGameGatewayganaeliminatePlayerForDisconnect, mismo patrón queresolveNopeWindow- 188 tests totales pasando
ILobbyRepository/LobbyRepository/LobbyNotifierexponen ahorawsClient/wsServer(getters aditivos, sin cambio de comportamiento) para que la partida reutilice la conexión que el lobby ya abrió en vez de crear una segunda
GameEngine.markPlayerDisconnected/markPlayerReconnected/eliminatePlayerForDisconnect— tres operaciones puras más (ninguna es unTurnAction, no pasan porGameRules.validate, mismo patrón queresolveNopeWindow) para que elReconnectionManagertenga con qué actuar cuando expira el grace period: reutiliza el mismo camino deeliminationOrder/WinConditionque ya usa la eliminación por bomba- 170 tests totales pasando
ReconnectionManagerreal (antes era solo un comentarioTODO): unTimerde grace period por jugador desconectado, usandoGameConstants.reconnectTimeoutSeconds(60s) por defecto;cancelIfPendinglo cancela si reconecta a tiempo- 162 tests totales pasando
WsMessage: activados los mensajes de partida (GameStateMessage,ActionMessage) que antes eran stubs sin usar, y añadidosGameEventMessage(para que los no-host, sin motor local, disparen sonidos/animaciones) yActionRejectedMessage(feedback dirigido cuando una acción de un cliente fallaGameRules.validateen el host)WsServerahora enrutaActionMessagede verdad (antes caía aldefaulty se perdía) por un nuevo streamactionMessages, y exponebroadcast()/sendToPlayer()públicosWsServer.markGameStarted()distingue una desconexión de lobby (comportamiento de siempre) de una desconexión a mitad de partida, que ahora disparaonPlayerDisconnecteden vez de sacar al jugador de la sala — la capa de juego decide qué hacer (grace period, siguiente pieza de la fase)- 156 tests totales pasando
toJson()/fromJson()manuales en todos los modelos puros del motor (CardModel,PlayerModel,DeckModel,TurnModel,GameConfig,GameResult,GameState) y en las dos jerarquías selladas (TurnAction,GameEvent), mismo estilo ya usado por los modelos del lobby — necesario para que elGameState/las acciones/los eventos viajen por WebSocket en el resto de la faseTurnActionahora extiendeEquatable(era el único modelo del motor que no lo hacía); sin esto, la igualdad estructural deGameStatese rompía en cuantopendingActioncontenía una instancia no-const(p. ej. una reconstruida desde JSON)- 149 tests totales pasando
SettingsScreenmostraba la versión hardcodeada0.1.0desde el primer release, nunca actualizada en los bumps posteriores — ahora dice0.4.0
flutter_animateen cartas y overlays:CardWidgethace un "pop" de escala (.animate().scaleXY(...)) al volverse jugable; los 5 overlays de la mesa (SeeTheFutureOverlay,FavorTargetOverlay,NopeWindowOverlay,InsertBombOverlay,ExplosionOverlay) ahora tienen un fade-in de entrada — mismo estilo.animate()que ya usabanHomeScreen/SplashScreen- Ajustados los tests de
game_table_view_test.dartysee_the_future_overlay_test.dartpara asentar (pumpAndSettle) las animaciones antes de verificar, siguiendo el mismo patrón quehome_screen_test.dartya usaba —flutter_animatedeja unTimercorriendo internamente yflutter_testfalla si queda pendiente al terminar el test - 118 tests totales pasando
Con esto se cierra la Fase 4 — Pantalla de juego completa. Los 4 overlays de interacción (Nope, InsertBomb, Favor/pares, SeeTheFuture), ExplosionOverlay, GameOverScreen con ranking y revancha, audio (efectos + música) y animaciones están implementados sobre el motor de la Fase 1. Quedan fuera de esta fase, documentados como pendientes: trío de gatos (necesita su propio diseño de UI para elegir carta de la mano rival), música de menú fuera de la pantalla de juego, y por supuesto toda la sincronización real por red (Fase 5).
GameNotifier— un test por método de intención (playCard,playFavor,playCatPair,playCatTrio,playNope,defuse) que verifica elTurnActionconcreto construido y sus campos, más un test del nuevo getterevents. Antes solo se probaba el camino genérico de_apply(error/éxito/fin de partida) a través dedrawCard, sin confirmar que el resto de métodos arman la acción correcta- 118 tests totales pasando
IAudioService/AudioService(core/audio/) — dos reproductores independientes (efectos vs. música en loop) sobreaudioplayers; los fallos de reproducción se capturan y loguean en vez de propagarse, para que un audio faltante o sin salida de sonido no interrumpa la partida. Expuesto víaaudioServiceProvider, sustituible por un fake en tests de widgetsGameSoundController— se suscribe alStream<GameEvent>del motor (nuevo getterGameNotifier.events) mientras dura la partida y reproduce el efecto de cada evento (soundAssetFor, función pura testeada aparte): robar, jugar carta (Attack tiene su propio clip), barajar, bomba activada, Defuse, Nope, fin de partida.PlayerEliminatedEventno suena aparte a propósito — se emite junto aBombTriggeredEventen el mismo instante y sonarían duplicadosGameScreenreproducemusic_ingame.mp3yGameOverScreenreproducemusic_gameover.mp3en loop mientras están montadas, resincronizando volumen/activado cuando cambian los ajustes- Corrección de
AssetPaths: los nombres de sonido no coincidían con los archivos reales deassets/sounds/(card_draw.mp3→draw_card.mp3,explosion.mp3→explode.mp3, etc.); Defuse y "jugador eliminado" reusancountdown.mp3/explode.mp3porque no tienen clip propio todavía (verATTRIBUTION.md) - 4 tests nuevos (
soundAssetFor+GameSoundControllercon unIAudioServicefake) — 111 tests totales pasando - Fuera de alcance esta vez: música de menú (
music_menu.mp3) en Home/Splash/Lobby/Settings — queda anotada en el Roadmap (Fase 6), no es parte de "pantalla de juego completa"
GameOverScreenahora es unConsumerWidgetreal: muestra el nombre del ganador, turnos jugados y el ranking completo (ganador primero, luego el orden inverso de eliminación real — el último en explotar queda 2º, el primero en explotar queda último), cruzandoGameResult.eliminationOrdercon los nombres delobbyProvider- Botón Revancha solo para el host (mismo límite que
GameScreen: solo el host corre elGameEnginereal hasta la Fase 5); re-arranca la partida constartLocalGamereusando los jugadores actuales de la sala y el mismoGameEventBus - Ruta
/game/overdeja de depender de un query paramwinnerId— lee el resultado directo degameProvider(GameFinished), evitando duplicar estado que ya vive en el provider - 3 tests nuevos (
GameOverScreen: sin resultado, ranking + revancha visible para el host, oculto para no-host)
WinCondition.checkconstruíaGameResult.eliminationOrderfiltrandoGameState.players, que sigue el orden de la lista, no el orden real en que los jugadores explotaron — un bug ya detectado durante la planificación de Fase 4 pero sin arreglar hasta queGameOverScreenlo necesitó de verdad.GameStategana un campoeliminationOrderqueActionProcessor._eliminatePlayerva llenando en el momento exacto de cada eliminación;WinConditionsolo lo reexpone- 1 test nuevo (
ActionProcessor: el orden de eliminación es cronológico, no el de la lista de jugadores) — 106 tests totales pasando
ExplosionOverlay— animación de eliminación (jugador robó Exploding Kitten sin Defuse): ícono con escala y rebote (Curves.elasticOut) sobre fondo oscuro, se cierra sola después de 1.6s sin necesitar ninguna acción del jugador. Placeholder con Flutter puro; se reemplaza por el Lottie real deAssetPaths.animExplosioncuando ese asset exista (assets/animations/sigue vacío)GameTableViewdetecta la eliminación por diff entre elGameStateanterior y el nuevo (un jugador que estaba vivo deja de estarlo) — no hay ningún evento ni campo dedicado enGameStatepara esto, mismo enfoque que ya usa el descarte deSeeTheFutureOverlay- 2 tests nuevos (
GameTableView: overlay aparece al detectar la eliminación; se cierra solo tras la animación, usandopumpAndSettle) — 102 tests totales pasando
InsertBombOverlay— se muestra mientrasTurnModel.phase == TurnPhase.resolvingyGameState.pendingBomb != null, solo al jugador que robó la bomba;Sliderpara elegir la posición de reinserción entre 0 (arriba del todo, la próxima carta que se robaría) ydrawPileCount(abajo del todo) — el motor (DeckManager.insertAt) ya clampaba cualquier valor, así que no hace falta validación extra en la UIGameTableViewgana el callbackonDefuseBomb, conectado enGameScreenagameProvider.notifier.defuse(...)(ya existía en el notifier, sin cambios de engine); toma la primera carta Defuse de la mano, garantizada por el invariante del motor (solo se llega aresolvingsi el jugador tiene Defuse)- Banner de estado actualizado: mientras se resuelve la bomba, los demás jugadores ven "Esperando a que <jugador> esconda la bomba…" en vez del texto genérico anterior
- 2 tests nuevos (
GameTableView: overlay se muestra y confirma con la posición elegida; no se muestra al jugador que no tiene el turno) — 100 tests totales pasando
NopeWindowOverlay— se muestra mientrasTurnModel.phase == TurnPhase.nopeWindow; barra de progreso puramente visual (no hay timestamp de apertura enGameState, así que el conteo es client-side y se reinicia cada vez que cambianopeChainCount, igual que elTimerreal delGameNotifier) y botón "¡Nope!" habilitado solo si el jugador local tiene una carta Nope en mano y existependingAction— misma condición queCardRules.canNopeen el motorGameTableViewgana el callbackonPlayNope, conectado enGameScreenagameProvider.notifier.playNope(...)(ya existía en el notifier, sin cambios de engine)- 2 tests nuevos (
GameTableView: overlay se muestra y juega el Nope de la mano; botón deshabilitado sin Nope en mano) — 98 tests totales pasando
FavorTargetOverlay— selector de jugador objetivo, usado por Favor (1 carta) y pares de gato (2 cartas del mismo tipo); ambos solo necesitan un objetivo, no una carta específica de la mano rivalGameTableViewpasa de selección de una sola carta a selección múltiple (Set<String>), con una clasificación explícita de qué se puede hacer con lo seleccionado: jugar directo (Attack/Skip/Shuffle/SeeTheFuture), elegir objetivo (Favor/par), esperar la pareja (una sola carta de gato), o "se juega en otro momento" (Nope/Defuse sueltos, trío de gatos)- Trío de gatos queda diferido a propósito: el motor requiere que el actor indique
chosenCardId, una carta concreta de la mano del rival que no puede ver en este diseño (un dispositivo por jugador) — necesita su propio diseño de UI, no se fuerza uno ad-hoc - 6 tests nuevos (
GameTableView: hint de par, flujo Favor completo, flujo par de gatos completo, cancelar limpia selección) — 96 tests totales pasando
SeeTheFutureOverlay— muestra las 3 cartas de arriba del mazo (de arriba hacia abajo) con los placeholders deCardVisuals, y un botón "Continuar" para cerrarloGameTableViewderiva su visibilidad deGameState.seeTheFutureCards(sin nuevo estado en el provider); el "descartado" es estado local de UI, porque el campo solo se limpia cuando el turno avanza (TurnManager.advance), no cuando el jugador ya lo vio — una revelación nueva (null → no-null) vuelve a mostrarlo aunque la anterior ya estuviera cerrada- 4 tests nuevos (
SeeTheFutureOverlay+ integración enGameTableView) — 92 tests totales pasando
GameTableView— composición de mesa (HUD + mazo + descarte + mano + banner de estado); solo muestra y controla la mano del jugador local (no la de quien tenga el turno), pensado para que cada dispositivo conectado a la sala sea dueño de un único jugador. Selección de carta por tap y botón "Jugar" para las cartas sin objetivo (Attack, Skip, Shuffle, See the Future); Favor/pares-tríos de gato/Nope/Defuse muestran "se juega en el próximo paso" hasta los overlays de selección de objetivoGameScreenconectado agameProviderylobbyProvider: solo el host arranca hoy elGameEnginereal (víaLocalGameGateway) al entrar a la sala; los no-host ven "Esperando sincronización con el host… (llega en la Fase 5)" — sincronizar el estado real por red es explícitamente Fase 5, no se simula- Navega a
GameOverScreenautomáticamente cuando elGameStatellega aGamePhase.finished - 7 tests nuevos (
GameTableView+GameScreencon lobby/gateway fake) — 88 tests totales pasando
- App compilada y lanzada en un dispositivo Android real (
flutter run): arranca sin errores hastaHomeScreen. No fue posible automatizar taps con ADB (el dispositivo no concedeINJECT_EVENTS), así que el flujo Lobby → GameScreen no se probó de punta a punta en este dispositivo — requiere además un segundo dispositivo real en la misma red para completar el lobby (GameConstants.minPlayers = 2)
CardWidget— carta individual con flip animado (boca arriba/abajo), glow cuando es jugable, borde de selección; usa el placeholder deCardVisualssiassetPathesnullDeckWidget— dorso del mazo con contador de cartas restantesDiscardPileWidget/DottedCardSlot— última carta descartada boca arriba, o hueco vacío si aún no se descartó nadaPlayersHudWidget— avatares de todos los jugadores con nombre y contador de cartas; atenúa a los eliminados y resalta a quien tiene el turnoPlayerHandWidget— mano del jugador local en abanico, selección por tap (no drag & drop, necesario igualmente para elegir pares/tríos de gato)- Todos son widgets "tontos": reciben datos ya resueltos (tipo de carta, ruta de asset, callbacks) y no leen providers — se pueden testear con fixtures sin
ProviderScope - 12 tests nuevos de widgets — 81 tests totales pasando
CardVisuals— apariencia de respaldo (color, icono, nombre) porCardType, usada como placeholder mientras no exista el arte finalCardAssetResolver— resuelve la ruta real de una carta desde elAssetManifestdel bundle si ya existe, onullpara caer al placeholder; permite ir soltando el arte carta por carta sin tocar ningún widget (hoy conassets/cards/vacío, todo resuelve a placeholder)IGameGateway/LocalGameGateway— fachada entre la UI y "la partida"; hoy envuelve unGameEnginelocal, deja el hueco para un gateway remoto en Fase 5 sin cambiar el Notifier ni los widgetsGameNotifier/gameProvider—Notifier<GameSessionState>(GameIdle/GameRunning/GameFinished) con métodos de intención (drawCard,playCard,playFavor,playCatPair,playCatTrio,playNope,defuse); capturaInvalidActionExceptioncomo error transitorio sin romper la UI; agenda y resuelve la ventana de Nope con unTimerinterno (GameConstants.nopeWindowMs) llamando aresolveNopeWindow()- 7 tests nuevos del
GameNotifiercon unIGameGatewayfake — 69 tests totales pasando
- La ventana de Nope nunca se cerraba — no existía ninguna vía para resolverla, y los efectos de Favor, Cat Pair/Trío y Shuffle se aplicaban antes de abrir la ventana, así que un Nope exitoso no podía cancelar nada. Ahora esos efectos se difieren y
ActionProcessor.resolveNopeWindow()(expuesto comoGameEngine.resolveNopeWindow()) los aplica solo si la cadena de Nopes no quedó cancelada (nopeChainCountpar) - Defuse duplicaba o perdía la bomba — al reinsertar la bomba se buscaba cualquier Exploding Kitten restante en el mazo en vez de la carta realmente robada, duplicándola (o perdiéndola si era la última). Ahora se guarda en
GameState.pendingBomby se reinserta exactamente esa carta
TurnManager.closeNopeWindow()/requireDraw()— código muerto sin llamadas, sustituido porActionProcessor.resolveNopeWindow()
GameEngine.dispose()documentado: cierra elGameEventBussingleton de por vida de la app; no debe llamarse desde el ciclo de vida de un provider (p. ej. en una revancha)- 4 tests nuevos de
ActionProcessor(Defuse, Favor diferido, Nope cancela Favor, Shuffle diferido) — 62 tests totales pasando
- Tests de widgets para
HomeScreen(render de título/botones/pie y navegación a crear sala, unirse a sala y ajustes) ySettingsScreen(carga de preferencias persistidas, guardado de nombre de jugador, toggle de sonido) — cierra el ítem pendiente de Fase 2, 58 tests totales pasando
LobbyRoom/LobbyPlayer/LobbyStatus— modelos inmutables del dominio del lobby contoJson/fromJson,copyWith,isFull,canStart(mínimo 2 jugadores, todos los no-host listos) y getterhostDiscoveredRoom— modelo de sala anunciada en la red local, contoJson/fromJsoneisFullWsMessage— sealed class con el protocolo completo del lobby:JoinRoom,SetReady,LeaveRoom,StartGame(cliente→servidor),RoomState,GameStarting,PlayerKicked,WsError(servidor→cliente),Ping/Pong(heartbeat) y stubs de Fase 5 (GameState,Action,PlayerReconnected)WsServer— servidor WebSocket que corre en el host (AppConstants.localGamePort), gestiona elLobbyRoomautoritativo y retransmiteRoomStateMessagetras cada cambioWsClient(sobreweb_socket_channel) — cliente WebSocket usado por todos los jugadores (incluido el host, vía loopback127.0.0.1), con estado de conexión y heartbeat ping/pongMdnsAdvertiser— anuncia la sala en la red local mediante beacons UDP broadcast periódicos (255.255.255.255 :AppConstants.discoveryPort); sincroniza el contador de jugadores en cada cambio de salaMdnsDiscoverer— escucha beacons UDP y exponeStream<List<DiscoveredRoom>>con las salas detectadasILobbyRepository/LobbyRepository— coordinaWsServer+WsClient+MdnsAdvertiser+MdnsDiscovererpara exponercreateRoom,discoverRooms,joinRoom,setReady,startGameyleaveRoomcomo una única fachadaLobbyNotifier/lobbyProvider—Notifier<LobbyState>con estadosLobbyIdle,LobbyConnecting,LobbyDiscovering,LobbyInRoom,LobbyError;playerIdProvider(UUID de sesión) ywifiIpProviderLobbyScreen— UI completa: crear sala, descubrir/unirse a salas en la red, lista de jugadores con estado ready, botón de inicio habilitado solo sicanStart- Permisos de red en Android:
INTERNET,ACCESS_NETWORK_STATE,ACCESS_WIFI_STATE,CHANGE_WIFI_MULTICAST_STATE - 34 tests nuevos del lobby (modelos, repositorio, providers) — 51 tests totales pasando
WsClientmigrado aweb_socket_channel(antes stub propio) para mayor compatibilidad multiplataforma
- El descubrimiento de salas usa beacons UDP broadcast propios, no mDNS/Bonjour real; queda pendiente migrar a la librería
nsdomulticast_dnspara descubrimiento estándar (ver TODOs enmdns_advertiser.dart/mdns_discoverer.dart) LobbyRepositoryunifica los modos host/cliente en una sola clase; se evaluará separarla enHostLobbyRepository/ClientLobbyRepositorysi crece en complejidad
- SplashScreen — animaciones escalonadas con
flutter_animate(scale elástico, fadeIn, slideY); navega a Home trasAppConstants.splashDuration - HomeScreen — menú principal con tema oscuro completo, botones animados y pie de disclaimer
- SettingsScreen — nombre de jugador, volumen y toggles de audio/música con persistencia en SharedPreferences (
AsyncNotifierRiverpod 3) AppSettings— modelo inmutable concopyWithyEquatableen capa domain de settingsSettingsNotifier/settingsProvider—AsyncNotifierProvidercon auto-guardado en SharedPreferences- Pantallas placeholder para Lobby, Game y GameOver (Fases 3 y 4)
- Router completo con las 6 rutas:
/,/home,/lobby,/game,/game/over,/settings
GameEngine.startGame— reemplaza destructuring de record inválido por acceso explícito (result.players,result.deck)ActionProcessor— elimina import deexceptions.dartno utilizadoGameRules— elimina imports deplayer_model.dartynope_rules.dartno utilizadosFailureResult<T>(eraFailure_<T>) — renombrado a UpperCamelCase válidoUnknownFailure— usa super parameter en lugar de inicializador explícitoAssetPaths.card()— elimina interpolación de llaves innecesaria
- Riverpod 3.1 / riverpod_generator 4.0
- GoRouter 17.3
- freezed 3.2 / freezed_annotation 3.1
- network_info_plus 8.2 (plugin Kotlin)
- Estructura de proyecto Flutter con arquitectura feature-first (
com.zenxlk) game_engine/— motor de juego en Dart puro, sin dependencias de Flutter- Modelos inmutables:
CardModel,PlayerModel,DeckModel,TurnModel,GameState CardTypecon todas las cartas de la edición base (incluidas las 5 cartas gato)DeckBuilder— construcción y reparto del mazo según número de jugadores (2–5)DeckManager— operaciones puras:drawTop,insertAt,discard,shuffle,peekTopGameRules— validación de acciones antes de procesarlasCardRules— jugabilidad, pares/tríos de gatos, Nope, DefuseNopeRules— cadena Nope/Nope-a-Nope (contador impar = cancelado)TurnRules— rotación de turno, Attack chains (actionsLeft)WinCondition— detección de único supervivienteActionProcessor— efectos de todas las cartas: Attack, Skip, Favor, Shuffle, See the Future, Cat Pair, Cat Trio, Defuse, NopeGameEngine— fachada pública constartGame()yapply(TurnAction)GameEventBus—Stream<GameEvent>broadcast para desacoplar UI y red
- Modelos inmutables:
core/— constantes, errores, extensiones, tema, router y utilidadesfeatures/— estructura feature-first con splash y home screen funcionalesnetwork/— stubs de WebSocket (cliente, servidor, reconexión) y mensajes tipadosassets/— carpetas placeholder para cartas, sonidos, animaciones y fuentes- CI — GitHub Actions: análisis estático, tests y build APK debug
- GitHub —
dependabot.yml, plantillas de issue y PR,.editorconfig - 16 tests unitarios del motor pasando