diff --git a/lib/estado/estado_juego.dart b/lib/estado/estado_juego.dart index c3f0817..6bae5fe 100644 --- a/lib/estado/estado_juego.dart +++ b/lib/estado/estado_juego.dart @@ -42,6 +42,36 @@ class EstadoJuego extends ChangeNotifier { notifyListeners(); } + /// Máximo de impostores admitido para un número de jugadores dado. + /// Es la misma regla en modo un solo móvil y en multidispositivo. + static int maxImpostoresPara(int numJugadores) => + (numJugadores ~/ 3).clamp(1, 4); + + /// Asigna impostores con un generador seguro y reparte la palabra al resto. + void _repartirRoles( + List jugadores, + ConfigPartida config, + String palabra, + ) { + final rng = Random.secure(); + final numImpostores = config.numImpostores.clamp( + 1, + maxImpostoresPara(jugadores.length), + ); + final impostoresElegidos = {}; + while (impostoresElegidos.length < numImpostores) { + impostoresElegidos.add(rng.nextInt(jugadores.length)); + } + for (final i in impostoresElegidos) { + jugadores[i].esImpostor = true; + } + for (final jugador in jugadores) { + if (!jugador.esImpostor) { + jugador.palabra = palabra; + } + } + } + /// Crea una nueva partida con la configuración dada y lista de jugadores void crearPartida({ required ConfigPartida config, @@ -60,29 +90,14 @@ class EstadoJuego extends ChangeNotifier { return Jugador(id: 'j${e.key}', nombre: e.value); }).toList(); - // Asignar impostores usando Random seguro (no predecible) - final rng = Random.secure(); - final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3); - final impostoresElegidos = {}; - while (impostoresElegidos.length < numImpostores) { - impostoresElegidos.add(rng.nextInt(jugadores.length)); - } - for (final i in impostoresElegidos) { - jugadores[i].esImpostor = true; - } - - // Asignar palabras - for (final j in jugadores) { - if (!j.esImpostor) { - j.palabra = palabra; - } - } + _repartirRoles(jugadores, config, palabra); _partida = Partida( config: config, jugadores: jugadores, palabraSecreta: palabra, categoriaReal: categoriaReal, + pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal), ); _votos.clear(); @@ -117,27 +132,14 @@ class EstadoJuego extends ChangeNotifier { ); }).toList(); - final rng = Random.secure(); - final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3); - final impostoresElegidos = {}; - while (impostoresElegidos.length < numImpostores) { - impostoresElegidos.add(rng.nextInt(jugadores.length)); - } - for (final i in impostoresElegidos) { - jugadores[i].esImpostor = true; - } - - for (final jugador in jugadores) { - if (!jugador.esImpostor) { - jugador.palabra = palabra; - } - } + _repartirRoles(jugadores, config, palabra); _partida = Partida( config: config, jugadores: jugadores, palabraSecreta: palabra, categoriaReal: categoriaReal, + pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal), ); _votos.clear(); @@ -145,6 +147,22 @@ class EstadoJuego extends ChangeNotifier { notifyListeners(); } + /// Nombres del resto de impostores para un jugador dado. + /// + /// Devuelve `null` cuando no hay nada que mostrar (el jugador no es impostor + /// o la partida no permite que se conozcan) y una lista —posiblemente vacía, + /// si es el único impostor— cuando sí procede mostrarlo. + List? companerosImpostoresDe(String jugadorId) { + final partida = _partida; + if (partida == null || !partida.config.impostoresSeConocen) return null; + final indice = partida.jugadores.indexWhere((j) => j.id == jugadorId); + if (indice < 0 || !partida.jugadores[indice].esImpostor) return null; + return partida.jugadores + .where((j) => j.esImpostor && j.id != jugadorId) + .map((j) => j.nombre) + .toList(); + } + /// Avanza a la fase de debate void iniciarDebate() { if (_partida == null) return; diff --git a/lib/modelos/inicio_partida_multijugador.dart b/lib/modelos/inicio_partida_multijugador.dart index f2a2019..ead4ebf 100644 --- a/lib/modelos/inicio_partida_multijugador.dart +++ b/lib/modelos/inicio_partida_multijugador.dart @@ -18,11 +18,19 @@ class JugadorInicioPartida { final bool esImpostor; final String? palabra; + /// Nombres del resto de impostores. + /// + /// `null` significa que no hay nada que mostrar (el jugador no es impostor o + /// la partida no permite que se conozcan). Una lista vacía significa que sí + /// procede mostrarlo y que es el único impostor. + final List? companerosImpostores; + const JugadorInicioPartida({ required this.jugadorId, required this.nombre, required this.esImpostor, required this.palabra, + this.companerosImpostores, }); Map toJson() => { @@ -30,14 +38,20 @@ class JugadorInicioPartida { 'nombre': nombre, 'esImpostor': esImpostor, if (palabra != null) 'palabra': palabra, + if (companerosImpostores != null) + 'companerosImpostores': companerosImpostores, }; factory JugadorInicioPartida.fromJson(Map json) { + final companeros = json['companerosImpostores'] as List?; return JugadorInicioPartida( jugadorId: json['jugadorId'] as String, nombre: json['nombre'] as String, esImpostor: json['esImpostor'] as bool? ?? false, palabra: json['palabra'] as String?, + companerosImpostores: companeros + ?.map((nombre) => nombre.toString()) + .toList(), ); } } @@ -82,9 +96,16 @@ class InicioPartidaMultijugador { required String palabraSecreta, required String categoria, required Map impostoresPorJugadorId, + bool impostoresSeConocen = false, }) { final payloads = {}; + final nombresImpostores = { + for (final asignacion in asignaciones) + if (impostoresPorJugadorId[asignacion.jugadorId] ?? false) + asignacion.jugadorId: asignacion.nombre, + }; + for (final asignacion in asignaciones) { final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false; final payloadActual = payloads[asignacion.clientId]; @@ -93,6 +114,12 @@ class InicioPartidaMultijugador { nombre: asignacion.nombre, esImpostor: esImpostor, palabra: esImpostor ? null : palabraSecreta, + companerosImpostores: esImpostor && impostoresSeConocen + ? (nombresImpostores.entries + .where((entry) => entry.key != asignacion.jugadorId) + .map((entry) => entry.value) + .toList()) + : null, ); if (payloadActual == null) { diff --git a/lib/modelos/palabra.dart b/lib/modelos/palabra.dart index 554a36d..b7f8943 100644 --- a/lib/modelos/palabra.dart +++ b/lib/modelos/palabra.dart @@ -3,13 +3,30 @@ import 'dart:math'; import 'package:flutter/services.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; +/// Una palabra del banco junto con la pista que verá el impostor. +class EntradaPalabra { + final String palabra; + + /// Pista específica de esta palabra. Si es null se usa la de la categoría. + final String? pista; + + const EntradaPalabra({required this.palabra, this.pista}); +} + /// Categorías disponibles en el banco de palabras. class BancoPalabras { final Map> categorias; final Map pistasPorCategoria; - BancoPalabras(this.categorias, {Map? pistasPorCategoria}) - : pistasPorCategoria = pistasPorCategoria ?? {}; + /// Pista por palabra, cuando el banco la aporta. + final Map pistasPorPalabra; + + BancoPalabras( + this.categorias, { + Map? pistasPorCategoria, + Map? pistasPorPalabra, + }) : pistasPorCategoria = pistasPorCategoria ?? {}, + pistasPorPalabra = pistasPorPalabra ?? {}; static final Map _instancias = {}; @@ -37,19 +54,42 @@ class BancoPalabras { final cats = data['categorias'] as Map; final mapa = >{}; final pistas = {}; + final pistasPalabra = {}; for (final entrada in cats.entries) { final valor = entrada.value; + final listaCruda = valor is Map + ? valor['palabras'] as List + : valor as List; + if (valor is Map) { - mapa[entrada.key] = List.from(valor['palabras'] as List); final pista = valor['pista']; if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista; - } else { - mapa[entrada.key] = List.from(valor as List); } + + final palabras = []; + for (final elemento in listaCruda) { + // Formato v2: "Perro". Formato v3: {"palabra": "Perro", "pista": "..."} + if (elemento is Map) { + final palabra = elemento['palabra'] as String?; + if (palabra == null || palabra.isEmpty) continue; + palabras.add(palabra); + final pistaPalabra = elemento['pista']; + if (pistaPalabra is String && pistaPalabra.isNotEmpty) { + pistasPalabra[palabra] = pistaPalabra; + } + } else { + palabras.add(elemento as String); + } + } + mapa[entrada.key] = palabras; } - _instancias[idioma] = BancoPalabras(mapa, pistasPorCategoria: pistas); + _instancias[idioma] = BancoPalabras( + mapa, + pistasPorCategoria: pistas, + pistasPorPalabra: pistasPalabra, + ); return _instancias[idioma]!; } @@ -57,7 +97,7 @@ class BancoPalabras { /// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null). String palabraAleatoria(String? categoria) { - final rng = Random(); + final rng = Random.secure(); if (categoria == null || categoria == 'todas') { final todasPalabras = categorias.values.expand((l) => l).toList(); return todasPalabras[rng.nextInt(todasPalabras.length)]; @@ -77,6 +117,16 @@ class BancoPalabras { /// Devuelve la pista localizada de una categoría si el banco la trae. String? pistaDeCategoria(String categoria) => pistasPorCategoria[categoria]; + /// Pista que verá el impostor para una palabra concreta. Prioriza la pista + /// específica de la palabra y cae a la de su categoría si no existe. + String? pistaDePalabra(String palabra, {String? categoria}) { + final especifica = pistasPorPalabra[palabra]; + if (especifica != null && especifica.isNotEmpty) return especifica; + final clave = categoria ?? categoriaDepalabra(palabra); + if (clave == null) return null; + return pistasPorCategoria[clave]; + } + /// Devuelve el nombre localizado de la categoría usando AppLocalizations. static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) { if (l10n != null) { @@ -132,9 +182,17 @@ class BancoPalabrasTraducidas { final banco = await BancoPalabras.cargar(idioma: idioma); final mapa = >{}; for (final categoria in banco.categorias.entries) { - final pista = banco.pistaDeCategoria(categoria.key) ?? categoria.key; + final pistaImpostor = + banco.pistaDeCategoria(categoria.key) ?? categoria.key; mapa[categoria.key] = categoria.value - .map((palabra) => EntradaPalabraTraducida(palabra: palabra, pista: pista)) + .map( + (palabra) => EntradaPalabraTraducida( + palabra: palabra, + pista: + banco.pistaDePalabra(palabra, categoria: categoria.key) ?? + pistaImpostor, + ), + ) .toList(); } diff --git a/lib/modelos/partida.dart b/lib/modelos/partida.dart index 33946bd..8a0618f 100644 --- a/lib/modelos/partida.dart +++ b/lib/modelos/partida.dart @@ -8,12 +8,16 @@ class ConfigPartida { final bool pistaImpostor; final int? tiempoDebateSegundos; // null = sin límite + /// Cuando hay más de un impostor, cada impostor ve los nombres del resto. + final bool impostoresSeConocen; + const ConfigPartida({ this.modoMultimovil = false, this.categoria = 'todas', this.numImpostores = 1, this.pistaImpostor = false, this.tiempoDebateSegundos, + this.impostoresSeConocen = true, }); } @@ -49,6 +53,10 @@ class Partida { final List jugadores; final String palabraSecreta; final String categoriaReal; + + /// Pista que ve el impostor. Es específica de la palabra cuando el banco la + /// aporta; si no, cae al nombre de la categoría. + final String pistaImpostor; FaseJuego fase; int rondaActual; final List historialVotaciones; @@ -59,11 +67,18 @@ class Partida { required this.jugadores, required this.palabraSecreta, required this.categoriaReal, + String? pistaImpostor, this.fase = FaseJuego.verPalabra, this.rondaActual = 1, List? historialVotaciones, this.ganador, - }) : historialVotaciones = historialVotaciones ?? []; + }) : pistaImpostor = pistaImpostor ?? categoriaReal, + historialVotaciones = historialVotaciones ?? []; + + /// Nombres de los impostores, para que cada impostor sepa quiénes son sus + /// compañeros cuando la partida lo permite. + List get nombresImpostores => + jugadores.where((j) => j.esImpostor).map((j) => j.nombre).toList(); List get jugadoresActivos => jugadores.where((j) => !j.eliminado).toList(); diff --git a/lib/modelos/sala_multijugador.dart b/lib/modelos/sala_multijugador.dart index 4d193da..7bc6cc6 100644 --- a/lib/modelos/sala_multijugador.dart +++ b/lib/modelos/sala_multijugador.dart @@ -154,10 +154,24 @@ class EstadoSalaMultijugador { } ResultadoOperacionSala registrarCliente(ClienteSala cliente) { + final existente = clientes[cliente.clientId]; + if (existente != null) { + // Reconexión: el clientId es estable, el endpointId no. Conservamos lo + // que ya sabíamos del cliente y solo refrescamos por dónde se le habla. + clientes[cliente.clientId] = existente.copiar( + endpointId: cliente.endpointId, + nombre: cliente.nombre, + conectado: true, + ); + return const ResultadoOperacionSala.ok(); + } clientes[cliente.clientId] = cliente; return const ResultadoOperacionSala.ok(); } + /// True si ese cliente ya estuvo en la sala y vuelve tras una caída. + bool esReconexion(String clientId) => clientes.containsKey(clientId); + ResultadoOperacionSala crearUsuario(Usuario usuario) { if (fase != FaseSalaMultijugador.lobby) { return const ResultadoOperacionSala.error('sala_cerrada'); @@ -274,6 +288,8 @@ class EstadoSalaMultijugador { if (entry.value.clienteIdSeleccionado == clientIdOrigen) { usuarios[entry.key] = entry.value.copiar( clienteIdSeleccionado: clientIdDestino, + // Se recuerda de quién eran para poder devolvérselos si vuelve. + absorbidoDe: entry.value.absorbidoDe ?? clientIdOrigen, ); reasignados++; } @@ -281,6 +297,27 @@ class EstadoSalaMultijugador { return reasignados; } + /// Usuarios que el host absorbió de un cliente concreto. + List usuariosAbsorbidosDe(String clientId) => usuarios.values + .where((usuario) => usuario.absorbidoDe == clientId) + .toList(); + + /// Devuelve a su dueño original los usuarios que el host había absorbido. + /// Se usa cuando ese dispositivo se reconecta. + int devolverUsuariosAbsorbidos(String clientId) { + if (!clientes.containsKey(clientId)) return 0; + var devueltos = 0; + for (final entry in usuarios.entries.toList()) { + if (entry.value.absorbidoDe != clientId) continue; + usuarios[entry.key] = entry.value.copiar( + clienteIdSeleccionado: clientId, + limpiarAbsorbidoDe: true, + ); + devueltos++; + } + return devueltos; + } + ResultadoOperacionSala validarInicio() { if (fase != FaseSalaMultijugador.lobby) { return const ResultadoOperacionSala.error('sala_cerrada'); diff --git a/lib/modelos/snapshot_partida_online.dart b/lib/modelos/snapshot_partida_online.dart index 0f7e6a4..e51d087 100644 --- a/lib/modelos/snapshot_partida_online.dart +++ b/lib/modelos/snapshot_partida_online.dart @@ -14,6 +14,11 @@ class SnapshotPartidaOnline { final List impostores; final String? mensaje; + /// Whether impostor roles may travel over the wire. While the game is running + /// the host must never broadcast who the impostors are: every client would be + /// able to read it straight from the payload. + final bool revelarImpostores; + const SnapshotPartidaOnline({ required this.roomId, required this.fase, @@ -26,6 +31,7 @@ class SnapshotPartidaOnline { this.historialVotaciones = const [], this.impostores = const [], this.mensaje, + this.revelarImpostores = false, }); factory SnapshotPartidaOnline.desdePartida( @@ -57,6 +63,7 @@ class SnapshotPartidaOnline { .toList() : const [], mensaje: mensaje, + revelarImpostores: revelarImpostores, ); } @@ -67,7 +74,9 @@ class SnapshotPartidaOnline { 'categoria': categoria, if (palabraSecreta != null) 'palabraSecreta': palabraSecreta, if (ganador != null) 'ganador': ganador, - 'jugadoresTodos': jugadores.map(_jugadorToJson).toList(), + 'jugadoresTodos': jugadores + .map((jugador) => _jugadorToJson(jugador, revelarImpostores)) + .toList(), if (resultadoActual != null) 'resultadoActual': _resultadoToJson(resultadoActual!), 'historialVotaciones': @@ -101,13 +110,19 @@ class SnapshotPartidaOnline { .map((nombre) => nombre.toString()) .toList(), mensaje: json['mensaje'] as String?, + revelarImpostores: jugadoresData.any( + (data) => (data as Map).containsKey('esImpostor'), + ), ); } - static Map _jugadorToJson(Jugador jugador) => { + static Map _jugadorToJson( + Jugador jugador, + bool revelarImpostores, + ) => { 'id': jugador.id, 'nombre': jugador.nombre, - 'esImpostor': jugador.esImpostor, + if (revelarImpostores) 'esImpostor': jugador.esImpostor, 'eliminado': jugador.eliminado, }; diff --git a/lib/modelos/usuario.dart b/lib/modelos/usuario.dart index 90c70f7..562960d 100644 --- a/lib/modelos/usuario.dart +++ b/lib/modelos/usuario.dart @@ -7,6 +7,10 @@ class Usuario { final String? foto; final String? creadoPorClienteId; final String? clienteIdSeleccionado; + + /// Cliente que controlaba a este usuario antes de que el host lo absorbiera + /// por desconexión. Permite devolvérselo si ese dispositivo vuelve. + final String? absorbidoDe; final int fuego; final List medallas; @@ -18,6 +22,7 @@ class Usuario { this.foto, this.creadoPorClienteId, this.clienteIdSeleccionado, + this.absorbidoDe, this.fuego = 0, this.medallas = const [], }); @@ -33,9 +38,11 @@ class Usuario { String? foto, String? creadoPorClienteId, String? clienteIdSeleccionado, + String? absorbidoDe, int? fuego, List? medallas, bool liberarSeleccion = false, + bool limpiarAbsorbidoDe = false, }) { return Usuario( id: id ?? this.id, @@ -47,6 +54,9 @@ class Usuario { clienteIdSeleccionado: liberarSeleccion ? null : (clienteIdSeleccionado ?? this.clienteIdSeleccionado), + absorbidoDe: limpiarAbsorbidoDe + ? null + : (absorbidoDe ?? this.absorbidoDe), fuego: fuego ?? this.fuego, medallas: medallas ?? this.medallas, ); @@ -61,6 +71,7 @@ class Usuario { if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId, if (clienteIdSeleccionado != null) 'clienteIdSeleccionado': clienteIdSeleccionado, + if (absorbidoDe != null) 'absorbidoDe': absorbidoDe, if (fuego > 0) 'fuego': fuego, if (medallas.isNotEmpty) 'medallas': medallas, }; @@ -73,6 +84,7 @@ class Usuario { foto: json['foto'] as String?, creadoPorClienteId: json['creadoPorClienteId'] as String?, clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?, + absorbidoDe: json['absorbidoDe'] as String?, fuego: (json['fuego'] as num?)?.toInt() ?? 0, medallas: (json['medallas'] as List? ?? const []) .map((valor) => valor.toString()) diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart index 217dfea..cf7a32f 100644 --- a/lib/pantallas/pantalla_crear_partida.dart +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -34,6 +34,7 @@ class _PantallaCrearPartidaState extends State { String _categoria = 'todas'; int _numImpostores = 1; bool _pistaImpostor = false; + bool _impostoresSeConocen = true; int? _tiempoDebate; final List _jugadores = []; final _controladorNombre = TextEditingController(); @@ -65,8 +66,11 @@ class _PantallaCrearPartidaState extends State { }); } - int get _maxImpostores => - _modoMultimovil ? 4 : (_jugadores.length / 3).floor().clamp(1, 4); + /// En multidispositivo el número real de jugadores se conoce en el lobby, así + /// que aquí se permite el tope global; al iniciar se ajusta y se avisa. + int get _maxImpostores => _modoMultimovil + ? 4 + : EstadoJuego.maxImpostoresPara(_jugadores.length); List _etiquetasTiempo(AppLocalizations l10n) => [ l10n.noLimit, @@ -139,6 +143,7 @@ class _PantallaCrearPartidaState extends State { numImpostores: _numImpostores, pistaImpostor: _pistaImpostor, tiempoDebateSegundos: _tiempoDebate, + impostoresSeConocen: _impostoresSeConocen, ), nombresJugadores: _jugadores, ); @@ -228,11 +233,23 @@ class _PantallaCrearPartidaState extends State { numImpostores: _numImpostores, pistaImpostor: _pistaImpostor, tiempoDebateSegundos: _tiempoDebate, + impostoresSeConocen: _impostoresSeConocen, ), sala: sala, ); final partida = estado.partida!; + // El tope de impostores depende de cuántos jugadores hay, y eso + // solo se sabe aquí. Si se recorta, hay que decirlo. + if (partida.impostoresTotales < _numImpostores) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.impostorsAdjusted(partida.impostoresTotales), + ), + ), + ); + } final asignaciones = partida.jugadores.map((jugador) { final usuarioSala = sala.usuarios[jugador.id]; final clientId = usuarioSala?.clienteIdSeleccionado; @@ -261,9 +278,12 @@ class _PantallaCrearPartidaState extends State { nearby.enviarInicioPartidaMulti( asignaciones: asignaciones, palabraSecreta: partida.palabraSecreta, - categoria: _categoria, + categoria: partida.categoriaReal, impostoresPorJugadorId: impostores, jugadoresTodos: jugadoresTodos, + impostoresSeConocen: _impostoresSeConocen, + // La pista solo sale del host si la partida la tiene activada. + pistaImpostor: _pistaImpostor ? partida.pistaImpostor : null, ); Navigator.pushReplacement( @@ -596,6 +616,21 @@ class _PantallaCrearPartidaState extends State { contentPadding: EdgeInsets.zero, ), + // Los impostores se reconocen entre ellos + SwitchListTile( + title: Text(l10n.impostorsKnowEachOther), + subtitle: Text( + _numImpostores > 1 + ? l10n.impostorsKnowEachOtherDescription + : l10n.impostorsKnowEachOtherNeedsTwo, + ), + value: _impostoresSeConocen, + onChanged: _numImpostores > 1 + ? (v) => setState(() => _impostoresSeConocen = v) + : null, + contentPadding: EdgeInsets.zero, + ), + // Temporizador Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/pantallas/pantalla_debate_cliente.dart b/lib/pantallas/pantalla_debate_cliente.dart index 9604790..8170f0c 100644 --- a/lib/pantallas/pantalla_debate_cliente.dart +++ b/lib/pantallas/pantalla_debate_cliente.dart @@ -15,7 +15,7 @@ class PantallaDebateCliente extends StatefulWidget { final int? tiempoDebateSegundos; final String? primerTurnoNombre; final String? partidaId; - final String? pistaCategoria; + final String? pistaImpostor; final List jugadores; final List jugadoresControlados; final VoidCallback onSolicitarVotacion; @@ -25,7 +25,7 @@ class PantallaDebateCliente extends StatefulWidget { this.tiempoDebateSegundos, this.primerTurnoNombre, this.partidaId, - this.pistaCategoria, + this.pistaImpostor, this.jugadores = const [], this.jugadoresControlados = const [], required this.onSolicitarVotacion, @@ -55,7 +55,7 @@ class _PantallaDebateClienteState extends State { jugadores: widget.jugadores, jugadoresControlados: widget.jugadoresControlados, partidaId: widget.partidaId, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, onVotos: _enviarVotos, ), ), @@ -133,7 +133,7 @@ class _PantallaDebateClienteState extends State { : () => mostrarRevisionPalabraOnline( context: context, jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, ), ), IconButton( diff --git a/lib/pantallas/pantalla_fin_partida_online.dart b/lib/pantallas/pantalla_fin_partida_online.dart index e7886ba..4450972 100644 --- a/lib/pantallas/pantalla_fin_partida_online.dart +++ b/lib/pantallas/pantalla_fin_partida_online.dart @@ -21,13 +21,13 @@ import 'pantalla_revision_palabra.dart'; class PantallaFinPartidaOnline extends StatefulWidget { final SnapshotPartidaOnline snapshot; final List jugadoresControlados; - final String? pistaCategoria; + final String? pistaImpostor; const PantallaFinPartidaOnline({ super.key, required this.snapshot, required this.jugadoresControlados, - this.pistaCategoria, + this.pistaImpostor, }); @override @@ -202,7 +202,7 @@ class _PantallaFinPartidaOnlineState extends State { : () => mostrarRevisionPalabraOnline( context: context, jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, ), ), IconButton( diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index 1e3dca9..fa75e8d 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -9,6 +9,7 @@ import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/jugador.dart'; import '../modelos/palabra.dart'; import '../modelos/partida.dart'; +import '../modelos/sala_multijugador.dart'; import '../modelos/snapshot_partida_online.dart'; import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_nearby.dart'; @@ -40,6 +41,8 @@ class _PantallaGestorHostState extends State { String? _primerTurnoNombre; final Map _clientesListos = {}; final Map _votosRecibidos = {}; + OnMensajeCallback? _listenerMensajes; + ServicioNearby? _nearby; @override void initState() { @@ -65,28 +68,118 @@ class _PantallaGestorHostState extends State { void _registrarListeners() { final nearby = context.read(); - nearby.onMensaje((endpointId, mensaje) { + _nearby = nearby; + _listenerMensajes = (endpointId, mensaje) { + if (!mounted) return; if (mensaje.tipo == TipoMensaje.listo) { - setState(() => _clientesListos[endpointId] = true); + // Se indexa por clientId, no por endpointId: el endpoint cambia en + // cada reconexión y si no perderíamos el "ya la he visto". + final clientId = _clientIdDe(endpointId) ?? endpointId; + setState(() => _clientesListos[clientId] = true); + } else if (mensaje.tipo == TipoMensaje.unirse) { + // Con la partida ya en marcha, un `unirse` solo puede ser un móvil + // que vuelve tras caerse. + final nombre = mensaje.datos['nombre'] as String?; + if (nombre != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.playerRejoined(nombre)), + ), + ); + } + } else if (mensaje.tipo == TipoMensaje.solicitarResync) { + _responderResync(endpointId); } else if (mensaje.tipo == TipoMensaje.voto) { final votanteId = mensaje.datos['votanteId'] as String?; final votoId = mensaje.datos['votadoId'] as String? ?? mensaje.datos['votoporId'] as String?; - if (votanteId != null && votoId != null) { - context.read().registrarVoto(votanteId, votoId); - setState(() => _votosRecibidos[votanteId] = votoId); - } + if (votanteId == null || votoId == null) return; + // Un jugador eliminado ya no vota, venga de donde venga el mensaje. + final partida = context.read().partida; + final sigueVivo = + partida?.jugadoresActivos.any((j) => j.id == votanteId) ?? false; + if (!sigueVivo) return; + context.read().registrarVoto(votanteId, votoId); + setState(() => _votosRecibidos[votanteId] = votoId); } - }); + }; + nearby.onMensaje(_listenerMensajes!); } @override void dispose() { _timer?.cancel(); + final listener = _listenerMensajes; + if (listener != null) _nearby?.removeMensajeListener(listener); super.dispose(); } + String? _clientIdDe(String endpointId) { + return context + .read() + .estadoSala + ?.clientePorEndpoint(endpointId) + ?.clientId; + } + + /// Jugadores de la partida que controla un cliente concreto, con su palabra, + /// su rol y sus compañeros. Es lo que necesita un móvil para volver a jugar. + List _jugadoresDeCliente( + Partida partida, + EstadoSalaMultijugador sala, + String clientId, + ) { + final estado = context.read(); + return sala + .usuariosPorCliente(clientId) + .where((usuario) => partida.jugadores.any((j) => j.id == usuario.id)) + .map((usuario) { + final jugador = partida.jugadores.firstWhere( + (j) => j.id == usuario.id, + ); + return JugadorInicioPartida( + jugadorId: jugador.id, + nombre: jugador.nombre, + esImpostor: jugador.esImpostor, + palabra: jugador.esImpostor ? null : partida.palabraSecreta, + companerosImpostores: estado.companerosImpostoresDe(jugador.id), + ); + }) + .toList(); + } + + /// Responde a un móvil que vuelve tras una caída con el estado completo: + /// en qué fase va la partida y qué jugadores le tocan. + Future _responderResync(String endpointId) async { + final nearby = context.read(); + final estado = context.read(); + final partida = estado.partida; + final sala = nearby.estadoSala; + if (partida == null || sala == null) return; + + final clientId = _clientIdDe(endpointId); + if (clientId == null) return; + + final datos = _snapshot(fase: partida.fase.name).toJson(); + datos['fase'] = partida.fase.name; + datos['jugadores'] = _jugadoresDeCliente(partida, sala, clientId) + .map((jugador) => jugador.toJson()) + .toList(); + if (partida.config.pistaImpostor) { + datos['pistaImpostor'] = partida.pistaImpostor; + } + if (partida.config.tiempoDebateSegundos != null) { + datos['tiempoDebateSegundos'] = partida.config.tiempoDebateSegundos; + } + if (_primerTurnoNombre != null) { + datos['primerTurnoNombre'] = _primerTurnoNombre; + } + + await nearby.enviarResync(endpointId, datos); + if (mounted) setState(() {}); + } + String _formatearTiempo(int segundos) { final min = segundos ~/ 60; final seg = segundos % 60; @@ -152,8 +245,16 @@ class _PantallaGestorHostState extends State { ); } + // Solo bloquean los clientes que siguen conectados: un móvil caído no + // puede impedir que avance la partida. + final clientesPendientes = + nearby.estadoSala?.clientes.values + .where((cliente) => !cliente.esHost && cliente.conectado) + .map((cliente) => cliente.clientId) ?? + const []; final todosListos = - _hostListo && _clientesListos.length >= nearby.jugadores.length; + _hostListo && + clientesPendientes.every((id) => _clientesListos[id] == true); final todosVotaron = estado.todosHanVotado(); return Scaffold( @@ -172,8 +273,8 @@ class _PantallaGestorHostState extends State { partida, nearby, ), - pistaCategoria: partida.config.pistaImpostor - ? partida.categoriaReal + pistaImpostor: partida.config.pistaImpostor + ? partida.pistaImpostor : null, ), ), @@ -287,7 +388,16 @@ class _PantallaGestorHostState extends State { texto: AppLocalizations.of(context)!.assumeOnThisPhone, icono: Icons.person_add_alt_1, assetIconPath: 'assets/ui/generated/actions/action_add_player.webp', - onPressed: () => nearby.asumirUsuariosDesconectados(), + onPressed: () async { + // Son jugadores nuevos para este móvil: sus palabras están + // sin ver y sus votos sin emitir. + final fase = context.read().partida?.fase; + await nearby.asumirUsuariosDesconectados(); + if (!mounted) return; + setState(() { + if (fase == FaseJuego.verPalabra) _hostListo = false; + }); + }, ), ), ), @@ -422,7 +532,9 @@ class _PantallaGestorHostState extends State { (jugador) => _buildJugadorTile( jugador.nombre, false, - _clientesListos[jugador.endpointId] ?? false, + _clientesListos[_clientIdDe(jugador.endpointId) ?? + jugador.endpointId] ?? + false, ), ), const SizedBox(height: 12), @@ -451,22 +563,7 @@ class _PantallaGestorHostState extends State { ) { final sala = nearby.estadoSala; if (sala == null) return const []; - - return sala - .usuariosPorCliente(sala.hostClientId) - .where((usuario) => partida.jugadores.any((j) => j.id == usuario.id)) - .map((usuario) { - final jugador = partida.jugadores.firstWhere( - (j) => j.id == usuario.id, - ); - return JugadorInicioPartida( - jugadorId: jugador.id, - nombre: jugador.nombre, - esImpostor: jugador.esImpostor, - palabra: jugador.palabra ?? partida.palabraSecreta, - ); - }) - .toList(); + return _jugadoresDeCliente(partida, sala, sala.hostClientId); } void _mostrarPalabraHost(BuildContext context) { @@ -483,8 +580,8 @@ class _PantallaGestorHostState extends State { MaterialPageRoute( builder: (_) => PantallaPalabrasCliente( jugadores: jugadoresHost, - pistaCategoria: partida.config.pistaImpostor - ? partida.categoriaReal + pistaImpostor: partida.config.pistaImpostor + ? partida.pistaImpostor : null, onTodosVistos: () { setState(() => _hostListo = true); @@ -508,7 +605,8 @@ class _PantallaGestorHostState extends State { esImpostor: hostLocal.esImpostor, palabra: partida.palabraSecreta, pistaActiva: partida.config.pistaImpostor, - categoria: partida.categoriaReal, + pista: partida.pistaImpostor, + companerosImpostores: estado.companerosImpostoresDe(hostLocal.id), onVisto: () => setState(() => _hostListo = true), ), ), @@ -767,8 +865,8 @@ class _PantallaGestorHostState extends State { jugadores: partida.jugadoresActivos, jugadoresControlados: jugadoresHost, partidaId: context.read().roomId, - pistaCategoria: partida.config.pistaImpostor - ? partida.categoriaReal + pistaImpostor: partida.config.pistaImpostor + ? partida.pistaImpostor : null, onVotos: (votos) { for (final entry in votos.entries) { @@ -1029,7 +1127,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget { final bool esImpostor; final String palabra; final bool pistaActiva; - final String categoria; + final String pista; + final List? companerosImpostores; final VoidCallback onVisto; const _PantallaRevelarPalabraHost({ @@ -1037,7 +1136,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget { required this.esImpostor, required this.palabra, required this.pistaActiva, - required this.categoria, + required this.pista, + required this.companerosImpostores, required this.onVisto, }); @@ -1120,15 +1220,13 @@ class _PantallaRevelarPalabraHostState ], if (widget.esImpostor && widget.pistaActiva) ...[ const SizedBox(height: 12), - Text( - l10n.clueCategory( - BancoPalabras.nombreBonitoCategoria( - widget.categoria, - l10n, - ), - ), - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: TemaApp.colorNaranja), + PistaImpostorFarolero(pista: widget.pista), + ], + if (widget.esImpostor && + widget.companerosImpostores != null) ...[ + const SizedBox(height: 12), + CompanerosImpostorFarolero( + nombres: widget.companerosImpostores!, ), ], ], diff --git a/lib/pantallas/pantalla_palabra_cliente.dart b/lib/pantallas/pantalla_palabra_cliente.dart index 3bee04d..a4bb65f 100644 --- a/lib/pantallas/pantalla_palabra_cliente.dart +++ b/lib/pantallas/pantalla_palabra_cliente.dart @@ -10,14 +10,14 @@ import 'package:farolero/tema/tema_app.dart'; class PantallaPalabraCliente extends StatefulWidget { final String palabra; final bool esImpostor; - final String? pistaCategoria; + final String? pistaImpostor; final VoidCallback onVisto; const PantallaPalabraCliente({ super.key, required this.palabra, required this.esImpostor, - this.pistaCategoria, + this.pistaImpostor, required this.onVisto, }); @@ -124,7 +124,7 @@ class _PantallaPalabraClienteState extends State { const SizedBox(height: 16), // Pista para impostores - if (widget.esImpostor && widget.pistaCategoria != null) ...[ + if (widget.esImpostor && widget.pistaImpostor != null) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -138,7 +138,7 @@ class _PantallaPalabraClienteState extends State { const SizedBox(width: 8), Flexible( child: Text( - '\u{1F3AD} ${l10n.clueIs(widget.pistaCategoria!)}', + '\u{1F3AD} ${l10n.clueIs(widget.pistaImpostor!)}', style: const TextStyle(color: TemaApp.colorAcento), ), ), diff --git a/lib/pantallas/pantalla_palabras_cliente.dart b/lib/pantallas/pantalla_palabras_cliente.dart index 8281996..77ea9e2 100644 --- a/lib/pantallas/pantalla_palabras_cliente.dart +++ b/lib/pantallas/pantalla_palabras_cliente.dart @@ -7,13 +7,13 @@ import 'package:farolero/tema/tema_app.dart'; /// Reveal secuencial para clientes que manejan uno o varios jugadores. class PantallaPalabrasCliente extends StatefulWidget { final List jugadores; - final String? pistaCategoria; + final String? pistaImpostor; final VoidCallback onTodosVistos; const PantallaPalabrasCliente({ super.key, required this.jugadores, - this.pistaCategoria, + this.pistaImpostor, required this.onTodosVistos, }); @@ -113,12 +113,16 @@ class _PantallaPalabrasClienteState extends State { ), ), ), - if (_visible && actual.esImpostor && widget.pistaCategoria != null) ...[ + if (_visible && actual.esImpostor && widget.pistaImpostor != null) ...[ const SizedBox(height: 12), - Text( - l10n.clueIs(widget.pistaCategoria!), - style: const TextStyle(color: TemaApp.colorNaranja), - textAlign: TextAlign.center, + PistaImpostorFarolero(pista: widget.pistaImpostor!), + ], + if (_visible && + actual.esImpostor && + actual.companerosImpostores != null) ...[ + const SizedBox(height: 12), + CompanerosImpostorFarolero( + nombres: actual.companerosImpostores!, ), ], const SizedBox(height: 12), diff --git a/lib/pantallas/pantalla_resultado_online.dart b/lib/pantallas/pantalla_resultado_online.dart index 6df5c60..bc0da1b 100644 --- a/lib/pantallas/pantalla_resultado_online.dart +++ b/lib/pantallas/pantalla_resultado_online.dart @@ -16,13 +16,13 @@ import 'package:provider/provider.dart'; class PantallaResultadoOnline extends StatefulWidget { final SnapshotPartidaOnline snapshot; final List jugadoresControlados; - final String? pistaCategoria; + final String? pistaImpostor; const PantallaResultadoOnline({ super.key, required this.snapshot, required this.jugadoresControlados, - this.pistaCategoria, + this.pistaImpostor, }); @override @@ -86,7 +86,7 @@ class _PantallaResultadoOnlineState extends State { tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?, primerTurnoNombre: datos['primerTurnoNombre'] as String?, partidaId: snapshot.roomId, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, jugadores: snapshot.jugadores, jugadoresControlados: widget.jugadoresControlados, onSolicitarVotacion: _solicitarVotacion, @@ -103,7 +103,7 @@ class _PantallaResultadoOnlineState extends State { jugadores: snapshot.jugadores, jugadoresControlados: widget.jugadoresControlados, partidaId: snapshot.roomId, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, onVotos: _enviarVotos, ), ), @@ -117,7 +117,7 @@ class _PantallaResultadoOnlineState extends State { builder: (_) => PantallaFinPartidaOnline( snapshot: snapshot, jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, ), ), ); @@ -197,7 +197,7 @@ class _PantallaResultadoOnlineState extends State { : () => mostrarRevisionPalabraOnline( context: context, jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, ), ), IconButton( diff --git a/lib/pantallas/pantalla_revision_palabra.dart b/lib/pantallas/pantalla_revision_palabra.dart index 5d27864..7406c2a 100644 --- a/lib/pantallas/pantalla_revision_palabra.dart +++ b/lib/pantallas/pantalla_revision_palabra.dart @@ -7,7 +7,7 @@ import 'package:farolero/tema/tema_app.dart'; Future mostrarRevisionPalabraOnline({ required BuildContext context, required List jugadoresControlados, - String? pistaCategoria, + String? pistaImpostor, }) async { if (jugadoresControlados.isEmpty) return; @@ -45,18 +45,18 @@ Future mostrarRevisionPalabraOnline({ context: context, builder: (dialogContext) => _DialogoRevisionPalabra( jugador: jugador, - pistaCategoria: pistaCategoria, + pistaImpostor: pistaImpostor, ), ); } class _DialogoRevisionPalabra extends StatelessWidget { final JugadorInicioPartida jugador; - final String? pistaCategoria; + final String? pistaImpostor; const _DialogoRevisionPalabra({ required this.jugador, - required this.pistaCategoria, + required this.pistaImpostor, }); @override @@ -106,12 +106,15 @@ class _DialogoRevisionPalabra extends StatelessWidget { ) else TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''), - if (jugador.esImpostor && pistaCategoria != null) ...[ + if (jugador.esImpostor && pistaImpostor != null) ...[ const SizedBox(height: 12), - Text( - l10n.clueIs(pistaCategoria!), - style: const TextStyle(color: TemaApp.colorNaranja), - textAlign: TextAlign.center, + PistaImpostorFarolero(pista: pistaImpostor!), + ], + if (jugador.esImpostor && + jugador.companerosImpostores != null) ...[ + const SizedBox(height: 12), + CompanerosImpostorFarolero( + nombres: jugador.companerosImpostores!, ), ], ], diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index 96c94e4..8964b1f 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -3,6 +3,7 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:provider/provider.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import '../modelos/jugador.dart'; +import '../modelos/partida.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/snapshot_partida_online.dart'; import '../modelos/usuario.dart'; @@ -41,10 +42,12 @@ class _PantallaUnirseState extends State { // Estado del juego recibido del host String? _palabraRecibida; bool _esImpostor = false; - String? _pistaCategoria; + String? _pistaImpostor; String? _partidaId; final List _jugadores = []; final List _jugadoresControlados = []; + OnMensajeCallback? _listenerMensajes; + ServicioNearby? _nearby; @override void initState() { @@ -61,7 +64,8 @@ class _PantallaUnirseState extends State { void _registrarListenerPartida() { final nearby = context.read(); - nearby.onMensaje((endpointId, mensaje) { + _nearby = nearby; + _listenerMensajes = (endpointId, mensaje) { if (!mounted) return; if (mensaje.tipo == TipoMensaje.partidaInicio) { // El host ha iniciado la partida — nos ha enviado nuestra palabra @@ -105,7 +109,9 @@ class _PantallaUnirseState extends State { ); } } - _pistaCategoria = mensaje.datos['categoria'] as String?; + // La pista solo llega si la partida la tiene activada; la categoría + // no vale como pista porque puede ser 'todas'. + _pistaImpostor = mensaje.datos['pistaImpostor'] as String?; _partidaId = (mensaje.datos['roomId'] as String?) ?? nearby.roomId ?? (mensaje.datos['clientId'] as String?) ?? @@ -115,6 +121,8 @@ class _PantallaUnirseState extends State { if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) { _navegarAPalabra(); } + } else if (mensaje.tipo == TipoMensaje.resync) { + _aplicarResync(mensaje.datos); } else if (mensaje.tipo == TipoMensaje.fase) { final fase = mensaje.datos['fase'] as String?; _actualizarSnapshotSiExiste(mensaje.datos); @@ -128,7 +136,43 @@ class _PantallaUnirseState extends State { _actualizarSnapshotSiExiste(mensaje.datos); if (mounted) _navegarFinPartida(mensaje.datos); } + }; + nearby.onMensaje(_listenerMensajes!); + } + + /// Reincorpora este móvil a una partida ya empezada tras una caída. + /// + /// El host manda el estado completo, así que se reconstruye todo —jugadores + /// controlados, pista, censo— antes de saltar a la pantalla de la fase en + /// curso. Se descartan las rutas viejas: la pila de antes ya no vale. + void _aplicarResync(Map datos) { + final controlados = (datos['jugadores'] as List? ?? const []) + .map((json) => JugadorInicioPartida.fromJson( + json as Map, + )) + .toList(); + + setState(() { + _jugadoresControlados + ..clear() + ..addAll(controlados); + _pistaImpostor = datos['pistaImpostor'] as String? ?? _pistaImpostor; + _partidaId = (datos['roomId'] as String?) ?? + _partidaId ?? + context.read().roomId; }); + _actualizarSnapshotSiExiste(datos); + + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + + final fase = datos['fase'] as String?; + if (fase == null || fase == FaseJuego.verPalabra.name) { + // Aún no ha empezado el debate: que vuelva a ver su palabra. + if (_jugadoresControlados.isNotEmpty) _navegarAPalabra(); + return; + } + _navegarSegunFase(fase, datos); } void _navegarAPalabra() { @@ -137,7 +181,7 @@ class _PantallaUnirseState extends State { MaterialPageRoute( builder: (_) => PantallaPalabrasCliente( jugadores: List.unmodifiable(_jugadoresControlados), - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, onTodosVistos: () { final nearby = context.read(); if (nearby.hostEndpointId != null) { @@ -159,7 +203,7 @@ class _PantallaUnirseState extends State { builder: (_) => PantallaPalabraCliente( palabra: _palabraRecibida ?? '', esImpostor: _esImpostor, - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, onVisto: () { // Enviar "listo" al host y volver a la espera final nearby = context.read(); @@ -190,7 +234,7 @@ class _PantallaUnirseState extends State { _partidaId = (datos['roomId'] as String?) ?? _partidaId ?? context.read().roomId; - _pistaCategoria = (datos['categoria'] as String?) ?? _pistaCategoria; + }); } @@ -206,7 +250,7 @@ class _PantallaUnirseState extends State { primerTurnoNombre: datosFase?['primerTurnoNombre'] as String?, partidaId: _partidaId ?? context.read().roomId, - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, jugadores: List.unmodifiable(_jugadores), jugadoresControlados: List.unmodifiable(_jugadoresControlados), onSolicitarVotacion: () { @@ -232,7 +276,7 @@ class _PantallaUnirseState extends State { jugadores: _jugadores, jugadoresControlados: List.unmodifiable(_jugadoresControlados), partidaId: _partidaId ?? context.read().roomId, - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, onVotos: (votos) { final nearby = context.read(); if (nearby.hostEndpointId != null) { @@ -273,7 +317,7 @@ class _PantallaUnirseState extends State { builder: (_) => PantallaResultadoOnline( snapshot: snapshot, jugadoresControlados: List.unmodifiable(_jugadoresControlados), - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, ), ), ); @@ -287,13 +331,15 @@ class _PantallaUnirseState extends State { builder: (_) => PantallaFinPartidaOnline( snapshot: snapshot, jugadoresControlados: List.unmodifiable(_jugadoresControlados), - pistaCategoria: _pistaCategoria, + pistaImpostor: _pistaImpostor, ), ), ); } @override void dispose() { + final listener = _listenerMensajes; + if (listener != null) _nearby?.removeMensajeListener(listener); _nombreController.dispose(); super.dispose(); } @@ -419,6 +465,10 @@ class _PantallaUnirseState extends State { final l10n = AppLocalizations.of(context)!; final nearby = context.watch(); + if (nearby.reconectando && !nearby.conectado) { + return _buildReconectando(context, l10n, nearby); + } + // Si estamos conectados → pantalla de espera if (nearby.conectado && !nearby.esHost) { return _buildPantallaEspera(context, l10n); @@ -704,6 +754,64 @@ class _PantallaUnirseState extends State { ); } + // ==================== RECONEXIÓN ==================== + + Widget _buildReconectando( + BuildContext context, + AppLocalizations l10n, + ServicioNearby nearby, + ) { + return Scaffold( + appBar: AppBar( + title: Text(_salaSeleccionada ?? l10n.joinGameTitle), + automaticallyImplyLeading: false, + actions: [ + IconButton( + tooltip: l10n.leaveGame, + icon: IconoFarolero(Icons.close), + onPressed: () async { + await nearby.desconectar(); + if (!context.mounted) return; + setState(() { + _buscando = false; + _conectando = false; + }); + }, + ), + ], + ), + body: FondoFarolero( + intenso: true, + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const _JoinLobbySignalArt(height: 180), + const SizedBox(height: 16), + EncabezadoFarolero( + icono: Icons.wifi_tethering_off, + titulo: l10n.reconnecting, + subtitulo: l10n.reconnectingHint, + color: TemaApp.colorNaranja, + trailing: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: TemaApp.colorNaranja, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + // ==================== ESPERA ==================== Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) { diff --git a/lib/pantallas/pantalla_ver_palabra.dart b/lib/pantallas/pantalla_ver_palabra.dart index 504bc27..5a4f7e3 100644 --- a/lib/pantallas/pantalla_ver_palabra.dart +++ b/lib/pantallas/pantalla_ver_palabra.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:provider/provider.dart'; import '../estado/estado_juego.dart'; -import '../modelos/palabra.dart'; import '../tema/componentes_farolero.dart'; import '../tema/tema_app.dart'; import 'pantalla_debate.dart'; @@ -104,7 +103,8 @@ class _PantallaVerPalabraState extends State { esImpostor: jugador.esImpostor, palabra: partida.palabraSecreta, pistaActiva: partida.config.pistaImpostor, - categoria: partida.categoriaReal, + pista: partida.pistaImpostor, + companerosImpostores: estado.companerosImpostoresDe(jugador.id), onVisto: () { setState(() => _hanVisto.add(jugadorId)); }, @@ -119,7 +119,8 @@ class _PantallaRevelarPalabra extends StatefulWidget { final bool esImpostor; final String palabra; final bool pistaActiva; - final String categoria; + final String pista; + final List? companerosImpostores; final VoidCallback onVisto; const _PantallaRevelarPalabra({ @@ -127,7 +128,8 @@ class _PantallaRevelarPalabra extends StatefulWidget { required this.esImpostor, required this.palabra, required this.pistaActiva, - required this.categoria, + required this.pista, + required this.companerosImpostores, required this.onVisto, }); @@ -191,10 +193,13 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> { ], if (widget.esImpostor && widget.pistaActiva) ...[ const SizedBox(height: 12), - Text( - l10n.clueCategory(BancoPalabras.nombreBonitoCategoria(widget.categoria, l10n)), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja), + PistaImpostorFarolero(pista: widget.pista), + ], + if (widget.esImpostor && + widget.companerosImpostores != null) ...[ + const SizedBox(height: 12), + CompanerosImpostorFarolero( + nombres: widget.companerosImpostores!, ), ], ], diff --git a/lib/pantallas/pantalla_votacion_cliente.dart b/lib/pantallas/pantalla_votacion_cliente.dart index 250559b..7e37794 100644 --- a/lib/pantallas/pantalla_votacion_cliente.dart +++ b/lib/pantallas/pantalla_votacion_cliente.dart @@ -2,15 +2,10 @@ import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/modelos/inicio_partida_multijugador.dart'; import 'package:farolero/modelos/jugador.dart'; -import 'package:farolero/modelos/partida.dart'; -import 'package:farolero/modelos/snapshot_partida_online.dart'; import 'package:farolero/pantallas/pantalla_notas_online.dart'; import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; -import 'package:farolero/pantallas/pantalla_resultado_online.dart'; -import 'package:farolero/servicios/servicio_nearby.dart'; import 'package:farolero/tema/componentes_farolero.dart'; import 'package:farolero/tema/tema_app.dart'; -import 'package:provider/provider.dart'; /// Pantalla de votación para cliente multidispositivo. /// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto @@ -19,7 +14,7 @@ class PantallaVotacionCliente extends StatefulWidget { final List jugadores; final List jugadoresControlados; final String? partidaId; - final String? pistaCategoria; + final String? pistaImpostor; final Function(Map votos) onVotos; const PantallaVotacionCliente({ @@ -27,7 +22,7 @@ class PantallaVotacionCliente extends StatefulWidget { required this.jugadores, this.jugadoresControlados = const [], this.partidaId, - this.pistaCategoria, + this.pistaImpostor, required this.onVotos, }); @@ -37,75 +32,31 @@ class PantallaVotacionCliente extends StatefulWidget { class _PantallaVotacionClienteState extends State { final Map _votosPorVotante = {}; - OnMensajeCallback? _listener; - ServicioNearby? _nearby; - List get _votantes => widget.jugadoresControlados; + /// Solo los jugadores vivos pueden ser votados. + List get _votables => + widget.jugadores.where((jugador) => !jugador.eliminado).toList(); + + /// Y solo los jugadores vivos que controla este dispositivo pueden votar. + List get _votantes => widget.jugadoresControlados + .where( + (controlado) => + _votables.any((jugador) => jugador.id == controlado.jugadorId), + ) + .toList(); + /// Protocolo antiguo: el cliente no recibió jugadores controlados y emite un + /// único voto sin identificar al votante. + bool get _modoLegacy => widget.jugadoresControlados.isEmpty; + + /// Todos los jugadores de este dispositivo están eliminados: no vota nadie. + bool get _sinVotantesVivos => !_modoLegacy && _votantes.isEmpty; + bool get _votacionCompleta { - if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy'); - return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null); - } - - @override - void initState() { - super.initState(); - _listener = (endpointId, mensaje) { - if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return; - if (mensaje.datos.containsKey('jugadoresTodos')) { - final snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos); - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => PantallaResultadoOnline( - snapshot: snapshot, - jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, - ), - ), - ); - } else { - final votosRaw = mensaje.datos['votos'] as Map? ?? {}; - final snapshot = SnapshotPartidaOnline( - roomId: widget.partidaId, - fase: 'resultado', - ronda: 1, - categoria: '', - jugadores: widget.jugadores, - resultadoActual: ResultadoVotacion( - eliminadoId: mensaje.datos['eliminadoId'] as String? ?? '', - eliminadoNombre: mensaje.datos['eliminadoNombre'] as String? ?? '?', - eraImpostor: mensaje.datos['eraImpostor'] as bool? ?? false, - votos: votosRaw.map( - (key, value) => MapEntry(key.toString(), value.toString()), - ), - ), - ); - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => PantallaResultadoOnline( - snapshot: snapshot, - jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, - ), - ), - ); - } - }; - WidgetsBinding.instance.addPostFrameCallback((_) { - final listener = _listener; - if (listener != null && mounted) { - _nearby = context.read(); - _nearby!.onMensaje(listener); - } - }); - } - - @override - void dispose() { - final listener = _listener; - if (listener != null) { - _nearby?.removeMensajeListener(listener); - } - super.dispose(); + if (_sinVotantesVivos) return false; + if (_modoLegacy) return _votosPorVotante.containsKey('_legacy'); + return _votantes.every( + (votante) => _votosPorVotante[votante.jugadorId] != null, + ); } @override @@ -128,7 +79,7 @@ class _PantallaVotacionClienteState extends State { : () => mostrarRevisionPalabraOnline( context: context, jugadoresControlados: widget.jugadoresControlados, - pistaCategoria: widget.pistaCategoria, + pistaImpostor: widget.pistaImpostor, ), ), IconButton( @@ -172,7 +123,15 @@ class _PantallaVotacionClienteState extends State { ), const SizedBox(height: 16), Expanded( - child: _votantes.isEmpty + child: _sinVotantesVivos + ? Center( + child: EstadoVacioFarolero( + icono: Icons.hourglass_empty, + titulo: l10n.eliminatedCannotVote, + subtitulo: l10n.waitingVoting, + ), + ) + : _modoLegacy ? _buildSelectorLegacy() : ListView.builder( itemCount: _votantes.length, @@ -200,15 +159,16 @@ class _PantallaVotacionClienteState extends State { bool get _puedeAbrirNotas { return widget.partidaId != null && - widget.jugadores.isNotEmpty && + _votables.isNotEmpty && widget.jugadoresControlados.isNotEmpty; } Widget _buildSelectorLegacy() { + final votables = _votables; return ListView.builder( - itemCount: widget.jugadores.length, + itemCount: votables.length, itemBuilder: (context, index) { - final jugador = widget.jugadores[index]; + final jugador = votables[index]; final selected = _votosPorVotante['_legacy'] == jugador.id; return _buildJugadorVotable( jugador: jugador, @@ -236,7 +196,7 @@ class _PantallaVotacionClienteState extends State { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - ...widget.jugadores.asMap().entries.map((entry) { + ..._votables.asMap().entries.map((entry) { final jugador = entry.value; final selected = _votosPorVotante[votante.jugadorId] == jugador.id; return _buildJugadorVotable( diff --git a/lib/servicios/identidad_dispositivo.dart b/lib/servicios/identidad_dispositivo.dart new file mode 100644 index 0000000..baae728 --- /dev/null +++ b/lib/servicios/identidad_dispositivo.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Identificador estable de este dispositivo. +/// +/// Nearby Connections asigna un `endpointId` nuevo en cada conexión, así que no +/// sirve para reconocer a un móvil que se reconecta. Este id se guarda en disco +/// y sobrevive a caídas de conexión, cierres de la app y reinicios. +class IdentidadDispositivo { + static const _clave = 'dispositivo.id'; + + static String? _cache; + + /// Devuelve el id del dispositivo, creándolo la primera vez. + static Future obtener() async { + final cacheado = _cache; + if (cacheado != null) return cacheado; + + final prefs = await SharedPreferences.getInstance(); + final guardado = prefs.getString(_clave); + if (guardado != null && guardado.isNotEmpty) { + _cache = guardado; + return guardado; + } + + final nuevo = _generar(); + await prefs.setString(_clave, nuevo); + _cache = nuevo; + return nuevo; + } + + /// Id ya cargado en memoria, si existe. Útil donde no se puede esperar. + static String? get cacheado => _cache; + + static String _generar() { + final rng = Random.secure(); + final bytes = List.generate(8, (_) => rng.nextInt(256)); + final hex = bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return 'dev-$hex'; + } + + /// Solo para pruebas: fija el id en memoria sin tocar disco. + static void fijarParaPruebas(String? id) => _cache = id; +} diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index bedfd21..1492797 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -5,6 +5,7 @@ import 'package:nearby_connections/nearby_connections.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/sala_multijugador.dart'; import '../modelos/usuario.dart'; +import 'identidad_dispositivo.dart'; /// Tipos de mensajes en el protocolo P2P. enum TipoMensaje { @@ -26,6 +27,10 @@ enum TipoMensaje { eliminarUsuario, errorOperacion, usuarioNuevo, + // Reconexión: el cliente pide el estado de la partida en curso y el host se + // lo devuelve completo. + solicitarResync, + resync, // Compatibilidad con versiones previas del protocolo. usuarioEliminado, usuariosActualizados, @@ -94,6 +99,17 @@ class ServicioNearby extends ChangeNotifier { final Map _usuariosPool = {}; Timer? _heartbeatTimer; + String? _miDeviceId; + bool _reconectando = false; + bool _partidaEnCursoAlEntrar = false; + bool _cerrando = false; + String? _nombreHostConectado; + Timer? _limiteReconexion; + + /// Cuánto se insiste en volver antes de rendirse. Sin tope, un móvil cuyo + /// host se ha ido se quedaría escaneando y gastando batería para siempre. + static const _ventanaReconexion = Duration(minutes: 3); + String? _palabraRecibida; bool? _soyImpostor; String? _faseActual; @@ -102,6 +118,13 @@ class ServicioNearby extends ChangeNotifier { bool get esHost => _esHost; bool get conectado => _conectado; + + /// El cliente perdió la conexión y está intentando volver por su cuenta. + bool get reconectando => _reconectando; + + /// Al registrarse, el host indicó que ya había una partida empezada. + bool get partidaEnCursoAlEntrar => _partidaEnCursoAlEntrar; + String? get miDeviceId => _miDeviceId; bool get buscando => _buscando; bool get anunciando => _anunciando; String? get miEndpointId => _miEndpointId; @@ -231,6 +254,7 @@ class ServicioNearby extends ChangeNotifier { _miNombre = miNombre; _roomId = DateTime.now().microsecondsSinceEpoch.toString(); _miClientId = _hostClientId; + _miDeviceId = await IdentidadDispositivo.obtener(); _estadoSala = EstadoSalaMultijugador.crear( roomId: _roomId!, nombreSala: nombreSala, @@ -295,6 +319,7 @@ class ServicioNearby extends ChangeNotifier { _miAvatar = miAvatar; _miFuego = miFuego; _miMedallas = miMedallas; + _miDeviceId ??= await IdentidadDispositivo.obtener(); try { final resultado = await Nearby().startDiscovery( @@ -330,6 +355,8 @@ class ServicioNearby extends ChangeNotifier { _miAvatar = miAvatar; _miFuego = miFuego; _miMedallas = miMedallas; + _miDeviceId ??= await IdentidadDispositivo.obtener(); + _nombreHostConectado = _hostsEncontrados[endpointId]; try { await Nearby().requestConnection( miNombre, @@ -364,6 +391,10 @@ class ServicioNearby extends ChangeNotifier { } else { _hostEndpointId = endpointId; _conectado = true; + _reconectando = false; + _buscando = false; + _limiteReconexion?.cancel(); + _limiteReconexion = null; _iniciarHeartbeatCliente(); enviarMensaje( endpointId, @@ -375,6 +406,7 @@ class ServicioNearby extends ChangeNotifier { if (_miAvatar != null) 'avatar': _miAvatar, 'fuego': _miFuego, 'medallas': _miMedallas, + if (_miDeviceId != null) 'deviceId': _miDeviceId, }, ), ); @@ -406,10 +438,49 @@ class ServicioNearby extends ChangeNotifier { _conectado = false; _hostEndpointId = null; _heartbeatTimer?.cancel(); + // Conservamos clientId y deviceId: son la llave para que el host nos + // reconozca cuando volvamos. + _iniciarReconexionCliente(); } notifyListeners(); } + /// Relanza el descubrimiento tras una caída para volver a la misma sala. + Future _iniciarReconexionCliente() async { + if (_esHost || _cerrando || _miNombre == null) return; + _reconectando = true; + _limiteReconexion?.cancel(); + _limiteReconexion = Timer(_ventanaReconexion, cancelarReconexion); + notifyListeners(); + + try { + await Nearby().stopDiscovery(); + } catch (_) {} + // Entre el await y aquí el usuario puede haber salido. + if (_cerrando || !_reconectando) return; + try { + _buscando = await Nearby().startDiscovery( + _miNombre!, + Strategy.P2P_STAR, + onEndpointFound: _onEndpointEncontrado, + onEndpointLost: _onEndpointPerdido, + serviceId: _serviceId, + ); + } catch (e) { + debugPrint('Error reiniciando descubrimiento: $e'); + } + notifyListeners(); + } + + /// Corta el reintento automático (por ejemplo si el usuario sale a menú). + Future cancelarReconexion() async { + _limiteReconexion?.cancel(); + _limiteReconexion = null; + if (!_reconectando) return; + _reconectando = false; + await pararBusqueda(); + } + void _iniciarHeartbeatCliente() { _heartbeatTimer?.cancel(); @@ -436,9 +507,30 @@ class ServicioNearby extends ChangeNotifier { ) { debugPrint('Host encontrado: $endpointName ($endpointId)'); _hostsEncontrados[endpointId] = endpointName; + // Volvemos solos, pero solo a nuestro host: si hay otra partida cerca no + // queremos aterrizar en la sala equivocada. + final esNuestroHost = + _nombreHostConectado == null || _nombreHostConectado == endpointName; + if (_reconectando && !_conectado && esNuestroHost) { + _reconectarA(endpointId); + } notifyListeners(); } + Future _reconectarA(String endpointId) async { + try { + await Nearby().requestConnection( + _miNombre ?? 'Jugador', + endpointId, + onConnectionInitiated: _onConexionIniciada, + onConnectionResult: _onResultadoConexion, + onDisconnected: _onDesconexion, + ); + } catch (e) { + debugPrint('Error reconectando a $endpointId: $e'); + } + } + void _onEndpointPerdido(String? endpointId) { debugPrint('Endpoint perdido: $endpointId'); if (endpointId != null) { @@ -494,7 +586,8 @@ class ServicioNearby extends ChangeNotifier { _registrarClienteRemoto(endpointId, mensaje); break; case TipoMensaje.voto: - _notificarMensaje(endpointId, mensaje); + // El reparto a los listeners lo hace _procesarMensaje al final; hacerlo + // aquí también entregaba cada voto dos veces. break; case TipoMensaje.listo: final jugador = _jugadores[endpointId]; @@ -522,6 +615,9 @@ class ServicioNearby extends ChangeNotifier { case TipoMensaje.usuariosActualizados: _handleUsuariosActualizados(mensaje); break; + case TipoMensaje.solicitarResync: + // Lo resuelve la pantalla gestora, que es quien tiene la partida. + break; default: break; } @@ -535,22 +631,45 @@ class ServicioNearby extends ChangeNotifier { final medallas = (mensaje.datos['medallas'] as List? ?? const []) .map((valor) => valor.toString()) .toList(); - final clientId = endpointId; + + // El clientId debe sobrevivir a la reconexión, y el endpointId no lo hace: + // Nearby asigna uno nuevo cada vez. Con clientes antiguos que no mandan + // deviceId se cae al comportamiento de siempre. + final deviceId = mensaje.datos['deviceId'] as String?; + final clientId = deviceId ?? endpointId; + final sala = _estadoSala; + final esReconexion = sala?.esReconexion(clientId) ?? false; + + // Si el mismo dispositivo tenía otro endpoint abierto, se descarta. + _jugadores.removeWhere( + (id, _) => + id != endpointId && + sala?.clientePorEndpoint(id)?.clientId == clientId, + ); _jugadores[endpointId] = JugadorConectado( endpointId: endpointId, nombre: nombre, ); - _estadoSala?.registrarCliente( + sala?.registrarCliente( ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre), ); - _crearUsuarioAutomaticoCliente( - clientId: clientId, - nombre: nombre, - nick: nick, - avatar: avatar, - fuego: fuego, - medallas: medallas, - ); + + if (esReconexion) { + // Vuelve un móvil que se había caído: recupera los jugadores que el host + // le había absorbido mientras tanto. + sala?.devolverUsuariosAbsorbidos(clientId); + } else { + _crearUsuarioAutomaticoCliente( + clientId: clientId, + nombre: nombre, + nick: nick, + avatar: avatar, + fuego: fuego, + medallas: medallas, + ); + } + + final partidaEnCurso = sala?.fase == FaseSalaMultijugador.enPartida; enviarMensaje( endpointId, @@ -560,11 +679,13 @@ class ServicioNearby extends ChangeNotifier { 'clientId': clientId, 'sala': _nombreSala, 'roomId': _roomId, + 'reconexion': esReconexion, + 'partidaEnCurso': partidaEnCurso, 'jugadores': _jugadores.values .map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId}) .toList(), 'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(), - if (_estadoSala != null) 'estadoSala': _estadoSala!.toJson(), + if (sala != null) 'estadoSala': sala.toJson(), }, ), ); @@ -736,6 +857,20 @@ class ServicioNearby extends ChangeNotifier { if (estadoSalaJson != null) { _sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson)); } + _partidaEnCursoAlEntrar = + mensaje.datos['partidaEnCurso'] as bool? ?? false; + if (_partidaEnCursoAlEntrar) { + // Entramos con la partida ya empezada: pedimos el estado completo en + // vez de quedarnos esperando en el lobby. + solicitarResync(); + } + notifyListeners(); + break; + case TipoMensaje.resync: + _faseActual = mensaje.datos['fase'] as String?; + _datosPartida = mensaje.datos; + final pista = mensaje.datos['pistaImpostor'] as String?; + if (pista != null) _datosPartida!['pistaImpostor'] = pista; notifyListeners(); break; case TipoMensaje.estadoSala: @@ -944,12 +1079,15 @@ class ServicioNearby extends ChangeNotifier { required String categoria, required Map impostoresPorJugadorId, required List> jugadoresTodos, + bool impostoresSeConocen = false, + String? pistaImpostor, }) async { final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( asignaciones: asignaciones, palabraSecreta: palabraSecreta, categoria: categoria, impostoresPorJugadorId: impostoresPorJugadorId, + impostoresSeConocen: impostoresSeConocen, ); for (final payload in payloads.values) { @@ -958,6 +1096,8 @@ class ServicioNearby extends ChangeNotifier { final datos = payload.toJson(); datos['jugadoresTodos'] = jugadoresTodos; datos['roomId'] = _roomId; + // Solo viaja si la partida tiene la pista activada. + if (pistaImpostor != null) datos['pistaImpostor'] = pistaImpostor; await enviarMensaje( endpointId, MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos), @@ -965,6 +1105,30 @@ class ServicioNearby extends ChangeNotifier { } } + /// El cliente pide al host el estado completo de la partida en curso. + Future solicitarResync() async { + final hostId = _hostEndpointId; + if (_esHost || hostId == null) return; + await enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.solicitarResync, + datos: {if (_miClientId != null) 'clientId': _miClientId}, + ), + ); + } + + /// El host responde a un cliente concreto con el estado completo. + Future enviarResync( + String endpointId, + Map datos, + ) async { + await enviarMensaje( + endpointId, + MensajeP2P(tipo: TipoMensaje.resync, datos: datos), + ); + } + Future enviarCambioFase( String fase, [ Map? extra, @@ -988,7 +1152,10 @@ class ServicioNearby extends ChangeNotifier { // ==================== LIMPIEZA ==================== Future desconectar() async { + _cerrando = true; _heartbeatTimer?.cancel(); + _limiteReconexion?.cancel(); + _limiteReconexion = null; try { await Nearby().stopAllEndpoints(); if (_anunciando) await Nearby().stopAdvertising(); @@ -1020,6 +1187,10 @@ class ServicioNearby extends ChangeNotifier { _hostsEncontrados.clear(); _usuariosPool.clear(); _heartbeatTimer = null; + _reconectando = false; + _partidaEnCursoAlEntrar = false; + _nombreHostConectado = null; + _cerrando = false; notifyListeners(); } @@ -1045,6 +1216,7 @@ class ServicioNearby extends ChangeNotifier { @override void dispose() { _heartbeatTimer?.cancel(); + _limiteReconexion?.cancel(); desconectar(); super.dispose(); } diff --git a/lib/tema/componentes_farolero.dart b/lib/tema/componentes_farolero.dart index fa504a5..8699271 100644 --- a/lib/tema/componentes_farolero.dart +++ b/lib/tema/componentes_farolero.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../l10n/generated/app_localizations.dart'; import '../modelos/gamificacion_usuario.dart'; import 'tema_app.dart'; @@ -1072,6 +1073,86 @@ class TarjetaPalabraFarolero extends StatelessWidget { } } +/// Pista que ve el impostor cuando la partida la tiene activada. +class PistaImpostorFarolero extends StatelessWidget { + final String pista; + + const PistaImpostorFarolero({super.key, required this.pista}); + + @override + Widget build(BuildContext context) { + return Text( + AppLocalizations.of(context)!.clueIs(pista), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja), + ); + } +} + +/// Panel con el resto de impostores. Solo se muestra a un impostor y solo +/// cuando la partida permite que se conozcan entre ellos. +class CompanerosImpostorFarolero extends StatelessWidget { + final List nombres; + + const CompanerosImpostorFarolero({super.key, required this.nombres}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (nombres.isEmpty) { + return Text( + l10n.youAreTheOnlyImpostor, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: TemaApp.colorTextoSecundario), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: TemaApp.decoracionPanel( + color: TemaApp.colorAcento.withValues(alpha: 0.16), + borderColor: TemaApp.colorAcento.withValues(alpha: 0.65), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconoFarolero( + Icons.groups, + color: TemaApp.colorAcento, + size: 20, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.otherImpostorsTitle(nombres.length), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + nombres.join(' · '), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorAcento, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + class AvatarFarolero extends StatelessWidget { final String texto; final String? assetPath; diff --git a/test/modelos/reconexion_sala_test.dart b/test/modelos/reconexion_sala_test.dart new file mode 100644 index 0000000..22301c4 --- /dev/null +++ b/test/modelos/reconexion_sala_test.dart @@ -0,0 +1,156 @@ +import 'package:farolero/modelos/sala_multijugador.dart'; +import 'package:farolero/modelos/usuario.dart'; +import 'package:flutter_test/flutter_test.dart'; + +EstadoSalaMultijugador _salaConCliente() { + final sala = EstadoSalaMultijugador.crear( + roomId: 'r1', + nombreSala: 'Sala', + hostClientId: 'host', + hostNombre: 'Ana', + ); + sala.registrarCliente( + const ClienteSala( + clientId: 'dev-beto', + endpointId: 'ep-1', + nombre: 'Beto', + ), + ); + sala.usuarios['u-beto'] = Usuario( + id: 'u-beto', + nombre: 'Beto', + creadoPorClienteId: 'dev-beto', + clienteIdSeleccionado: 'dev-beto', + ); + return sala; +} + +void main() { + group('Reconexión de un cliente', () { + test('el mismo clientId con endpoint nuevo no crea un cliente duplicado', () { + final sala = _salaConCliente(); + + sala.registrarCliente( + const ClienteSala( + clientId: 'dev-beto', + endpointId: 'ep-2', + nombre: 'Beto', + ), + ); + + expect(sala.clientes.length, 2, reason: 'host + Beto, sin duplicados'); + expect(sala.clientes['dev-beto']!.endpointId, 'ep-2'); + expect(sala.clientes['dev-beto']!.conectado, isTrue); + }); + + test('esReconexion distingue a quien vuelve de quien llega nuevo', () { + final sala = _salaConCliente(); + + expect(sala.esReconexion('dev-beto'), isTrue); + expect(sala.esReconexion('dev-cris'), isFalse); + }); + + test('en partida, desconectarse no libera a sus jugadores', () { + final sala = _salaConCliente(); + sala.fase = FaseSalaMultijugador.enPartida; + + sala.desconectarCliente('dev-beto'); + + expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto'); + expect(sala.clientes['dev-beto']!.conectado, isFalse); + }); + + test('en lobby, desconectarse sí libera a sus jugadores', () { + final sala = _salaConCliente(); + + sala.desconectarCliente('dev-beto'); + + expect(sala.usuarios['u-beto']!.estaDisponible, isTrue); + }); + }); + + group('Absorción por el host y devolución', () { + test('el host absorbe y queda constancia de quién era el dueño', () { + final sala = _salaConCliente(); + sala.fase = FaseSalaMultijugador.enPartida; + sala.desconectarCliente('dev-beto'); + + final reasignados = sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'dev-beto', + clientIdDestino: 'host', + ); + + expect(reasignados, 1); + expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'host'); + expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto'); + expect(sala.usuariosAbsorbidosDe('dev-beto').single.id, 'u-beto'); + }); + + test('al volver el móvil recupera a sus jugadores', () { + final sala = _salaConCliente(); + sala.fase = FaseSalaMultijugador.enPartida; + sala.desconectarCliente('dev-beto'); + sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'dev-beto', + clientIdDestino: 'host', + ); + + sala.registrarCliente( + const ClienteSala( + clientId: 'dev-beto', + endpointId: 'ep-2', + nombre: 'Beto', + ), + ); + final devueltos = sala.devolverUsuariosAbsorbidos('dev-beto'); + + expect(devueltos, 1); + expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto'); + expect(sala.usuarios['u-beto']!.absorbidoDe, isNull); + expect(sala.usuariosPorCliente('host'), isEmpty); + }); + + test('una doble absorción no pierde al dueño original', () { + final sala = _salaConCliente(); + sala.fase = FaseSalaMultijugador.enPartida; + sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'dev-beto', + clientIdDestino: 'host', + ); + // El host vuelve a pasar por el mismo camino: no debe reescribir el + // origen a 'host' y dejar al usuario huérfano. + sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'host', + clientIdDestino: 'host', + ); + + expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto'); + }); + + test('no se devuelve nada a un cliente que no está en la sala', () { + final sala = _salaConCliente(); + sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'dev-beto', + clientIdDestino: 'host', + ); + + expect(sala.devolverUsuariosAbsorbidos('dev-fantasma'), 0); + }); + }); + + group('Serialización', () { + test('absorbidoDe sobrevive al viaje JSON', () { + final sala = _salaConCliente(); + sala.fase = FaseSalaMultijugador.enPartida; + sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'dev-beto', + clientIdDestino: 'host', + ); + + final reparsed = EstadoSalaMultijugador.fromJson(sala.toJson()); + + expect(reparsed.usuarios['u-beto']!.absorbidoDe, 'dev-beto'); + expect(reparsed.clientes['dev-beto']!.endpointId, 'ep-1'); + }); + }); +} diff --git a/test/modelos/rol_impostor_test.dart b/test/modelos/rol_impostor_test.dart new file mode 100644 index 0000000..6768078 --- /dev/null +++ b/test/modelos/rol_impostor_test.dart @@ -0,0 +1,198 @@ +import 'package:farolero/estado/estado_juego.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; +import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/modelos/palabra.dart'; +import 'package:farolero/modelos/partida.dart'; +import 'package:farolero/modelos/snapshot_partida_online.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Partida _partidaConImpostores() => Partida( + config: const ConfigPartida(numImpostores: 2), + jugadores: [ + Jugador(id: 'j1', nombre: 'Ana', esImpostor: true), + Jugador(id: 'j2', nombre: 'Beto'), + Jugador(id: 'j3', nombre: 'Cris', esImpostor: true), + Jugador(id: 'j4', nombre: 'Dani'), + ], + palabraSecreta: 'Camión', + categoriaReal: 'objetos', +); + +void main() { + group('SnapshotPartidaOnline no filtra los roles', () { + test('durante la partida no envía esImpostor de nadie', () { + final json = SnapshotPartidaOnline.desdePartida( + _partidaConImpostores(), + fase: 'debate', + ).toJson(); + + final jugadores = json['jugadoresTodos'] as List; + for (final jugador in jugadores) { + expect( + (jugador as Map).containsKey('esImpostor'), + isFalse, + reason: 'el rol no puede viajar en el snapshot de fase', + ); + } + expect(json.containsKey('impostores'), isFalse); + expect(json.containsKey('palabraSecreta'), isFalse); + }); + + test('al final de la partida sí revela roles y palabra', () { + final json = SnapshotPartidaOnline.desdePartida( + _partidaConImpostores(), + fase: 'finPartida', + revelarImpostores: true, + revelarPalabra: true, + ).toJson(); + + final jugadores = (json['jugadoresTodos'] as List) + .cast>(); + expect(jugadores.every((j) => j.containsKey('esImpostor')), isTrue); + expect(json['impostores'], ['Ana', 'Cris']); + expect(json['palabraSecreta'], 'Camión'); + }); + + test('el snapshot de fase se reparsea sin marcar impostores', () { + final snapshot = SnapshotPartidaOnline.fromJson( + SnapshotPartidaOnline.desdePartida( + _partidaConImpostores(), + fase: 'votacion', + ).toJson(), + ); + + expect(snapshot.jugadores.any((j) => j.esImpostor), isFalse); + expect(snapshot.revelarImpostores, isFalse); + }); + }); + + group('Los impostores se conocen entre ellos', () { + final asignaciones = const [ + AsignacionJugador( + jugadorId: 'j1', + nombre: 'Ana', + clientId: 'host', + endpointId: null, + ), + AsignacionJugador( + jugadorId: 'j2', + nombre: 'Beto', + clientId: 'c2', + endpointId: 'e2', + ), + AsignacionJugador( + jugadorId: 'j3', + nombre: 'Cris', + clientId: 'c3', + endpointId: 'e3', + ), + ]; + const impostores = {'j1': true, 'j2': false, 'j3': true}; + + test('cada impostor recibe los nombres del resto, nunca el suyo', () { + final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( + asignaciones: asignaciones, + palabraSecreta: 'Camión', + categoria: 'objetos', + impostoresPorJugadorId: impostores, + impostoresSeConocen: true, + ); + + expect(payloads['host']!.jugadores.single.companerosImpostores, ['Cris']); + expect(payloads['c3']!.jugadores.single.companerosImpostores, ['Ana']); + }); + + test('un jugador normal nunca recibe la lista', () { + final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( + asignaciones: asignaciones, + palabraSecreta: 'Camión', + categoria: 'objetos', + impostoresPorJugadorId: impostores, + impostoresSeConocen: true, + ); + + expect(payloads['c2']!.jugadores.single.companerosImpostores, isNull); + }); + + test('con la opción desactivada nadie recibe la lista', () { + final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( + asignaciones: asignaciones, + palabraSecreta: 'Camión', + categoria: 'objetos', + impostoresPorJugadorId: impostores, + impostoresSeConocen: false, + ); + + for (final payload in payloads.values) { + for (final jugador in payload.jugadores) { + expect(jugador.companerosImpostores, isNull); + } + } + }); + + test('la lista vacía sobrevive al viaje JSON y no se vuelve null', () { + const jugador = JugadorInicioPartida( + jugadorId: 'j1', + nombre: 'Ana', + esImpostor: true, + palabra: null, + companerosImpostores: [], + ); + + final reparsed = JugadorInicioPartida.fromJson(jugador.toJson()); + + expect(reparsed.companerosImpostores, isEmpty); + expect(reparsed.companerosImpostores, isNotNull); + }); + }); + + group('Pistas del banco de palabras', () { + test('prioriza la pista de la palabra sobre la de la categoría', () { + final banco = BancoPalabras( + { + 'objetos': ['Camión', 'Silla'], + }, + pistasPorCategoria: {'objetos': 'Objetos'}, + pistasPorPalabra: {'Camión': 'Se conduce y transporta carga'}, + ); + + expect(banco.pistaDePalabra('Camión'), 'Se conduce y transporta carga'); + expect(banco.pistaDePalabra('Silla'), 'Objetos'); + }); + + test('sin pista de palabra ni de categoría devuelve null', () { + final banco = BancoPalabras({ + 'objetos': ['Silla'], + }); + + expect(banco.pistaDePalabra('Silla'), isNull); + }); + }); + + group('Tope de impostores', () { + test('es el mismo en ambos modos de juego', () { + expect(EstadoJuego.maxImpostoresPara(3), 1); + expect(EstadoJuego.maxImpostoresPara(5), 1); + expect(EstadoJuego.maxImpostoresPara(6), 2); + expect(EstadoJuego.maxImpostoresPara(12), 4); + expect(EstadoJuego.maxImpostoresPara(20), 4); + }); + }); + + group('Partida', () { + test('la pista cae a la categoría cuando no se aporta una específica', () { + final partida = Partida( + config: const ConfigPartida(), + jugadores: [Jugador(id: 'j1', nombre: 'Ana')], + palabraSecreta: 'Camión', + categoriaReal: 'objetos', + ); + + expect(partida.pistaImpostor, 'objetos'); + }); + + test('nombresImpostores lista solo a los impostores', () { + expect(_partidaConImpostores().nombresImpostores, ['Ana', 'Cris']); + }); + }); +}