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
@@ -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<String>? companerosImpostores;
const JugadorInicioPartida({
required this.jugadorId,
required this.nombre,
required this.esImpostor,
required this.palabra,
this.companerosImpostores,
});
Map<String, dynamic> 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<String, dynamic> json) {
final companeros = json['companerosImpostores'] as List<dynamic>?;
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<String, bool> impostoresPorJugadorId,
bool impostoresSeConocen = false,
}) {
final payloads = <String, InicioPartidaCliente>{};
final nombresImpostores = <String, String>{
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) {
+67 -9
View File
@@ -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<String, List<String>> categorias;
final Map<String, String> pistasPorCategoria;
BancoPalabras(this.categorias, {Map<String, String>? pistasPorCategoria})
: pistasPorCategoria = pistasPorCategoria ?? {};
/// Pista por palabra, cuando el banco la aporta.
final Map<String, String> pistasPorPalabra;
BancoPalabras(
this.categorias, {
Map<String, String>? pistasPorCategoria,
Map<String, String>? pistasPorPalabra,
}) : pistasPorCategoria = pistasPorCategoria ?? {},
pistasPorPalabra = pistasPorPalabra ?? {};
static final Map<String, BancoPalabras> _instancias = {};
@@ -37,19 +54,42 @@ class BancoPalabras {
final cats = data['categorias'] as Map<String, dynamic>;
final mapa = <String, List<String>>{};
final pistas = <String, String>{};
final pistasPalabra = <String, String>{};
for (final entrada in cats.entries) {
final valor = entrada.value;
final listaCruda = valor is Map<String, dynamic>
? valor['palabras'] as List
: valor as List;
if (valor is Map<String, dynamic>) {
mapa[entrada.key] = List<String>.from(valor['palabras'] as List);
final pista = valor['pista'];
if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista;
} else {
mapa[entrada.key] = List<String>.from(valor as List);
}
final palabras = <String>[];
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 = <String, List<EntradaPalabraTraducida>>{};
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();
}
+16 -1
View File
@@ -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<Jugador> 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<ResultadoVotacion> 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<ResultadoVotacion>? 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<String> get nombresImpostores =>
jugadores.where((j) => j.esImpostor).map((j) => j.nombre).toList();
List<Jugador> get jugadoresActivos =>
jugadores.where((j) => !j.eliminado).toList();
+37
View File
@@ -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<Usuario> 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');
+18 -3
View File
@@ -14,6 +14,11 @@ class SnapshotPartidaOnline {
final List<String> 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<String, dynamic>).containsKey('esImpostor'),
),
);
}
static Map<String, dynamic> _jugadorToJson(Jugador jugador) => {
static Map<String, dynamic> _jugadorToJson(
Jugador jugador,
bool revelarImpostores,
) => {
'id': jugador.id,
'nombre': jugador.nombre,
'esImpostor': jugador.esImpostor,
if (revelarImpostores) 'esImpostor': jugador.esImpostor,
'eliminado': jugador.eliminado,
};
+12
View File
@@ -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<String> 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<String>? 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<dynamic>? ?? const [])
.map((valor) => valor.toString())