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:
@@ -0,0 +1,156 @@
|
||||
import 'package:farolero/modelos/sala_multijugador.dart';
|
||||
import 'package:farolero/modelos/usuario.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
EstadoSalaMultijugador _salaConCliente() {
|
||||
final sala = EstadoSalaMultijugador.crear(
|
||||
roomId: 'r1',
|
||||
nombreSala: 'Sala',
|
||||
hostClientId: 'host',
|
||||
hostNombre: 'Ana',
|
||||
);
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'dev-beto',
|
||||
endpointId: 'ep-1',
|
||||
nombre: 'Beto',
|
||||
),
|
||||
);
|
||||
sala.usuarios['u-beto'] = Usuario(
|
||||
id: 'u-beto',
|
||||
nombre: 'Beto',
|
||||
creadoPorClienteId: 'dev-beto',
|
||||
clienteIdSeleccionado: 'dev-beto',
|
||||
);
|
||||
return sala;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Reconexión de un cliente', () {
|
||||
test('el mismo clientId con endpoint nuevo no crea un cliente duplicado', () {
|
||||
final sala = _salaConCliente();
|
||||
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'dev-beto',
|
||||
endpointId: 'ep-2',
|
||||
nombre: 'Beto',
|
||||
),
|
||||
);
|
||||
|
||||
expect(sala.clientes.length, 2, reason: 'host + Beto, sin duplicados');
|
||||
expect(sala.clientes['dev-beto']!.endpointId, 'ep-2');
|
||||
expect(sala.clientes['dev-beto']!.conectado, isTrue);
|
||||
});
|
||||
|
||||
test('esReconexion distingue a quien vuelve de quien llega nuevo', () {
|
||||
final sala = _salaConCliente();
|
||||
|
||||
expect(sala.esReconexion('dev-beto'), isTrue);
|
||||
expect(sala.esReconexion('dev-cris'), isFalse);
|
||||
});
|
||||
|
||||
test('en partida, desconectarse no libera a sus jugadores', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.fase = FaseSalaMultijugador.enPartida;
|
||||
|
||||
sala.desconectarCliente('dev-beto');
|
||||
|
||||
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto');
|
||||
expect(sala.clientes['dev-beto']!.conectado, isFalse);
|
||||
});
|
||||
|
||||
test('en lobby, desconectarse sí libera a sus jugadores', () {
|
||||
final sala = _salaConCliente();
|
||||
|
||||
sala.desconectarCliente('dev-beto');
|
||||
|
||||
expect(sala.usuarios['u-beto']!.estaDisponible, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Absorción por el host y devolución', () {
|
||||
test('el host absorbe y queda constancia de quién era el dueño', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.fase = FaseSalaMultijugador.enPartida;
|
||||
sala.desconectarCliente('dev-beto');
|
||||
|
||||
final reasignados = sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'dev-beto',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
|
||||
expect(reasignados, 1);
|
||||
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'host');
|
||||
expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
|
||||
expect(sala.usuariosAbsorbidosDe('dev-beto').single.id, 'u-beto');
|
||||
});
|
||||
|
||||
test('al volver el móvil recupera a sus jugadores', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.fase = FaseSalaMultijugador.enPartida;
|
||||
sala.desconectarCliente('dev-beto');
|
||||
sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'dev-beto',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'dev-beto',
|
||||
endpointId: 'ep-2',
|
||||
nombre: 'Beto',
|
||||
),
|
||||
);
|
||||
final devueltos = sala.devolverUsuariosAbsorbidos('dev-beto');
|
||||
|
||||
expect(devueltos, 1);
|
||||
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto');
|
||||
expect(sala.usuarios['u-beto']!.absorbidoDe, isNull);
|
||||
expect(sala.usuariosPorCliente('host'), isEmpty);
|
||||
});
|
||||
|
||||
test('una doble absorción no pierde al dueño original', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.fase = FaseSalaMultijugador.enPartida;
|
||||
sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'dev-beto',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
// El host vuelve a pasar por el mismo camino: no debe reescribir el
|
||||
// origen a 'host' y dejar al usuario huérfano.
|
||||
sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'host',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
|
||||
expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
|
||||
});
|
||||
|
||||
test('no se devuelve nada a un cliente que no está en la sala', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'dev-beto',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
|
||||
expect(sala.devolverUsuariosAbsorbidos('dev-fantasma'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('Serialización', () {
|
||||
test('absorbidoDe sobrevive al viaje JSON', () {
|
||||
final sala = _salaConCliente();
|
||||
sala.fase = FaseSalaMultijugador.enPartida;
|
||||
sala.reasignarUsuariosDeCliente(
|
||||
clientIdOrigen: 'dev-beto',
|
||||
clientIdDestino: 'host',
|
||||
);
|
||||
|
||||
final reparsed = EstadoSalaMultijugador.fromJson(sala.toJson());
|
||||
|
||||
expect(reparsed.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
|
||||
expect(reparsed.clientes['dev-beto']!.endpointId, 'ep-1');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:farolero/estado/estado_juego.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/modelos/jugador.dart';
|
||||
import 'package:farolero/modelos/palabra.dart';
|
||||
import 'package:farolero/modelos/partida.dart';
|
||||
import 'package:farolero/modelos/snapshot_partida_online.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Partida _partidaConImpostores() => Partida(
|
||||
config: const ConfigPartida(numImpostores: 2),
|
||||
jugadores: [
|
||||
Jugador(id: 'j1', nombre: 'Ana', esImpostor: true),
|
||||
Jugador(id: 'j2', nombre: 'Beto'),
|
||||
Jugador(id: 'j3', nombre: 'Cris', esImpostor: true),
|
||||
Jugador(id: 'j4', nombre: 'Dani'),
|
||||
],
|
||||
palabraSecreta: 'Camión',
|
||||
categoriaReal: 'objetos',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('SnapshotPartidaOnline no filtra los roles', () {
|
||||
test('durante la partida no envía esImpostor de nadie', () {
|
||||
final json = SnapshotPartidaOnline.desdePartida(
|
||||
_partidaConImpostores(),
|
||||
fase: 'debate',
|
||||
).toJson();
|
||||
|
||||
final jugadores = json['jugadoresTodos'] as List<dynamic>;
|
||||
for (final jugador in jugadores) {
|
||||
expect(
|
||||
(jugador as Map<String, dynamic>).containsKey('esImpostor'),
|
||||
isFalse,
|
||||
reason: 'el rol no puede viajar en el snapshot de fase',
|
||||
);
|
||||
}
|
||||
expect(json.containsKey('impostores'), isFalse);
|
||||
expect(json.containsKey('palabraSecreta'), isFalse);
|
||||
});
|
||||
|
||||
test('al final de la partida sí revela roles y palabra', () {
|
||||
final json = SnapshotPartidaOnline.desdePartida(
|
||||
_partidaConImpostores(),
|
||||
fase: 'finPartida',
|
||||
revelarImpostores: true,
|
||||
revelarPalabra: true,
|
||||
).toJson();
|
||||
|
||||
final jugadores = (json['jugadoresTodos'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
expect(jugadores.every((j) => j.containsKey('esImpostor')), isTrue);
|
||||
expect(json['impostores'], ['Ana', 'Cris']);
|
||||
expect(json['palabraSecreta'], 'Camión');
|
||||
});
|
||||
|
||||
test('el snapshot de fase se reparsea sin marcar impostores', () {
|
||||
final snapshot = SnapshotPartidaOnline.fromJson(
|
||||
SnapshotPartidaOnline.desdePartida(
|
||||
_partidaConImpostores(),
|
||||
fase: 'votacion',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
expect(snapshot.jugadores.any((j) => j.esImpostor), isFalse);
|
||||
expect(snapshot.revelarImpostores, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Los impostores se conocen entre ellos', () {
|
||||
final asignaciones = const [
|
||||
AsignacionJugador(
|
||||
jugadorId: 'j1',
|
||||
nombre: 'Ana',
|
||||
clientId: 'host',
|
||||
endpointId: null,
|
||||
),
|
||||
AsignacionJugador(
|
||||
jugadorId: 'j2',
|
||||
nombre: 'Beto',
|
||||
clientId: 'c2',
|
||||
endpointId: 'e2',
|
||||
),
|
||||
AsignacionJugador(
|
||||
jugadorId: 'j3',
|
||||
nombre: 'Cris',
|
||||
clientId: 'c3',
|
||||
endpointId: 'e3',
|
||||
),
|
||||
];
|
||||
const impostores = {'j1': true, 'j2': false, 'j3': true};
|
||||
|
||||
test('cada impostor recibe los nombres del resto, nunca el suyo', () {
|
||||
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: 'Camión',
|
||||
categoria: 'objetos',
|
||||
impostoresPorJugadorId: impostores,
|
||||
impostoresSeConocen: true,
|
||||
);
|
||||
|
||||
expect(payloads['host']!.jugadores.single.companerosImpostores, ['Cris']);
|
||||
expect(payloads['c3']!.jugadores.single.companerosImpostores, ['Ana']);
|
||||
});
|
||||
|
||||
test('un jugador normal nunca recibe la lista', () {
|
||||
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: 'Camión',
|
||||
categoria: 'objetos',
|
||||
impostoresPorJugadorId: impostores,
|
||||
impostoresSeConocen: true,
|
||||
);
|
||||
|
||||
expect(payloads['c2']!.jugadores.single.companerosImpostores, isNull);
|
||||
});
|
||||
|
||||
test('con la opción desactivada nadie recibe la lista', () {
|
||||
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: 'Camión',
|
||||
categoria: 'objetos',
|
||||
impostoresPorJugadorId: impostores,
|
||||
impostoresSeConocen: false,
|
||||
);
|
||||
|
||||
for (final payload in payloads.values) {
|
||||
for (final jugador in payload.jugadores) {
|
||||
expect(jugador.companerosImpostores, isNull);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('la lista vacía sobrevive al viaje JSON y no se vuelve null', () {
|
||||
const jugador = JugadorInicioPartida(
|
||||
jugadorId: 'j1',
|
||||
nombre: 'Ana',
|
||||
esImpostor: true,
|
||||
palabra: null,
|
||||
companerosImpostores: [],
|
||||
);
|
||||
|
||||
final reparsed = JugadorInicioPartida.fromJson(jugador.toJson());
|
||||
|
||||
expect(reparsed.companerosImpostores, isEmpty);
|
||||
expect(reparsed.companerosImpostores, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Pistas del banco de palabras', () {
|
||||
test('prioriza la pista de la palabra sobre la de la categoría', () {
|
||||
final banco = BancoPalabras(
|
||||
{
|
||||
'objetos': ['Camión', 'Silla'],
|
||||
},
|
||||
pistasPorCategoria: {'objetos': 'Objetos'},
|
||||
pistasPorPalabra: {'Camión': 'Se conduce y transporta carga'},
|
||||
);
|
||||
|
||||
expect(banco.pistaDePalabra('Camión'), 'Se conduce y transporta carga');
|
||||
expect(banco.pistaDePalabra('Silla'), 'Objetos');
|
||||
});
|
||||
|
||||
test('sin pista de palabra ni de categoría devuelve null', () {
|
||||
final banco = BancoPalabras({
|
||||
'objetos': ['Silla'],
|
||||
});
|
||||
|
||||
expect(banco.pistaDePalabra('Silla'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Tope de impostores', () {
|
||||
test('es el mismo en ambos modos de juego', () {
|
||||
expect(EstadoJuego.maxImpostoresPara(3), 1);
|
||||
expect(EstadoJuego.maxImpostoresPara(5), 1);
|
||||
expect(EstadoJuego.maxImpostoresPara(6), 2);
|
||||
expect(EstadoJuego.maxImpostoresPara(12), 4);
|
||||
expect(EstadoJuego.maxImpostoresPara(20), 4);
|
||||
});
|
||||
});
|
||||
|
||||
group('Partida', () {
|
||||
test('la pista cae a la categoría cuando no se aporta una específica', () {
|
||||
final partida = Partida(
|
||||
config: const ConfigPartida(),
|
||||
jugadores: [Jugador(id: 'j1', nombre: 'Ana')],
|
||||
palabraSecreta: 'Camión',
|
||||
categoriaReal: 'objetos',
|
||||
);
|
||||
|
||||
expect(partida.pistaImpostor, 'objetos');
|
||||
});
|
||||
|
||||
test('nombresImpostores lista solo a los impostores', () {
|
||||
expect(_partidaConImpostores().nombresImpostores, ['Ana', 'Cris']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user