From a5d24c272169916acbb41d604caf2ac0dbbe049e Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 9 May 2026 16:23:55 +0200 Subject: [PATCH] =?UTF-8?q?NUEVA=20GESTI=C3=93N=20DE=20USUARIOS=20Y=20PART?= =?UTF-8?q?IDAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modelos/sala_multijugador.dart | 46 ++++++ lib/pantallas/pantalla_crear_partida.dart | 128 +++++++++++----- .../pantalla_fin_partida_online.dart | 25 ++- lib/pantallas/pantalla_gestor_host.dart | 77 +++++++++- lib/pantallas/pantalla_principal.dart | 28 +--- .../pantalla_seleccion_modo_juego.dart | 144 ++++++++++++++++++ lib/pantallas/pantalla_unirse.dart | 17 ++- .../servicio_historial_partidas.dart | 30 ++++ lib/servicios/servicio_nearby.dart | 120 ++++++++++++++- test/modelos/sala_multijugador_test.dart | 38 +++++ test/servicio_historial_partidas_test.dart | 34 +++++ 11 files changed, 606 insertions(+), 81 deletions(-) create mode 100644 lib/pantallas/pantalla_seleccion_modo_juego.dart create mode 100644 test/servicio_historial_partidas_test.dart diff --git a/lib/modelos/sala_multijugador.dart b/lib/modelos/sala_multijugador.dart index caa3bf5..4d193da 100644 --- a/lib/modelos/sala_multijugador.dart +++ b/lib/modelos/sala_multijugador.dart @@ -32,6 +32,7 @@ class ClienteSala { final String nombre; final bool esHost; final bool conectado; + final int ultimaActividadMs; const ClienteSala({ required this.clientId, @@ -39,6 +40,7 @@ class ClienteSala { required this.nombre, this.esHost = false, this.conectado = true, + this.ultimaActividadMs = 0, }); ClienteSala copiar({ @@ -47,6 +49,7 @@ class ClienteSala { String? nombre, bool? esHost, bool? conectado, + int? ultimaActividadMs, }) { return ClienteSala( clientId: clientId ?? this.clientId, @@ -54,6 +57,7 @@ class ClienteSala { nombre: nombre ?? this.nombre, esHost: esHost ?? this.esHost, conectado: conectado ?? this.conectado, + ultimaActividadMs: ultimaActividadMs ?? this.ultimaActividadMs, ); } @@ -63,6 +67,7 @@ class ClienteSala { 'nombre': nombre, 'esHost': esHost, 'conectado': conectado, + 'ultimaActividadMs': ultimaActividadMs, }; factory ClienteSala.fromJson(Map json) => ClienteSala( @@ -71,6 +76,7 @@ class ClienteSala { nombre: json['nombre'] as String, esHost: json['esHost'] as bool? ?? false, conectado: json['conectado'] as bool? ?? true, + ultimaActividadMs: (json['ultimaActividadMs'] as num?)?.toInt() ?? 0, ); } @@ -121,6 +127,19 @@ class EstadoSalaMultijugador { int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length; + List get clientesDesconectados => clientes.values + .where((cliente) => !cliente.esHost && !cliente.conectado) + .toList(); + + List get usuariosDeClientesDesconectados { + final desconectados = clientesDesconectados + .map((cliente) => cliente.clientId) + .toSet(); + return usuarios.values + .where((usuario) => desconectados.contains(usuario.clienteIdSeleccionado)) + .toList(); + } + List usuariosPorCliente(String clientId) { return usuarios.values .where((usuario) => usuario.clienteIdSeleccionado == clientId) @@ -235,6 +254,33 @@ class EstadoSalaMultijugador { } } + void registrarActividadCliente(String clientId, {int? ahoraMs}) { + final cliente = clientes[clientId]; + if (cliente == null) return; + clientes[clientId] = cliente.copiar( + conectado: true, + ultimaActividadMs: + ahoraMs ?? DateTime.now().millisecondsSinceEpoch, + ); + } + + int reasignarUsuariosDeCliente({ + required String clientIdOrigen, + required String clientIdDestino, + }) { + if (!clientes.containsKey(clientIdDestino)) return 0; + var reasignados = 0; + for (final entry in usuarios.entries.toList()) { + if (entry.value.clienteIdSeleccionado == clientIdOrigen) { + usuarios[entry.key] = entry.value.copiar( + clienteIdSeleccionado: clientIdDestino, + ); + reasignados++; + } + } + return reasignados; + } + ResultadoOperacionSala validarInicio() { if (fase != FaseSalaMultijugador.lobby) { return const ResultadoOperacionSala.error('sala_cerrada'); diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart index e8f4cb4..4323b8e 100644 --- a/lib/pantallas/pantalla_crear_partida.dart +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -16,7 +16,14 @@ import 'pantalla_principal.dart'; import 'pantalla_ver_palabra.dart'; class PantallaCrearPartida extends StatefulWidget { - const PantallaCrearPartida({super.key}); + final bool modoInicial; + final bool bloquearModo; + + const PantallaCrearPartida({ + super.key, + this.modoInicial = false, + this.bloquearModo = false, + }); @override State createState() => _PantallaCrearPartidaState(); @@ -33,7 +40,14 @@ class _PantallaCrearPartidaState extends State { final _opcionesTiempo = [null, 60, 120, 180, 300]; - int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4); + @override + void initState() { + super.initState(); + _modoMultimovil = widget.modoInicial; + } + + int get _maxImpostores => + _modoMultimovil ? 4 : (_jugadores.length / 3).floor().clamp(1, 4); List _etiquetasTiempo(AppLocalizations l10n) => [ l10n.noLimit, @@ -295,42 +309,70 @@ class _PantallaCrearPartidaState extends State { ), ), const SizedBox(height: 12), - // Modo de juego - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (!widget.bloquearModo) ...[ + // Modo de juego + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.gameMode, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + ButtonSegment( + value: false, + label: Text(l10n.singleDevice), + icon: const Icon(Icons.phone_android), + ), + ButtonSegment( + value: true, + label: Text(l10n.multiDevice), + icon: const Icon(Icons.devices), + ), + ], + selected: {_modoMultimovil}, + onSelectionChanged: (valor) { + setState(() { + _modoMultimovil = valor.first; + if (_numImpostores > _maxImpostores) { + _numImpostores = _maxImpostores; + } + }); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + ] else ...[ + PanelFarolero( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( children: [ - Text( - l10n.gameMode, - style: Theme.of(context).textTheme.titleLarge, + Icon( + _modoMultimovil ? Icons.devices : Icons.phone_android, + color: TemaApp.colorNaranja, ), - const SizedBox(height: 12), - SegmentedButton( - segments: [ - ButtonSegment( - value: false, - label: Text(l10n.singleDevice), - icon: const Icon(Icons.phone_android), - ), - ButtonSegment( - value: true, - label: Text(l10n.multiDevice), - icon: const Icon(Icons.devices), - ), - ], - selected: {_modoMultimovil}, - onSelectionChanged: (valor) { - setState(() => _modoMultimovil = valor.first); - }, + const SizedBox(width: 12), + Expanded( + child: Text( + _modoMultimovil + ? 'Partida multidispositivo' + : 'Partida en este dispositivo', + style: Theme.of(context).textTheme.titleMedium, + ), ), ], ), ), - ), - const SizedBox(height: 12), - + const SizedBox(height: 12), + ], // Categoría Card( child: Padding( @@ -367,13 +409,14 @@ class _PantallaCrearPartidaState extends State { ), const SizedBox(height: 12), - // Jugadores - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + if (!_modoMultimovil) ...[ + // Jugadores + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -429,11 +472,12 @@ class _PantallaCrearPartidaState extends State { dense: true, ); }), - ], + ], + ), ), ), - ), - const SizedBox(height: 12), + const SizedBox(height: 12), + ], // Configuración de partida Card( diff --git a/lib/pantallas/pantalla_fin_partida_online.dart b/lib/pantallas/pantalla_fin_partida_online.dart index a02bdcd..a502f20 100644 --- a/lib/pantallas/pantalla_fin_partida_online.dart +++ b/lib/pantallas/pantalla_fin_partida_online.dart @@ -4,13 +4,14 @@ import 'package:provider/provider.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/palabra.dart'; import '../modelos/snapshot_partida_online.dart'; +import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/tema_app.dart'; import 'pantalla_notas_online.dart'; import 'pantalla_principal.dart'; import 'pantalla_revision_palabra.dart'; -class PantallaFinPartidaOnline extends StatelessWidget { +class PantallaFinPartidaOnline extends StatefulWidget { final SnapshotPartidaOnline snapshot; final List jugadoresControlados; final String? pistaCategoria; @@ -22,11 +23,33 @@ class PantallaFinPartidaOnline extends StatelessWidget { this.pistaCategoria, }); + @override + State createState() => + _PantallaFinPartidaOnlineState(); +} + +class _PantallaFinPartidaOnlineState extends State { + bool _guardada = false; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final snapshot = widget.snapshot; + final jugadoresControlados = widget.jugadoresControlados; + final pistaCategoria = widget.pistaCategoria; final ganaronJugadores = snapshot.ganador == 'jugadores'; + if (!_guardada && snapshot.ganador != null) { + _guardada = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context + .read() + .guardarSnapshotOnline(snapshot); + } + }); + } + return Scaffold( appBar: AppBar( title: Text(l10n.gameOver), diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index 93e5bdf..75ec5d7 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -8,6 +8,7 @@ import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/jugador.dart'; import '../modelos/partida.dart'; import '../modelos/snapshot_partida_online.dart'; +import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/componentes_farolero.dart'; import '../tema/tema_app.dart'; @@ -29,6 +30,7 @@ class _PantallaGestorHostState extends State { Timer? _timer; int _segundosRestantes = 0; bool _hostListo = false; + bool _partidaOnlineGuardada = false; String? _primerTurnoId; String? _primerTurnoNombre; final Map _clientesListos = {}; @@ -200,6 +202,7 @@ class _PantallaGestorHostState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ + _buildAvisoClientesDesconectados(context, nearby), _buildFaseIndicator(context, partida.fase, l10n), const SizedBox(height: 16), Expanded( @@ -226,6 +229,58 @@ class _PantallaGestorHostState extends State { ); } + Widget _buildAvisoClientesDesconectados( + BuildContext context, + ServicioNearby nearby, + ) { + final sala = nearby.estadoSala; + final usuariosAfectados = sala?.usuariosDeClientesDesconectados ?? const []; + if (usuariosAfectados.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: TemaApp.decoracionPanel( + color: TemaApp.colorAcento.withValues(alpha: 0.16), + borderColor: TemaApp.colorAcento.withValues(alpha: 0.65), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.link_off, color: TemaApp.colorAcento), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Hay jugadores con el dispositivo desconectado.', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + usuariosAfectados.map((usuario) => usuario.nombre).join(', '), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () => nearby.asumirUsuariosDesconectados(), + icon: const Icon(Icons.person_add_alt_1), + label: const Text('Asumir en este móvil'), + ), + ), + ], + ), + ), + ); + } + Widget _buildFaseIndicator( BuildContext context, FaseJuego fase, @@ -1115,12 +1170,21 @@ class _PantallaGestorHostState extends State { final estado = context.read(); final nearby = context.read(); estado.comprobarFinPartida(); - await nearby.enviarFinPartida( - _snapshot(fase: 'finPartida', revelarFinal: true).toJson(), - ); + final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true) + .toJson(); + await _guardarHistorialOnlineHost(context); + await nearby.enviarFinPartida(snapshotFinal); if (mounted) setState(() {}); } + Future _guardarHistorialOnlineHost(BuildContext context) async { + if (_partidaOnlineGuardada) return; + final partida = context.read().partida; + if (partida?.ganador == null) return; + _partidaOnlineGuardada = true; + await context.read().guardarPartida(partida!); + } + Future _iniciarAdivinanzaOnline(BuildContext context) async { final estado = context.read(); final nearby = context.read(); @@ -1170,9 +1234,10 @@ class _PantallaGestorHostState extends State { final acierto = estado.intentarAdivinar(intento); if (acierto) { final nearby = context.read(); - await nearby.enviarFinPartida( - _snapshot(fase: 'finPartida', revelarFinal: true).toJson(), - ); + final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true) + .toJson(); + await _guardarHistorialOnlineHost(context); + await nearby.enviarFinPartida(snapshotFinal); if (mounted) setState(() {}); return; } diff --git a/lib/pantallas/pantalla_principal.dart b/lib/pantallas/pantalla_principal.dart index fe2150c..90674ae 100644 --- a/lib/pantallas/pantalla_principal.dart +++ b/lib/pantallas/pantalla_principal.dart @@ -5,7 +5,7 @@ import '../servicios/servicio_perfil_usuario.dart'; import '../tema/componentes_farolero.dart'; import '../tema/tema_app.dart'; import 'pantalla_ajustes.dart'; -import 'pantalla_crear_partida.dart'; +import 'pantalla_seleccion_modo_juego.dart'; import 'pantalla_historial.dart'; import 'pantalla_reglas.dart'; import 'pantalla_unirse.dart'; @@ -94,7 +94,7 @@ class PantallaPrincipal extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (_) => const PantallaCrearPartida(), + builder: (_) => const PantallaSeleccionModoJuego(), ), ); }, @@ -142,30 +142,6 @@ class PantallaPrincipal extends StatelessWidget { }, ), ), - const SizedBox(width: 8), - Expanded( - child: AccesoFarolero( - etiqueta: 'Logros', - icono: Icons.emoji_events, - onPressed: () {}, - ), - ), - const SizedBox(width: 8), - Expanded( - child: AccesoFarolero( - etiqueta: 'Ranking', - icono: Icons.bar_chart, - onPressed: () {}, - ), - ), - const SizedBox(width: 8), - Expanded( - child: AccesoFarolero( - etiqueta: 'Tienda', - icono: Icons.storefront, - onPressed: () {}, - ), - ), ], ), const SizedBox(height: 28), diff --git a/lib/pantallas/pantalla_seleccion_modo_juego.dart b/lib/pantallas/pantalla_seleccion_modo_juego.dart new file mode 100644 index 0000000..4abd61d --- /dev/null +++ b/lib/pantallas/pantalla_seleccion_modo_juego.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +import '../tema/componentes_farolero.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_crear_partida.dart'; + +class PantallaSeleccionModoJuego extends StatelessWidget { + const PantallaSeleccionModoJuego({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Elegir modo de juego')), + body: FondoFarolero( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.sports_esports, + size: 64, + color: TemaApp.colorNaranja, + ), + const SizedBox(height: 16), + Text( + '¿Cómo querés jugar?', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Elegí primero el tipo de partida para configurar solo lo necesario.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 28), + _ModoCard( + icono: Icons.phone_android, + titulo: 'Partida en este dispositivo', + descripcion: + 'Todos los jugadores usan este móvil. Acá se agregan los nombres manualmente.', + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaCrearPartida( + modoInicial: false, + bloquearModo: true, + ), + ), + ), + ), + const SizedBox(height: 14), + _ModoCard( + icono: Icons.devices, + titulo: 'Partida multidispositivo', + descripcion: + 'Este móvil crea el servidor. Los usuarios se gestionan después en el lobby.', + destacado: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaCrearPartida( + modoInicial: true, + bloquearModo: true, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModoCard extends StatelessWidget { + final IconData icono; + final String titulo; + final String descripcion; + final bool destacado; + final VoidCallback onTap; + + const _ModoCard({ + required this.icono, + required this.titulo, + required this.descripcion, + required this.onTap, + this.destacado = false, + }); + + @override + Widget build(BuildContext context) { + final color = destacado ? TemaApp.colorNaranja : TemaApp.colorAcento; + return Card( + color: TemaApp.colorTarjeta, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withValues(alpha: 0.7)), + ), + child: Icon(icono, color: color, size: 30), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titulo, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 6), + Text( + descripcion, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index 777230b..8733405 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -314,7 +314,12 @@ class _PantallaUnirseState extends State { if (!mounted) return; final nearby = context.read(); - final ok = await nearby.buscarHosts(_nombreController.text.trim()); + final perfil = context.read().perfil; + final ok = await nearby.buscarHosts( + _nombreController.text.trim(), + miNick: perfil.nick, + miAvatar: perfil.avatarAsset, + ); if (ok) { setState(() { @@ -337,11 +342,14 @@ class _PantallaUnirseState extends State { }); final nearby = context.read(); + final perfil = context.read().perfil; // Parar discovery antes de conectar await nearby.pararBusqueda(); final ok = await nearby.conectarAHost( endpointId, _nombreController.text.trim(), + miNick: perfil.nick, + miAvatar: perfil.avatarAsset, ); if (!ok && mounted) { @@ -381,7 +389,12 @@ class _PantallaUnirseState extends State { // Iniciar búsqueda para que Nearby encuentre al host final nearby = context.read(); if (!nearby.buscando) { - await nearby.buscarHosts(_nombreController.text.trim()); + final perfil = context.read().perfil; + await nearby.buscarHosts( + _nombreController.text.trim(), + miNick: perfil.nick, + miAvatar: perfil.avatarAsset, + ); } return; } diff --git a/lib/servicios/servicio_historial_partidas.dart b/lib/servicios/servicio_historial_partidas.dart index 9acdd31..8363019 100644 --- a/lib/servicios/servicio_historial_partidas.dart +++ b/lib/servicios/servicio_historial_partidas.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../modelos/partida.dart'; +import '../modelos/snapshot_partida_online.dart'; class ResultadoPartidaGuardado { final String id; @@ -42,6 +43,25 @@ class ResultadoPartidaGuardado { ); } + factory ResultadoPartidaGuardado.desdeSnapshotOnline( + SnapshotPartidaOnline snapshot, + ) { + final impostores = snapshot.impostores.isNotEmpty + ? snapshot.impostores.length + : snapshot.jugadores.where((jugador) => jugador.esImpostor).length; + return ResultadoPartidaGuardado( + id: 'online-${snapshot.roomId ?? DateTime.now().microsecondsSinceEpoch}', + fecha: DateTime.now(), + modoMultimovil: true, + jugadores: snapshot.jugadores.length, + impostores: impostores, + rondas: snapshot.ronda, + ganador: snapshot.ganador ?? 'sin_resultado', + palabra: snapshot.palabraSecreta ?? '', + categoria: snapshot.categoria, + ); + } + Map toJson() => { 'id': id, 'fecha': fecha.toIso8601String(), @@ -99,6 +119,16 @@ class ServicioHistorialPartidas extends ChangeNotifier { notifyListeners(); } + Future guardarSnapshotOnline(SnapshotPartidaOnline snapshot) async { + if (snapshot.ganador == null || snapshot.palabraSecreta == null) return; + final guardado = ResultadoPartidaGuardado.desdeSnapshotOnline(snapshot); + if (_partidas.any((partida) => partida.id == guardado.id)) return; + _partidas.insert(0, guardado); + if (_partidas.length > 100) _partidas.removeRange(100, _partidas.length); + await _persistir(); + notifyListeners(); + } + Future limpiar() async { _partidas.clear(); await _persistir(); diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index 4af5625..7d4791a 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -1,4 +1,5 @@ -import 'dart:convert'; +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nearby_connections/nearby_connections.dart'; import '../modelos/inicio_partida_multijugador.dart'; @@ -82,11 +83,14 @@ class ServicioNearby extends ChangeNotifier { String? _miClientId; String? _nombreSala; String? _miNombre; + String? _miNick; + String? _miAvatar; final Map _jugadores = {}; final List _listeners = []; final Map _hostsEncontrados = {}; final Map _usuariosPool = {}; + Timer? _heartbeatTimer; String? _palabraRecibida; bool? _soyImpostor; @@ -273,8 +277,14 @@ class ServicioNearby extends ChangeNotifier { // ==================== CLIENTE ==================== - Future buscarHosts(String miNombre) async { + Future buscarHosts( + String miNombre, { + String? miNick, + String? miAvatar, + }) async { _miNombre = miNombre; + _miNick = miNick; + _miAvatar = miAvatar; try { final resultado = await Nearby().startDiscovery( @@ -297,7 +307,15 @@ class ServicioNearby extends ChangeNotifier { } } - Future conectarAHost(String endpointId, String miNombre) async { + Future conectarAHost( + String endpointId, + String miNombre, { + String? miNick, + String? miAvatar, + }) async { + _miNombre = miNombre; + _miNick = miNick; + _miAvatar = miAvatar; try { await Nearby().requestConnection( miNombre, @@ -332,11 +350,16 @@ class ServicioNearby extends ChangeNotifier { } else { _hostEndpointId = endpointId; _conectado = true; + _iniciarHeartbeatCliente(); enviarMensaje( endpointId, MensajeP2P( tipo: TipoMensaje.unirse, - datos: {'nombre': _miNombre ?? 'Jugador'}, + datos: { + 'nombre': _miNombre ?? 'Jugador', + if (_miNick != null) 'nick': _miNick, + if (_miAvatar != null) 'avatar': _miAvatar, + }, ), ); } @@ -366,10 +389,30 @@ class ServicioNearby extends ChangeNotifier { } else { _conectado = false; _hostEndpointId = null; + _heartbeatTimer?.cancel(); } notifyListeners(); } + + void _iniciarHeartbeatCliente() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) { + final hostId = _hostEndpointId; + if (!_esHost && _conectado && hostId != null) { + enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.ping, + datos: { + 'heartbeat': true, + if (_miClientId != null) 'clientId': _miClientId, + }, + ), + ); + } + }); + } void _onEndpointEncontrado( String endpointId, String endpointName, @@ -426,6 +469,10 @@ class ServicioNearby extends ChangeNotifier { } void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { + final cliente = _estadoSala?.clientePorEndpoint(endpointId); + if (cliente != null) { + _estadoSala?.registrarActividadCliente(cliente.clientId); + } switch (mensaje.tipo) { case TipoMensaje.unirse: _registrarClienteRemoto(endpointId, mensaje); @@ -466,6 +513,8 @@ class ServicioNearby extends ChangeNotifier { void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) { final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; + final nick = mensaje.datos['nick'] as String?; + final avatar = mensaje.datos['avatar'] as String?; final clientId = endpointId; _jugadores[endpointId] = JugadorConectado( endpointId: endpointId, @@ -474,6 +523,12 @@ class ServicioNearby extends ChangeNotifier { _estadoSala?.registrarCliente( ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre), ); + _crearUsuarioAutomaticoCliente( + clientId: clientId, + nombre: nombre, + nick: nick, + avatar: avatar, + ); enviarMensaje( endpointId, @@ -495,6 +550,42 @@ class ServicioNearby extends ChangeNotifier { notifyListeners(); } + void _crearUsuarioAutomaticoCliente({ + required String clientId, + required String nombre, + String? nick, + String? avatar, + }) { + final sala = _estadoSala; + if (sala == null || sala.fase != FaseSalaMultijugador.lobby) return; + + final yaTieneUsuario = sala.usuariosPorCliente(clientId).isNotEmpty; + if (yaTieneUsuario) return; + + final base = nombre.trim().isEmpty ? 'Jugador' : nombre.trim(); + var nombreFinal = base; + var intento = 2; + while (sala.usuarios.values.any( + (u) => u.nombre.trim().toLowerCase() == nombreFinal.toLowerCase(), + )) { + nombreFinal = '$base ($intento)'; + intento++; + } + + final usuario = Usuario( + id: 'u-${_roomId ?? DateTime.now().microsecondsSinceEpoch}-$clientId', + nombre: nombreFinal, + nick: nick, + avatar: avatar, + foto: avatar, + creadoPorClienteId: clientId, + ); + final resultadoCrear = sala.crearUsuario(usuario); + if (!resultadoCrear.exitoso) return; + sala.seleccionarUsuario(usuarioId: usuario.id, clienteId: clientId); + _sincronizarPoolDesdeSala(); + } + void _handleUsuarioNuevo(MensajeP2P mensaje) { final usuarioJson = mensaje.datos['usuario'] as Map?; if (usuarioJson != null) { @@ -776,6 +867,22 @@ class ServicioNearby extends ChangeNotifier { ); } + Future asumirUsuariosDesconectados() async { + final sala = _estadoSala; + if (!_esHost || sala == null) return; + var huboCambios = false; + for (final cliente in sala.clientesDesconectados) { + final reasignados = sala.reasignarUsuariosDeCliente( + clientIdOrigen: cliente.clientId, + clientIdDestino: sala.hostClientId, + ); + huboCambios = huboCambios || reasignados > 0; + } + if (huboCambios) { + await _broadcastEstadoSala(); + } + } + // ==================== HOST: ACCIONES DE JUEGO ==================== Future enviarInicioPartida({ @@ -851,6 +958,7 @@ class ServicioNearby extends ChangeNotifier { // ==================== LIMPIEZA ==================== Future desconectar() async { + _heartbeatTimer?.cancel(); try { await Nearby().stopAllEndpoints(); if (_anunciando) await Nearby().stopAdvertising(); @@ -869,6 +977,8 @@ class ServicioNearby extends ChangeNotifier { _miClientId = null; _nombreSala = null; _miNombre = null; + _miNick = null; + _miAvatar = null; _palabraRecibida = null; _soyImpostor = null; _faseActual = null; @@ -877,6 +987,7 @@ class ServicioNearby extends ChangeNotifier { _jugadores.clear(); _hostsEncontrados.clear(); _usuariosPool.clear(); + _heartbeatTimer = null; notifyListeners(); } @@ -901,6 +1012,7 @@ class ServicioNearby extends ChangeNotifier { @override void dispose() { + _heartbeatTimer?.cancel(); desconectar(); super.dispose(); } diff --git a/test/modelos/sala_multijugador_test.dart b/test/modelos/sala_multijugador_test.dart index 38d0bb4..db5b99a 100644 --- a/test/modelos/sala_multijugador_test.dart +++ b/test/modelos/sala_multijugador_test.dart @@ -140,6 +140,44 @@ void main() { expect(sala.usuarios['helena']?.estaDisponible, isTrue); }); + + test('registra actividad y conserva usuarios seleccionados en partida al desconectar', () { + sala.registrarCliente( + const ClienteSala( + clientId: 'cliente-sofia', + endpointId: 'endpoint-2', + nombre: 'Sofía', + ), + ); + sala + ..crearUsuario(Usuario(id: 'ana', nombre: 'Ana')) + ..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía')) + ..crearUsuario(Usuario(id: 'helena', nombre: 'Helena')) + ..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host') + ..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'cliente-sofia') + ..seleccionarUsuario(usuarioId: 'helena', clienteId: 'cliente-sofia'); + + sala.registrarActividadCliente('cliente-sofia', ahoraMs: 1234); + expect(sala.clientes['cliente-sofia']?.conectado, isTrue); + expect(sala.clientes['cliente-sofia']?.ultimaActividadMs, 1234); + + sala.iniciarPartida(); + sala.desconectarCliente('cliente-sofia'); + + expect(sala.clientes['cliente-sofia']?.conectado, isFalse); + expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'cliente-sofia'); + expect(sala.usuarios['helena']?.clienteIdSeleccionado, 'cliente-sofia'); + expect(sala.usuariosDeClientesDesconectados.length, 2); + + final reasignados = sala.reasignarUsuariosDeCliente( + clientIdOrigen: 'cliente-sofia', + clientIdDestino: 'host', + ); + + expect(reasignados, 2); + expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'host'); + expect(sala.usuarios['helena']?.clienteIdSeleccionado, 'host'); + }); test('serializa y restaura clientes y usuarios seleccionados', () { sala ..crearUsuario(Usuario(id: 'ana', nombre: 'Ana')) diff --git a/test/servicio_historial_partidas_test.dart b/test/servicio_historial_partidas_test.dart new file mode 100644 index 0000000..f7d7421 --- /dev/null +++ b/test/servicio_historial_partidas_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/modelos/snapshot_partida_online.dart'; +import 'package:farolero/servicios/servicio_historial_partidas.dart'; + +void main() { + test('crea resumen de historial desde snapshot online final', () { + final snapshot = SnapshotPartidaOnline( + roomId: 'room-1', + fase: 'finPartida', + ronda: 2, + categoria: 'animales', + palabraSecreta: 'León', + ganador: 'jugadores', + jugadores: [ + Jugador(id: 'j1', nombre: 'Ana'), + Jugador(id: 'j2', nombre: 'Bruno', esImpostor: true), + Jugador(id: 'j3', nombre: 'Clara'), + ], + impostores: ['Bruno'], + ); + + final guardado = ResultadoPartidaGuardado.desdeSnapshotOnline(snapshot); + + expect(guardado.id, 'online-room-1'); + expect(guardado.modoMultimovil, isTrue); + expect(guardado.jugadores, 3); + expect(guardado.impostores, 1); + expect(guardado.rondas, 2); + expect(guardado.ganador, 'jugadores'); + expect(guardado.palabra, 'León'); + expect(guardado.categoria, 'animales'); + }); +} \ No newline at end of file