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.
203 lines
6.6 KiB
Dart
203 lines
6.6 KiB
Dart
import 'dart:convert';
|
|
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;
|
|
|
|
/// 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 = {};
|
|
|
|
static Future<BancoPalabras> cargar({String idioma = 'es'}) async {
|
|
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
|
|
|
|
String jsonStr;
|
|
try {
|
|
jsonStr = await rootBundle.loadString(
|
|
'assets/words/palabras_$idioma.json',
|
|
);
|
|
} catch (_) {
|
|
try {
|
|
final archivoLegacy = idioma == 'es'
|
|
? 'assets/palabras.json'
|
|
: 'assets/palabras_$idioma.json';
|
|
jsonStr = await rootBundle.loadString(archivoLegacy);
|
|
} catch (_) {
|
|
if (idioma != 'es') return cargar(idioma: 'es');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
final data = json.decode(jsonStr) as Map<String, dynamic>;
|
|
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>) {
|
|
final pista = valor['pista'];
|
|
if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista;
|
|
}
|
|
|
|
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,
|
|
pistasPorPalabra: pistasPalabra,
|
|
);
|
|
return _instancias[idioma]!;
|
|
}
|
|
|
|
List<String> get nombresCategorias => categorias.keys.toList();
|
|
|
|
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null).
|
|
String palabraAleatoria(String? categoria) {
|
|
final rng = Random.secure();
|
|
if (categoria == null || categoria == 'todas') {
|
|
final todasPalabras = categorias.values.expand((l) => l).toList();
|
|
return todasPalabras[rng.nextInt(todasPalabras.length)];
|
|
}
|
|
final lista = categorias[categoria]!;
|
|
return lista[rng.nextInt(lista.length)];
|
|
}
|
|
|
|
/// Devuelve la categoría a la que pertenece una palabra.
|
|
String? categoriaDepalabra(String palabra) {
|
|
for (final entrada in categorias.entries) {
|
|
if (entrada.value.contains(palabra)) return entrada.key;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 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) {
|
|
final nombres = {
|
|
'todas': l10n.categoryAll,
|
|
'animales': l10n.categoryAnimals,
|
|
'comida': l10n.categoryFood,
|
|
'paises': l10n.categoryCountries,
|
|
'deportes': l10n.categorySports,
|
|
'profesiones': l10n.categoryProfessions,
|
|
'objetos': l10n.categoryObjects,
|
|
'lugares': l10n.categoryPlaces,
|
|
'peliculas': l10n.categoryMovies,
|
|
'musica': l10n.categoryMusic,
|
|
'tecnologia': l10n.categoryTechnology,
|
|
};
|
|
return nombres[clave] ?? clave;
|
|
}
|
|
const nombres = {
|
|
'todas': 'Todas',
|
|
'animales': 'Animales',
|
|
'comida': 'Comida',
|
|
'paises': 'Países',
|
|
'deportes': 'Deportes',
|
|
'profesiones': 'Profesiones',
|
|
'objetos': 'Objetos',
|
|
'lugares': 'Lugares',
|
|
'peliculas': 'Películas',
|
|
'musica': 'Música',
|
|
'tecnologia': 'Tecnología',
|
|
};
|
|
return nombres[clave] ?? clave;
|
|
}
|
|
}
|
|
|
|
class EntradaPalabraTraducida {
|
|
final String palabra;
|
|
final String pista;
|
|
|
|
const EntradaPalabraTraducida({required this.palabra, required this.pista});
|
|
}
|
|
|
|
class BancoPalabrasTraducidas {
|
|
final Map<String, List<EntradaPalabraTraducida>> categorias;
|
|
|
|
const BancoPalabrasTraducidas(this.categorias);
|
|
|
|
static final Map<String, BancoPalabrasTraducidas> _instancias = {};
|
|
|
|
static Future<BancoPalabrasTraducidas> cargar({String idioma = 'es'}) async {
|
|
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
|
|
|
|
final banco = await BancoPalabras.cargar(idioma: idioma);
|
|
final mapa = <String, List<EntradaPalabraTraducida>>{};
|
|
for (final categoria in banco.categorias.entries) {
|
|
final pistaImpostor =
|
|
banco.pistaDeCategoria(categoria.key) ?? categoria.key;
|
|
mapa[categoria.key] = categoria.value
|
|
.map(
|
|
(palabra) => EntradaPalabraTraducida(
|
|
palabra: palabra,
|
|
pista:
|
|
banco.pistaDePalabra(palabra, categoria: categoria.key) ??
|
|
pistaImpostor,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
_instancias[idioma] = BancoPalabrasTraducidas(mapa);
|
|
return _instancias[idioma]!;
|
|
}
|
|
}
|