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.
311 lines
9.1 KiB
Dart
311 lines
9.1 KiB
Dart
import 'dart:math';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../modelos/jugador.dart';
|
|
import '../modelos/partida.dart';
|
|
import '../modelos/palabra.dart';
|
|
import '../modelos/sala_multijugador.dart';
|
|
import '../servicios/servicio_notas.dart';
|
|
|
|
/// Estado global del juego gestionado con Provider
|
|
class EstadoJuego extends ChangeNotifier {
|
|
BancoPalabras? _banco;
|
|
Partida? _partida;
|
|
final Map<String, String> _votos = {}; // votanteId -> votadoId
|
|
bool _cargando = false;
|
|
|
|
/// Jugador local del host en modo multi-dispositivo
|
|
Jugador? _hostLocal;
|
|
|
|
BancoPalabras? get banco => _banco;
|
|
Partida? get partida => _partida;
|
|
Map<String, String> get votos => Map.unmodifiable(_votos);
|
|
bool get cargando => _cargando;
|
|
|
|
/// Jugador local del host (para modo multi-dispositivo)
|
|
Jugador? get hostLocal => _hostLocal;
|
|
|
|
Future<void> cargarBanco() async {
|
|
_cargando = true;
|
|
notifyListeners();
|
|
_banco = await BancoPalabras.cargar();
|
|
_cargando = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Establece el jugador local del host para modo multi-dispositivo
|
|
void setHostJugador(String nombre) {
|
|
_hostLocal = Jugador(
|
|
id: 'host-local',
|
|
nombre: nombre,
|
|
endpointId: null, // El host local no tiene endpointId
|
|
);
|
|
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<Jugador> jugadores,
|
|
ConfigPartida config,
|
|
String palabra,
|
|
) {
|
|
final rng = Random.secure();
|
|
final numImpostores = config.numImpostores.clamp(
|
|
1,
|
|
maxImpostoresPara(jugadores.length),
|
|
);
|
|
final impostoresElegidos = <int>{};
|
|
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,
|
|
required List<String> nombresJugadores,
|
|
}) {
|
|
if (_banco == null) return;
|
|
if (nombresJugadores.length < 3) return;
|
|
|
|
// Seleccionar palabra
|
|
final palabra = _banco!.palabraAleatoria(config.categoria);
|
|
final categoriaReal =
|
|
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
|
|
|
|
// Crear jugadores
|
|
final jugadores = nombresJugadores.asMap().entries.map((e) {
|
|
return Jugador(id: 'j${e.key}', nombre: e.value);
|
|
}).toList();
|
|
|
|
_repartirRoles(jugadores, config, palabra);
|
|
|
|
_partida = Partida(
|
|
config: config,
|
|
jugadores: jugadores,
|
|
palabraSecreta: palabra,
|
|
categoriaReal: categoriaReal,
|
|
pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal),
|
|
);
|
|
|
|
_votos.clear();
|
|
ServicioNotas.limpiarNotas();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Crea una partida multi-dispositivo usando los usuarios seleccionados de la
|
|
/// sala como jugadores reales. La identidad de jugador se conserva por id y
|
|
/// cada jugador queda asociado al endpoint del cliente que lo controla.
|
|
void crearPartidaDesdeSala({
|
|
required ConfigPartida config,
|
|
required EstadoSalaMultijugador sala,
|
|
}) {
|
|
if (_banco == null) return;
|
|
final usuariosSeleccionados = sala.usuariosSeleccionados;
|
|
if (usuariosSeleccionados.length < 3) return;
|
|
|
|
final palabra = _banco!.palabraAleatoria(config.categoria);
|
|
final categoriaReal =
|
|
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
|
|
|
|
final jugadores = usuariosSeleccionados.map((usuario) {
|
|
final clienteId = usuario.clienteIdSeleccionado;
|
|
final endpointId = clienteId == null
|
|
? null
|
|
: sala.clientes[clienteId]?.endpointId;
|
|
return Jugador(
|
|
id: usuario.id,
|
|
nombre: usuario.nombre,
|
|
endpointId: endpointId,
|
|
);
|
|
}).toList();
|
|
|
|
_repartirRoles(jugadores, config, palabra);
|
|
|
|
_partida = Partida(
|
|
config: config,
|
|
jugadores: jugadores,
|
|
palabraSecreta: palabra,
|
|
categoriaReal: categoriaReal,
|
|
pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal),
|
|
);
|
|
|
|
_votos.clear();
|
|
ServicioNotas.limpiarNotas();
|
|
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<String>? 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;
|
|
_partida!.fase = FaseJuego.debate;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Avanza a la fase de votación
|
|
void iniciarVotacion() {
|
|
if (_partida == null) return;
|
|
_partida!.fase = FaseJuego.votacion;
|
|
_votos.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Registra un voto (modo un solo móvil)
|
|
void registrarVoto(String votanteId, String votadoId) {
|
|
_votos[votanteId] = votadoId;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Elimina un voto
|
|
void eliminarVoto(String votanteId) {
|
|
_votos.remove(votanteId);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Comprueba si todos los jugadores activos (no eliminados) han votado
|
|
bool todosHanVotado() {
|
|
if (_partida == null) return false;
|
|
final activos = _partida!.jugadoresActivos;
|
|
return activos.every((j) => _votos.containsKey(j.id));
|
|
}
|
|
|
|
/// Procesa los votos y determina el eliminado
|
|
ResultadoVotacion? procesarVotacion() {
|
|
if (_partida == null) return null;
|
|
|
|
// Contar votos
|
|
final conteo = <String, int>{};
|
|
for (final votado in _votos.values) {
|
|
conteo[votado] = (conteo[votado] ?? 0) + 1;
|
|
}
|
|
|
|
if (conteo.isEmpty) return null;
|
|
|
|
// Encontrar máximo
|
|
final maxVotos = conteo.values.reduce(max);
|
|
final masVotados = conteo.entries
|
|
.where((e) => e.value == maxVotos)
|
|
.toList();
|
|
|
|
// En caso de empate, elegir aleatoriamente (usar Random.secure para consistencia)
|
|
final rng = Random.secure();
|
|
final eliminadoId = masVotados[rng.nextInt(masVotados.length)].key;
|
|
|
|
final eliminado = _partida!.jugadores.firstWhere(
|
|
(j) => j.id == eliminadoId,
|
|
);
|
|
eliminado.eliminado = true;
|
|
|
|
final resultado = ResultadoVotacion(
|
|
eliminadoId: eliminadoId,
|
|
eliminadoNombre: eliminado.nombre,
|
|
eraImpostor: eliminado.esImpostor,
|
|
votos: Map.from(_votos),
|
|
);
|
|
|
|
_partida!.historialVotaciones.add(resultado);
|
|
_partida!.fase = FaseJuego.resultado;
|
|
notifyListeners();
|
|
|
|
return resultado;
|
|
}
|
|
|
|
/// Comprueba si la partida ha terminado y actualiza el estado
|
|
bool comprobarFinPartida() {
|
|
if (_partida == null) return false;
|
|
|
|
final impostoresVivos = _partida!.impostoresActivos.length;
|
|
final jugadoresVivos = _partida!.jugadoresNormalesActivos.length;
|
|
|
|
// Los jugadores ganan si no quedan impostores
|
|
if (impostoresVivos == 0) {
|
|
_partida!.ganador = 'jugadores';
|
|
_partida!.fase = FaseJuego.finPartida;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
// Los impostores ganan si son >= que los jugadores normales
|
|
if (impostoresVivos >= jugadoresVivos) {
|
|
_partida!.ganador = 'impostores';
|
|
_partida!.fase = FaseJuego.finPartida;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Avanza a la fase de adivinanza del impostor
|
|
void iniciarAdivinanza() {
|
|
if (_partida == null) return;
|
|
_partida!.fase = FaseJuego.adivinanza;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// El impostor intenta adivinar la palabra
|
|
bool intentarAdivinar(String intento) {
|
|
if (_partida == null) return false;
|
|
final acierto =
|
|
intento.trim().toLowerCase() ==
|
|
_partida!.palabraSecreta.trim().toLowerCase();
|
|
if (acierto) {
|
|
_partida!.ganador = 'impostores';
|
|
_partida!.fase = FaseJuego.finPartida;
|
|
notifyListeners();
|
|
}
|
|
return acierto;
|
|
}
|
|
|
|
/// Inicia la siguiente ronda
|
|
void siguienteRonda() {
|
|
if (_partida == null) return;
|
|
_partida!.rondaActual++;
|
|
_partida!.fase = FaseJuego.debate;
|
|
_votos.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Revancha: mismos jugadores, nueva palabra
|
|
void revancha() {
|
|
if (_partida == null || _banco == null) return;
|
|
final nombres = _partida!.jugadores.map((j) => j.nombre).toList();
|
|
crearPartida(config: _partida!.config, nombresJugadores: nombres);
|
|
}
|
|
|
|
/// Limpia la partida actual
|
|
void limpiar() {
|
|
_partida = null;
|
|
_votos.clear();
|
|
notifyListeners();
|
|
}
|
|
}
|