fix: multidispositivo - Random seguro + gestor host + reacción clientes
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Has been cancelled
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled

- Random.secure() para selección de impostores (no predecible)
- Random.secure() también en desempate de votación
- Nueva PantallaGestorHost para coordinación multi-device
- Navegación: host va a gestor tras iniciar, no a pantalla de palabra
- PantallaPalabraCliente: cada jugador ve su palabra en su móvil
- PantallaDebateCliente: debate con timer y botón solicitar votación
- PantallaVotacionCliente: voto desde el móvil del cliente
- PantallaUnirse: listener que reacciona a partidaInicio y cambia de fase
- Protocolo: listo/voto/solicitoVotacion via Nearby hacia el host
- Nuevas cadenas l10n ES
This commit is contained in:
ShanaiaBot
2026-04-15 02:09:05 +02:00
parent 302cdf6f1a
commit eb2662f561
27 changed files with 2282 additions and 60 deletions

196
SPEC.md Normal file
View File

@@ -0,0 +1,196 @@
# Farolero — SDD: Multidispositivo completo + Impostor seguro
## 1. Explore
### Hallazgos
**Bug 1: El host funciona como juego en un solo móvil**
- En `pantalla_crear_partida.dart``_iniciarPartidaMulti()`, al pulsar "Iniciar":
1. Crea la partida localmente ✅
2. Envía palabras a clientes via Nearby ✅
3. Navega a `PantallaVerPalabra`
- `PantallaVerPalabra` está diseñada para juego en un solo móvil (muestra TODOS los jugadores en lista)
- El host no necesita ver palabras — es el gestor, no un jugador
**Bug 2: Clientes no se enteran de que el juego ha started**
- `ServicioNearby` procesa `partidaInicio` y actualiza `_palabraRecibida` / `_soyImpostor`
- Llama a `notifyListeners()`
- Pero `PantallaUnirse``_buildPantallaEspera` no escucha cambios de `ServicioNearby`
- Se queda infinamente en "Esperando al host..." aunque la partida ya haya started
**Bug 3: Random de impostores predecible**
- `EstadoJuego.crearPartida()` usa `Random()` con seed del sistema (determinista)
- `indices.shuffle(rng)` mezcla un array [0,1,2,...,n-1] — la mezcla depende del estado inicial
- Si un atacante conoce el número de jugadores y el momento de creación, puede reconstruir la seed
- Solución: usar `Random.secure()` (CSPRNG) o al menos `Random.fromSecureRandom()`
### Restricciones
- Nearby Connections tiene límite de payloads (4KB por mensaje)
- Google Nearby Connections requiere Bluetooth + Location permissions en Android/iOS
- La palabra secreta NO debe enviarse en texto plano a la red ( Nearby es inseguro en teoría — cualquier dispositivo puede sniffear)
- Compatibilidad hacia atrás: modo un solo móvil no debe romperse
---
## 2. Propose
### Cambio 1: Pantalla Gestor Host (multidispositivo)
El host en modo multi-device necesita una pantalla que:
- Muestre la lista de jugadores conectados y sus estados (listo para debate/votación)
- Permita avanzar de fase (debate → votación → resultado)
- NO muestre palabras a nadie
- Gestione la coordinación de la partida completa
**Pantallas afectadas/creadas:**
- Nueva: `pantalla_gestor_host.dart`
- Modificar: `pantalla_crear_partida.dart` (onIniciar → navegar a gestor)
- Modificar: `pantalla_lobby_host.dart` (el QR se queda ahí)
### Cambio 2: Reacción de clientes al inicio de partida
Los clientes deben:
- Escuchar cambios en `ServicioNearby` (registrar listener)
- Cuando llega `partidaInicio`, navegar automáticamente a la pantalla de ver su palabra
- Crear pantalla dedicada para cliente: `pantalla_palabra_cliente.dart`
**Pantallas afectadas/creadas:**
- Nueva: `pantalla_palabra_cliente.dart`
- Nueva: `pantalla_debate_cliente.dart`
- Nueva: `pantalla_votacion_cliente.dart`
- Modificar: `pantalla_unirse.dart` (registrar listener y reaccionar)
### Cambio 3: Impostor con Random seguro
Reemplazar `Random()` por `Random.secure()` en `EstadoJuego.crearPartida()`.
**Archivos afectados:**
- `lib/estado/estado_juego.dart`
---
## 3. Spec
### 3.1 PantallaGestorHost
**Props:**
```dart
class PantallaGestorHost extends StatelessWidget {
final VoidCallback onPartidaFin;
}
```
**Responsabilidades:**
- Escuchar cambios de `EstadoJuego` (fase actual)
- Escuchar cambios de `ServicioNearby` (desconexiones)
- Botones para avanzar de fase
- Mostrar estado de cada jugador (en debate, votando, eliminado)
- NO mostrar palabras a nadie
**Estados:**
- `debate`: muestra timer si hay tiempo configurado, botón "Iniciar votación"
- `votacion`: muestra quién ha votado, botón "Ver resultados" cuando todos voted
- `resultado`: muestra quién fue eliminado y si era impostor
- `adivinanza`: el impostor剩余 intenta adivinar
- `finPartida`: muestra ganador
### 3.2 PantallaPalabraCliente
**Props:**
```dart
class PantallaPalabraCliente extends StatelessWidget {
final String palabra;
final bool esImpostor;
final String? pistaCategoria;
final VoidCallback onVisto;
}
```
**Responsabilidades:**
- Mostrar la palabra solo al jugador correspondiente
- Si es impostor: mostrar pista de categoría (si está activa)
- Mantener pulsado para revelar (mismo UX que `_PantallaRevelarPalabra`)
- Al pulsar "Ya la he visto" → enviar `listo` al host via Nearby
- Esperar al host que inicie el debate
### 3.3 Sincronización de fases
**Protocolo de mensajes Via Nearby:**
```
HOST → CLIENTE:
partidaInicio → { palabra, esImpostor, categoria, numJugadores }
fase → { fase: "debate"|"votacion"|"resultado"|"adivinanza"|"fin" }
votacionResultado → { eliminadoId, eliminadoNombre, eraImpostor, votos }
impostorAdivina → { acierto: bool }
partidaFin → { ganador: "jugadores"|"impostores" }
CLIENTE → HOST:
listo → {} (el cliente ha visto su palabra y está listo)
voto → { votoporId }
```
### 3.4 Random seguro para impostores
```dart
// ANTES (predecible):
final rng = Random();
final indices = List.generate(jugadores.length, (i) => i);
indices.shuffle(rng);
// DESPUÉS (seguro):
final rng = Random.secure();
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
```
---
## 4. Tasks
### Epic 1: Impostor seguro
- [ ] **Task 1.1**: Cambiar `Random()` por `Random.secure()` en `EstadoJuego.crearPartida()`
### Epic 2: PantallaGestorHost
- [ ] **Task 2.1**: Crear `pantalla_gestor_host.dart` con estructura de fases
- [ ] **Task 2.2**: Implementar transición `lobby → gestor` en `pantalla_crear_partida.dart`
- [ ] **Task 2.3**: Implementar envío de cambio de fase a clientes
- [ ] **Task 2.4**: Gestionar desconexiones de jugadores durante la partida
### Epic 3: Cliente recibe inicio de partida
- [ ] **Task 3.1**: Registrar listener en `PantallaUnirse` para `partidaInicio`
- [ ] **Task 3.2**: Crear `pantalla_palabra_cliente.dart` (similar a `_PantallaRevelarPalabra` pero StatelessWidget)
- [ ] **Task 3.3**: Enviar mensaje `listo` al host cuando el cliente confirma que ha visto la palabra
### Epic 4: Cliente en fases de juego
- [ ] **Task 4.1**: Crear `pantalla_debate_cliente.dart` (muestra countdown, botón para solicitar votación)
- [ ] **Task 4.2**: Crear `pantalla_votacion_cliente.dart` (votar desde el móvil del cliente)
- [ ] **Task 4.3**: Cliente reacciona a `fase: votacion` y muestra pantalla de voto
- [ ] **Task 4.4**: Cliente reacciona a `fase: resultado` y muestra quién fue eliminado
- [ ] **Task 4.5**: Si el cliente es impostor剩余, mostrar pantalla de adivinanza
### Epic 5: Testing
- [ ] **Task 5.1**: Probar flujo completo multi-device (3+ jugadores)
- [ ] **Task 5.2**: Probar desconexión de un jugador durante la partida
- [ ] **Task 5.3**: Probar que en modo un solo móvil no se rompe nada
---
## 5. Apply
*(Ejecución por agente OpenCode)*
---
## 6. Verify
- [ ] Flujo multi-device: host inicia → clientes ven sus palabras → debate → votan → resultado
- [ ] Modo un solo móvil sigue funcionando exactamente igual que antes
- [ ] Impostores se seleccionan con Random.secure() — no reproducibles
- [ ] Sin regresiones en localized strings