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.
378 lines
12 KiB
Dart
378 lines
12 KiB
Dart
import 'usuario.dart';
|
|
|
|
enum FaseSalaMultijugador { lobby, enPartida, finalizada }
|
|
|
|
class ResultadoOperacionSala {
|
|
final bool exitoso;
|
|
final String? codigo;
|
|
final String? mensaje;
|
|
|
|
const ResultadoOperacionSala._({
|
|
required this.exitoso,
|
|
this.codigo,
|
|
this.mensaje,
|
|
});
|
|
|
|
const ResultadoOperacionSala.ok([String? mensaje])
|
|
: this._(exitoso: true, mensaje: mensaje);
|
|
|
|
const ResultadoOperacionSala.error(String codigo, [String? mensaje])
|
|
: this._(exitoso: false, codigo: codigo, mensaje: mensaje);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'exitoso': exitoso,
|
|
if (codigo != null) 'codigo': codigo,
|
|
if (mensaje != null) 'mensaje': mensaje,
|
|
};
|
|
}
|
|
|
|
class ClienteSala {
|
|
final String clientId;
|
|
final String? endpointId;
|
|
final String nombre;
|
|
final bool esHost;
|
|
final bool conectado;
|
|
final int ultimaActividadMs;
|
|
|
|
const ClienteSala({
|
|
required this.clientId,
|
|
this.endpointId,
|
|
required this.nombre,
|
|
this.esHost = false,
|
|
this.conectado = true,
|
|
this.ultimaActividadMs = 0,
|
|
});
|
|
|
|
ClienteSala copiar({
|
|
String? clientId,
|
|
String? endpointId,
|
|
String? nombre,
|
|
bool? esHost,
|
|
bool? conectado,
|
|
int? ultimaActividadMs,
|
|
}) {
|
|
return ClienteSala(
|
|
clientId: clientId ?? this.clientId,
|
|
endpointId: endpointId ?? this.endpointId,
|
|
nombre: nombre ?? this.nombre,
|
|
esHost: esHost ?? this.esHost,
|
|
conectado: conectado ?? this.conectado,
|
|
ultimaActividadMs: ultimaActividadMs ?? this.ultimaActividadMs,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'clientId': clientId,
|
|
if (endpointId != null) 'endpointId': endpointId,
|
|
'nombre': nombre,
|
|
'esHost': esHost,
|
|
'conectado': conectado,
|
|
'ultimaActividadMs': ultimaActividadMs,
|
|
};
|
|
|
|
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
|
|
clientId: json['clientId'] as String,
|
|
endpointId: json['endpointId'] as String?,
|
|
nombre: json['nombre'] as String,
|
|
esHost: json['esHost'] as bool? ?? false,
|
|
conectado: json['conectado'] as bool? ?? true,
|
|
ultimaActividadMs: (json['ultimaActividadMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
}
|
|
|
|
class EstadoSalaMultijugador {
|
|
final String roomId;
|
|
final String nombreSala;
|
|
final String hostClientId;
|
|
FaseSalaMultijugador fase;
|
|
final Map<String, ClienteSala> clientes;
|
|
final Map<String, Usuario> usuarios;
|
|
|
|
EstadoSalaMultijugador({
|
|
required this.roomId,
|
|
required this.nombreSala,
|
|
required this.hostClientId,
|
|
this.fase = FaseSalaMultijugador.lobby,
|
|
Map<String, ClienteSala>? clientes,
|
|
Map<String, Usuario>? usuarios,
|
|
}) : clientes = clientes ?? {},
|
|
usuarios = usuarios ?? {};
|
|
|
|
factory EstadoSalaMultijugador.crear({
|
|
required String roomId,
|
|
required String nombreSala,
|
|
required String hostClientId,
|
|
required String hostNombre,
|
|
}) {
|
|
final sala = EstadoSalaMultijugador(
|
|
roomId: roomId,
|
|
nombreSala: nombreSala,
|
|
hostClientId: hostClientId,
|
|
);
|
|
sala.registrarCliente(
|
|
ClienteSala(
|
|
clientId: hostClientId,
|
|
nombre: hostNombre,
|
|
esHost: true,
|
|
),
|
|
);
|
|
return sala;
|
|
}
|
|
|
|
List<Usuario> get usuariosSeleccionados =>
|
|
usuarios.values.where((usuario) => usuario.estaSeleccionado).toList();
|
|
|
|
List<Usuario> get usuariosDisponibles =>
|
|
usuarios.values.where((usuario) => usuario.estaDisponible).toList();
|
|
|
|
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
|
|
|
|
List<ClienteSala> get clientesDesconectados => clientes.values
|
|
.where((cliente) => !cliente.esHost && !cliente.conectado)
|
|
.toList();
|
|
|
|
List<Usuario> get usuariosDeClientesDesconectados {
|
|
final desconectados = clientesDesconectados
|
|
.map((cliente) => cliente.clientId)
|
|
.toSet();
|
|
return usuarios.values
|
|
.where((usuario) => desconectados.contains(usuario.clienteIdSeleccionado))
|
|
.toList();
|
|
}
|
|
|
|
List<Usuario> usuariosPorCliente(String clientId) {
|
|
return usuarios.values
|
|
.where((usuario) => usuario.clienteIdSeleccionado == clientId)
|
|
.toList();
|
|
}
|
|
|
|
ClienteSala? clientePorEndpoint(String endpointId) {
|
|
for (final cliente in clientes.values) {
|
|
if (cliente.endpointId == endpointId) return cliente;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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');
|
|
}
|
|
if (usuarios.containsKey(usuario.id)) {
|
|
return const ResultadoOperacionSala.error('usuario_duplicado');
|
|
}
|
|
final nombreNormalizado = usuario.nombre.trim().toLowerCase();
|
|
final nombreExiste = usuarios.values.any(
|
|
(u) => u.nombre.trim().toLowerCase() == nombreNormalizado,
|
|
);
|
|
if (nombreExiste) {
|
|
return const ResultadoOperacionSala.error('nombre_duplicado');
|
|
}
|
|
usuarios[usuario.id] = usuario;
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
ResultadoOperacionSala seleccionarUsuario({
|
|
required String usuarioId,
|
|
required String clienteId,
|
|
}) {
|
|
if (fase != FaseSalaMultijugador.lobby) {
|
|
return const ResultadoOperacionSala.error('sala_cerrada');
|
|
}
|
|
final cliente = clientes[clienteId];
|
|
if (cliente == null || !cliente.conectado) {
|
|
return const ResultadoOperacionSala.error('cliente_no_disponible');
|
|
}
|
|
final usuario = usuarios[usuarioId];
|
|
if (usuario == null) {
|
|
return const ResultadoOperacionSala.error('usuario_no_existe');
|
|
}
|
|
if (usuario.clienteIdSeleccionado == clienteId) {
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
if (usuario.clienteIdSeleccionado != null) {
|
|
return const ResultadoOperacionSala.error('usuario_ya_seleccionado');
|
|
}
|
|
usuarios[usuarioId] = usuario.copiar(clienteIdSeleccionado: clienteId);
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
ResultadoOperacionSala liberarUsuario({
|
|
required String usuarioId,
|
|
required String solicitanteClientId,
|
|
}) {
|
|
if (fase != FaseSalaMultijugador.lobby) {
|
|
return const ResultadoOperacionSala.error('sala_cerrada');
|
|
}
|
|
final usuario = usuarios[usuarioId];
|
|
if (usuario == null) {
|
|
return const ResultadoOperacionSala.error('usuario_no_existe');
|
|
}
|
|
final solicitante = clientes[solicitanteClientId];
|
|
final puedeLiberar =
|
|
usuario.clienteIdSeleccionado == solicitanteClientId ||
|
|
(solicitante?.esHost ?? false);
|
|
if (!puedeLiberar) {
|
|
return const ResultadoOperacionSala.error('usuario_de_otro_cliente');
|
|
}
|
|
usuarios[usuarioId] = usuario.copiar(liberarSeleccion: true);
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
ResultadoOperacionSala eliminarUsuario({
|
|
required String usuarioId,
|
|
required String solicitanteClientId,
|
|
}) {
|
|
final solicitante = clientes[solicitanteClientId];
|
|
if (!(solicitante?.esHost ?? false)) {
|
|
return const ResultadoOperacionSala.error('solo_host');
|
|
}
|
|
final usuario = usuarios[usuarioId];
|
|
if (usuario == null) {
|
|
return const ResultadoOperacionSala.error('usuario_no_existe');
|
|
}
|
|
if (usuario.estaSeleccionado) {
|
|
return const ResultadoOperacionSala.error('usuario_seleccionado');
|
|
}
|
|
usuarios.remove(usuarioId);
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
void desconectarCliente(String clientId) {
|
|
final cliente = clientes[clientId];
|
|
if (cliente == null) return;
|
|
clientes[clientId] = cliente.copiar(conectado: false);
|
|
if (fase != FaseSalaMultijugador.lobby) return;
|
|
for (final entry in usuarios.entries.toList()) {
|
|
if (entry.value.clienteIdSeleccionado == clientId) {
|
|
usuarios[entry.key] = entry.value.copiar(liberarSeleccion: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
// Se recuerda de quién eran para poder devolvérselos si vuelve.
|
|
absorbidoDe: entry.value.absorbidoDe ?? clientIdOrigen,
|
|
);
|
|
reasignados++;
|
|
}
|
|
}
|
|
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');
|
|
}
|
|
if (cantidadUsuariosSeleccionados < 3) {
|
|
return const ResultadoOperacionSala.error('faltan_jugadores');
|
|
}
|
|
if (usuariosPorCliente(hostClientId).isEmpty) {
|
|
return const ResultadoOperacionSala.error('host_sin_usuario');
|
|
}
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
ResultadoOperacionSala iniciarPartida() {
|
|
final validacion = validarInicio();
|
|
if (!validacion.exitoso) return validacion;
|
|
fase = FaseSalaMultijugador.enPartida;
|
|
return const ResultadoOperacionSala.ok();
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'roomId': roomId,
|
|
'nombreSala': nombreSala,
|
|
'hostClientId': hostClientId,
|
|
'fase': fase.name,
|
|
'clientes': clientes.values.map((cliente) => cliente.toJson()).toList(),
|
|
'usuarios': usuarios.values.map((usuario) => usuario.toJson()).toList(),
|
|
};
|
|
|
|
factory EstadoSalaMultijugador.fromJson(Map<String, dynamic> json) {
|
|
final clientes = <String, ClienteSala>{};
|
|
for (final clienteJson in json['clientes'] as List<dynamic>? ?? []) {
|
|
final cliente = ClienteSala.fromJson(
|
|
clienteJson as Map<String, dynamic>,
|
|
);
|
|
clientes[cliente.clientId] = cliente;
|
|
}
|
|
|
|
final usuarios = <String, Usuario>{};
|
|
for (final usuarioJson in json['usuarios'] as List<dynamic>? ?? []) {
|
|
final usuario = Usuario.fromJson(usuarioJson as Map<String, dynamic>);
|
|
usuarios[usuario.id] = usuario;
|
|
}
|
|
|
|
return EstadoSalaMultijugador(
|
|
roomId: json['roomId'] as String,
|
|
nombreSala: json['nombreSala'] as String,
|
|
hostClientId: json['hostClientId'] as String,
|
|
fase: FaseSalaMultijugador.values.firstWhere(
|
|
(fase) => fase.name == json['fase'],
|
|
orElse: () => FaseSalaMultijugador.lobby,
|
|
),
|
|
clientes: clientes,
|
|
usuarios: usuarios,
|
|
);
|
|
}
|
|
}
|