feat(multidispositivo): reconnection, impostor awareness and protocol fixes

Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
  over. Every phase change was broadcasting the impostor roles in clear to
  all clients.
- The impostor clue travels in its own field and only when the game enables
  it. It used to be sent as the category key ("todas" when no category was
  picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
  screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
  twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
  impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
  silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.

Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
  nullable: null means nothing to show, an empty list means "you are the
  only impostor".

Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
  clientId so a phone that drops and returns is the same client. Falls back
  to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
  disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
  phase plus that client's own players, and the client jumps straight to the
  running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
  client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
  belonged to, and is cancelled correctly while shutting down.

Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
  new one carrying a clue per word, falling back to the category clue.

Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
This commit is contained in:
2026-07-25 20:36:32 +02:00
parent bad641653c
commit 863690168c
23 changed files with 1284 additions and 235 deletions
+47
View File
@@ -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<String> 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<int>.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;
}
+184 -12
View File
@@ -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<String, Usuario> _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<void> _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<void> 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<void> _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<dynamic>? ?? 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<String, bool> impostoresPorJugadorId,
required List<Map<String, dynamic>> 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<void> 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<void> enviarResync(
String endpointId,
Map<String, dynamic> datos,
) async {
await enviarMensaje(
endpointId,
MensajeP2P(tipo: TipoMensaje.resync, datos: datos),
);
}
Future<void> enviarCambioFase(
String fase, [
Map<String, dynamic>? extra,
@@ -988,7 +1152,10 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ====================
Future<void> 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();
}