@ing.jorgeu/wargaming-overlay 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/PUBLISHING.md +13 -20
  2. package/README.md +244 -4
  3. package/THIRD_PARTY_ASSETS.md +8 -0
  4. package/battle_reader.py +50 -7
  5. package/cli.js +27 -7
  6. package/device_connection.py +14 -5
  7. package/game-store.js +26 -3
  8. package/package.json +25 -6
  9. package/public/army-list.css +18 -0
  10. package/public/army-list.html +1 -0
  11. package/public/army-list.js +59 -0
  12. package/public/control.html +3 -3
  13. package/public/control.js +3 -2
  14. package/public/detachments.css +2 -2
  15. package/public/live-score.css +3 -3
  16. package/public/live-score.js +8 -4
  17. package/public/match-view.js +15 -4
  18. package/public/roster-view.js +8 -0
  19. package/public/rosters-control.js +77 -0
  20. package/public/style.css +8 -0
  21. package/reader-status.js +19 -0
  22. package/rosters.js +39 -0
  23. package/scripts/setup-kokoro.js +2 -0
  24. package/scripts/tts-demo.js +10 -0
  25. package/server.js +53 -11
  26. package/tts/kokoro-assets.js +27 -0
  27. package/tts/kokoro-engine.js +31 -0
  28. package/tts/kokoro-worker.js +13 -0
  29. package/tts/licenses/espeak-ng-COPYING +674 -0
  30. package/tts/playback.js +28 -0
  31. package/tts/queue.js +22 -0
  32. package/tts/sanitizer.js +31 -0
  33. package/tts/speech.js +72 -0
  34. package/tts/voice-command.js +14 -0
  35. package/tts/wav.js +6 -0
  36. package/tts/windows-playback.js +14 -0
  37. package/turn-tracker.js +33 -21
  38. package/youtube/auth.js +71 -0
  39. package/youtube/chat.proto +29 -0
  40. package/youtube/index.js +23 -0
  41. package/youtube/live-broadcast.js +13 -0
  42. package/youtube/live-chat.js +24 -0
  43. package/youtube/poll-chat.js +15 -0
  44. package/youtube/service.js +73 -0
package/PUBLISHING.md CHANGED
@@ -1,35 +1,28 @@
1
- # Preparación para npm
1
+ # Publicación de releases
2
2
 
3
- La versión 2.1.0 está empaquetada para distribución, pero NO se ha publicado ni instalado globalmente en el equipo del usuario.
3
+ Paquete: `@ing.jorgeu/wargaming-overlay`, cuenta npm `ing.jorgeu`. Repositorio: https://github.com/ing-jorgeu/wargaming-overlay. Código bajo 0BSD; las notas de recursos visuales están en `THIRD_PARTY_ASSETS.md`.
4
4
 
5
- ## Pendiente de decidir
6
-
7
- - Nombre elegido: `@ing.jorgeu/wargaming-overlay`. Publicar con la cuenta npm `ing.jorgeu`, confirmada por el titular.
8
- - Licencia del código resuelta: **0BSD**, seleccionada por el usuario. Permite uso comercial, modificación y redistribución sin atribución obligatoria. Las imágenes requieren revisión por separado.
9
- - Revisar la procedencia y permiso de redistribución de los recursos visuales; ver `THIRD_PARTY_ASSETS.md`.
10
- - Añadir `repository`, `homepage`, `bugs` y `author` cuando se conozcan. No se han inventado datos personales ni URLs.
11
- - Ejecutar el workflow de GitHub Actions en las tres plataformas y probar USB real en Windows y Linux. Aquí solo se validó macOS físicamente; las ramas de otras plataformas se prueban con escenarios simulados.
12
-
13
- ## Primera publicación manual
14
-
15
- Desde la raíz del proyecto, ya con las decisiones anteriores resueltas:
5
+ Desde la raíz del repositorio:
16
6
 
17
7
  ```sh
8
+ npm ci
9
+ npm version 3.0.0 --no-git-tag-version
18
10
  npm test
19
11
  npm pack --dry-run
20
12
  npm login
21
13
  npm whoami
22
- npm view @ing.jorgeu/wargaming-overlay name version
23
14
  npm publish --dry-run
24
- npm publish --access public
25
15
  ```
26
16
 
27
- Si el nombre existe y no eres su propietario, cambia `name` a `@tu-scope/wargaming-overlay` (sustituyendo el ejemplo por un scope real). Consulta el nombre elegido con `npm view` antes de publicar. Un 404 puede significar que el nombre no existe; otros errores no prueban disponibilidad. Usa una versión nueva si ya fue publicada.
17
+ Usa una versión nueva en cada release. Revisa el diff, crea el commit y súbelo a `origin/main` antes de publicar. Después:
28
18
 
29
- El último comando publica realmente. No se ha ejecutado. La autenticación y la verificación de npm deben completarse en tu sesión; no guardes tokens en el repositorio.
19
+ ```sh
20
+ npm publish --access public
21
+ npm view @ing.jorgeu/wargaming-overlay version
22
+ ```
30
23
 
31
- `prepublishOnly` ejecuta las pruebas. `files` limita el paquete a código, interfaz y documentación; no incluye estado personal, backups, pruebas ni workflows. No hay scripts de instalación automáticos. ADB se instala únicamente al invocar `setup --install-adb`.
24
+ `prepublishOnly` ejecuta las pruebas. npm puede pedir autorización en el navegador tanto para login como para publicar; no guardes tokens en el repositorio. Confirma que el registro muestra la versión esperada y que el commit está en remoto.
32
25
 
33
- Para releases posteriores se puede configurar Trusted Publishing con GitHub Actions desde la configuración del paquete en npm. No se ha creado un workflow de publicación con repositorio o identidad inventados.
26
+ `files` limita el paquete a código, interfaz y documentación. No incluye Excel de investigación, credenciales OAuth, estados de partidas, pruebas ni workflows. Las dependencias de gRPC se instalan por npm; no hay scripts de instalación propios. ADB se instala únicamente al invocar `setup --install-adb`.
34
27
 
35
- Fuentes oficiales: [npm publish](https://docs.npmjs.com/cli/v7/commands/npm-publish/), [Trusted Publishing](https://docs.npmjs.com/trusted-publishers/).
28
+ Las pruebas automatizadas cubren el transporte gRPC local, OAuth simulado y continuidad del overlay ante errores. El acceso real a la API del canal ya se verificó; la recepción de mensajes de un directo y el cambio físico de salida de audio siguen requiriendo una prueba real. Windows/Linux no usan el TTS nativo de macOS.
package/README.md CHANGED
@@ -25,7 +25,7 @@ El paquete está publicado en [npm como @ing.jorgeu/wargaming-overlay](https://w
25
25
 
26
26
  | Dependencia | Requisito y función | Descarga oficial |
27
27
  | --- | --- | --- |
28
- | Node.js y npm | Node 20 o posterior; preferir una versión LTS mantenida. Ejecuta el CLI y servidor. npm viene con las instalaciones habituales de Node. | [Node.js](https://nodejs.org/en/download/) |
28
+ | Node.js y npm | Node 22 o posterior; preferir una versión LTS mantenida. Ejecuta el CLI y servidor. npm viene con las instalaciones habituales de Node. | [Node.js](https://nodejs.org/en/download/) |
29
29
  | Python | Python 3.8 o posterior; preferir una versión mantenida. Interpreta el XML de Android. | [Python](https://www.python.org/downloads/) |
30
30
  | ADB | Android SDK Platform-Tools. Comunica el equipo con el teléfono. | [Platform-Tools](https://developer.android.com/tools/releases/platform-tools) |
31
31
  | OBS Studio | Fuente Navegador disponible. Muestra el overlay sobre el vídeo. | [OBS Studio](https://obsproject.com/download) |
@@ -354,13 +354,52 @@ Con otra relación de aspecto mantiene 16:9, centrado horizontalmente y alineado
354
354
  | `wargaming-overlay setup --dry-run` | Muestra el plan sin instalar ni consultar ADB. |
355
355
  | `wargaming-overlay setup --install-adb` | Instala ADB si falta y hay un gestor compatible; después diagnostica. |
356
356
  | `wargaming-overlay migrate CARPETA` | Copia el estado anterior sin sobrescribir archivos existentes. |
357
- | `wargaming-overlay --help` | Muestra ayuda. |
357
+ | `wargaming-overlay youtube-auth --client-secret RUTA` | Autoriza el canal con el JSON OAuth de escritorio. |
358
+ | `wargaming-overlay youtube-auth` | Reautoriza usando el cliente OAuth guardado. |
359
+ | `wargaming-overlay --help` o `-h` | Muestra ayuda. |
358
360
  | `wargaming-overlay --version` | Muestra versión. |
359
361
 
360
362
  `setup --install-adb` usa Homebrew, WinGet, apt-get, dnf o pacman según la plataforma. No instala Node, Python ni gestores de paquetes; no activa opciones de Android, no acepta autorizaciones USB ni acuerdos por ti. Puede pedir permisos del sistema. Si termina instalando pero todavía no detecta ADB, abre otra terminal y ejecuta `doctor`.
361
363
 
362
364
  `doctor` y `setup` devuelven código 1 si el diagnóstico no está listo, incluso si solo falta conectar el teléfono. Con ADB/Python instalados, `start` conserva el panel abierto y reintenta aunque falte el teléfono.
363
365
 
366
+ ### Comandos desde el repositorio
367
+
368
+ Instala las dependencias con `npm ci` (o `npm install` si actualizas dependencias). Todos los comandos de la tabla anterior pueden ejecutarse como `node cli.js COMANDO` sin instalación global.
369
+
370
+ | Comando | Acción |
371
+ | --- | --- |
372
+ | `npm start` | Inicia el backend desde este checkout. |
373
+ | `npm start -- --port 8766 --interval 5` | Pasa opciones al backend; el separador `--` es necesario. |
374
+ | `npm test` | Ejecuta las pruebas automatizadas; no necesita el canal ni reproduce audio. |
375
+ | `wargaming-overlay --test-tts "Hola"` | Lee un texto con la configuración TTS y termina; no inicia overlay, YouTube ni ADB. |
376
+ | `npm run test-tts` | Reproduce tres mensajes de prueba por la salida predeterminada del sistema. |
377
+ | `npm run facts:coverage` | Informa la cobertura real de curiosidades por facción. |
378
+ | `npm run facts:coverage -- --require-four-hours` | Devuelve código 1 si alguna facción no llega a 120 curiosidades. |
379
+ | `npm run prepublishOnly` | Ejecuta las pruebas previas a publicar; npm lo invoca automáticamente en `npm publish`. |
380
+ | `npm pack --dry-run` | Revisa los archivos que incluirá el paquete sin publicarlo. |
381
+
382
+ Para instalar o actualizar el comando global: `npm install -g @ing.jorgeu/wargaming-overlay`. Para una versión concreta: `npm install -g @ing.jorgeu/wargaming-overlay@3.0.0`. Usa `wargaming-overlay --version` para comprobar qué instalación ejecuta tu terminal.
383
+
384
+ ### Activar y desactivar la voz de YouTube
385
+
386
+ El TTS está **desactivado por defecto**. Sin `YOUTUBE_CHAT_TTS_ENABLED=true`, el overlay no inicia el chat, la cola de voz ni Kokoro. También puedes deshabilitarlo con `"enabled": false` en `youtube-tts.json`.
387
+
388
+ ```sh
389
+ # Desde el repositorio
390
+ YOUTUBE_CHAT_TTS_ENABLED=true npm start
391
+
392
+ # Con el paquete instalado globalmente
393
+ YOUTUBE_CHAT_TTS_ENABLED=true wargaming-overlay start
394
+
395
+ # Desactivar explícitamente
396
+ YOUTUBE_CHAT_TTS_ENABLED=false wargaming-overlay start
397
+ ```
398
+
399
+ La variable se aplica a ese inicio. También puedes exportarla en tu terminal; para quitar un valor exportado, usa `unset YOUTUBE_CHAT_TTS_ENABLED`. La configuración opcional `youtube-tts.json`, los pasos OAuth y las limitaciones están en [YouTube Live → voz local](#youtube-live--voz-local-con-kokoro). `Ctrl+C` detiene lector, chat y voz; los cambios de código/configuración requieren reiniciar.
400
+
401
+ Para publicar una release, consulta [PUBLISHING.md](PUBLISHING.md): `npm version VERSION --no-git-tag-version`, `npm login`, `npm whoami`, `npm publish --dry-run` y `npm publish --access public`. La publicación requiere acceso a la cuenta propietaria y una versión no publicada.
402
+
364
403
  ### Opciones
365
404
 
366
405
  | Opción | Valor predeterminado | Uso |
@@ -371,6 +410,7 @@ Con otra relación de aspecto mantiene 16:9, centrado horizontalmente y alineado
371
410
  | `--adb` | Autodetección | Ruta al ejecutable ADB para `start`, `doctor` o `setup`. |
372
411
  | `--python` | Autodetección | Ruta al ejecutable Python para `start`, `doctor` o `setup`. |
373
412
  | `--data-dir` | Según el sistema | Carpeta de datos para `start` y destino de `migrate`. |
413
+ | `--client-secret` | Cliente guardado, si existe | Ruta al JSON OAuth de escritorio; solo para `youtube-auth`. |
374
414
 
375
415
  Las opciones no se guardan como configuración del CLI: repítelas en futuros inicios si quieres mantener un puerto, serial o ruta distintos.
376
416
 
@@ -461,7 +501,7 @@ El botón **Restablecer datos de la partida** vacía los puntos y misiones guard
461
501
 
462
502
  Solo lee lo visible. Un bloque sin nombre de jugador se ignora para evitar asignaciones incorrectas. Los datos conservados pueden haber cambiado fuera de pantalla y las listas de misiones pueden ser parciales. No interpreta una lista ausente como lista vacía.
463
503
 
464
- Al cambiar de ronda no reutiliza CP ni misiones de la ronda anterior. Si consultas una ronda histórica en el teléfono, mostrará esa ronda. TOP/BOTTOM se infiere automáticamente al observar aumentos de puntuación o cambios de objetivos previamente leídos. No requiere seleccionar el turno en el panel. El orden se aprende a partir de la primera actividad observada tras una ronda nueva, o desde una partida observada en ronda 1 con ambos marcadores en cero. Si conectas a mitad de partida, puede quedar sin indicar hasta la siguiente ronda. La detección se guarda al reiniciar y es independiente del intercambio de lados. Es una estimación: puntos registrados fuera de turno o correcciones al alza pueden confundirla. No interpreta misiones que aparecen por primera vez al desplazar la pantalla como cartas recién robadas. Bajar puntos o gastar CP no cambia el turno; cambios simultáneos en ambos jugadores lo conservan. El nombre general de la primaria no siempre está expuesto; se muestran sus condiciones de puntuación.
504
+ Al avanzar a la ronda siguiente conserva las secundarias sin puntuar hasta confirmar la lista actual y mantiene las condiciones de primaria, reiniciando sus marcas y contadores. Los totales acumulados se conservan; los CP conservan el último valor leído hasta que el teléfono los actualice. Las lecturas parciales no eliminan misiones fuera de pantalla. No arrastra misiones al consultar rondas anteriores o saltar varias rondas. Si consultas una ronda histórica en el teléfono, mostrará esa ronda. TOP/BOTTOM se infiere automáticamente al observar una secundaria nueva sin puntos que aparece después de la lectura inicial, incluso en listas parcialmente visibles. No requiere seleccionar el turno en el panel. El orden se toma de Tabletop Battles: el primer jugador de la interfaz juega TOP y el segundo BOTTOM. Aunque conectes en ronda 5, la siguiente secundaria nueva permite identificar la mitad sin esperar otra ronda. La primera lectura por sí sola no determina el turno activo; hasta identificarlo se muestra solo la ronda. El último turno identificado se conserva dentro de esa ronda, incluso al cambiar de pantalla o reiniciar. La detección se guarda al reiniciar y es independiente del intercambio de lados. Es una estimación: la lectura parcial puede ocultar cuándo se robó una carta. Recuerda las cartas vistas por jugador y ronda para no confundir su reaparición al desplazar la pantalla con un robo. Una carta sin puntos nunca vista antes puede ser una carta antigua que estaba fuera de pantalla: en ese caso la estimación puede adelantarse. Puntuar primarias o secundarias, marcar objetivos y gastar o ganar CP no cambia el turno; nuevas cartas simultáneas de ambos jugadores conservan el turno anterior. El nombre general de la primaria no siempre está expuesto; se muestran sus condiciones de puntuación.
465
505
 
466
506
  ## Solución de problemas
467
507
 
@@ -530,7 +570,7 @@ npm pack
530
570
 
531
571
  `npm pack` genera el `.tgz` instalable. `npm pack --dry-run` lista el contenido sin crear ese archivo. No hay un paso de compilación ni dependencias npm que descargar para ejecutar las pruebas; estas sí necesitan Python.
532
572
 
533
- La matriz de `.github/workflows/test.yml` está preparada para Node 20/22/24 en Windows, macOS y Linux. Las pruebas cubren diagnósticos, rutas, selección de instaladores, parser y estado guardado. No sustituyen las pruebas físicas de USB ni de emisión con OBS.
573
+ La matriz de `.github/workflows/test.yml` está preparada para Node 22/24 en Windows, macOS y Linux. Las pruebas cubren diagnósticos, rutas, selección de instaladores, parser y estado guardado. No sustituyen las pruebas físicas de USB ni de emisión con OBS.
534
574
 
535
575
  Consulta [PUBLISHING.md](PUBLISHING.md) para autenticarte y publicar. `npm publish` sube el paquete realmente; no es un paso necesario para usarlo localmente. El scope debe ser tuyo o de una organización en la que tengas permiso. No se han incluido credenciales en el proyecto.
536
576
 
@@ -633,3 +673,203 @@ El lector recuerda el teléfono por su identificador físico en `phone-connectio
633
673
  Activa la depuración inalámbrica y empareja el computador con el teléfono en Android. Tener activada la opción no equivale a estar emparejado. ADB debe poder descubrir el teléfono en la red. Si el descubrimiento no funciona, conecta previamente con `adb connect IP:PUERTO` usando la dirección que Android muestra en Depuración inalámbrica. El overlay puede recuperar direcciones conocidas y servicios inalámbricos que anuncien el identificador del teléfono guardado; no activa puertos de depuración ni empareja dispositivos desconocidos automáticamente.
634
674
 
635
675
  Para elegir otro teléfono, inicia con `--serial SERIAL` usando su identificador de `adb devices -l`. Si hay varios teléfonos diferentes y ninguno seleccionado, el lector pide seleccionar uno. El panel distingue conexión USB, Wi-Fi y espera de una ronda legible. Al enviar Tabletop Battles al fondo se conserva el último marcador; al volver a la ronda, la lectura se reanuda. Durante una reconexión puede haber una pausa de unos segundos.
676
+
677
+ ## YouTube Live → voz local con Kokoro
678
+
679
+ Función opcional del mismo backend, independiente de OBS y del teléfono. Usa OAuth de solo lectura, descubre el directo activo de la cuenta autorizada y recibe el chat por `liveChatMessages.streamList` (gRPC). La voz neural Kokoro se genera localmente desde Node mediante ONNX. Sale por el dispositivo predeterminado del sistema; volumen y salida se cambian en los ajustes de audio. No necesita Python, Homebrew ni servicios TTS externos.
680
+
681
+ ### Primera autorización
682
+
683
+ 1. Crea un proyecto en [Google Cloud](https://console.cloud.google.com/) y habilita **YouTube Data API v3**.
684
+ 2. Configura Google Auth Platform, añade tu cuenta como usuario de prueba si corresponde y crea un cliente OAuth **Aplicación de escritorio**. Descarga el JSON fuera del repositorio.
685
+ 3. Desde este repositorio, ejecuta:
686
+
687
+ ```sh
688
+ node cli.js youtube-auth --client-secret "$HOME/Downloads/client_secret.json"
689
+ ```
690
+
691
+ Con el paquete instalado, el equivalente es `wargaming-overlay youtube-auth --client-secret /ruta/al/archivo.json`. Se abre el navegador: elige la cuenta/canal correcto y concede lectura. El retorno escucha temporalmente solo en `127.0.0.1`, valida `state` y usa PKCE. No se deben pegar tokens en la terminal ni en el chat.
692
+
693
+ Esta V1 guarda cliente OAuth y tokens en `~/.config/wargaming-overlay/youtube/`, archivos con permisos `0600`, fuera del repositorio y de los recursos HTTP del overlay. El refresh token se reutiliza al reiniciar y renueva el acceso automáticamente. Para desconectar la cuenta, detén el backend, revoca el acceso en tu cuenta Google y elimina esa carpeta. No hay almacenamiento en Keychain en esta versión.
694
+
695
+ **Google limita a siete días los refresh tokens de proyectos externos en modo Testing** para estos permisos. Antes de usarlo permanentemente, revisa el estado de publicación del proyecto. Una revocación o expiración requiere repetir `youtube-auth`; no se puede garantizar una autorización eterna.
696
+
697
+ ### Arranque y prueba
698
+
699
+ ```sh
700
+ YOUTUBE_CHAT_TTS_ENABLED=true npm start
701
+ npm run test-tts
702
+ ```
703
+
704
+ La segunda instrucción ejecuta tres mensajes falsos, sin YouTube ni OBS. No la ejecutes a la vez que otro lector TTS: la exclusión de voces es por proceso. Para desactivar el chat, omite la variable o usa `YOUTUBE_CHAT_TTS_ENABLED=false`. No se inicia ninguna conexión YouTube cuando está desactivado. El TTS también se inicia en Windows x64 y Linux x64/ARM64 con audio de escritorio.
705
+
706
+ Si no hay directo con chat, comprueba cada 60 segundos. Al conectar omite el historial anterior a la conexión. Mantiene en memoria el identificador del chat, el cursor de continuación y hasta 1.000 IDs recientes. Ante una desconexión reintenta a 1, 2, 5, 10 y como máximo 30 segundos; una única conexión por instancia. Al terminar el directo o deshabilitarse el chat cancela la voz pendiente y vuelve a buscar. Si hay varios directos simultáneos, elige el primero por ID y lo indica en el log.
707
+
708
+ Los mensajes normales se transforman en eventos `youtube_chat_message`; los eventos administrativos, Super Chat y membresías no se leen en V1. No se escriben mensajes de texto en disco. En Windows, el audio se guarda temporalmente como WAV y se elimina al finalizar o cancelar la lectura. La terminal muestra mensajes sanitizados y estados YouTube/TTS, sin tokens. Los fallos de OAuth, chat o voz no detienen el overlay.
709
+
710
+ ### Ajustes sencillos
711
+
712
+ Opcionalmente crea `youtube-tts.json` dentro de la carpeta de datos que muestra el backend al iniciar:
713
+
714
+ ```json
715
+ {
716
+ "enabled": true,
717
+ "maxMessageLength": 300,
718
+ "maxQueueLength": 10,
719
+ "readUsername": true,
720
+ "filterUrls": true,
721
+ "extraBlockedTerms": []
722
+ }
723
+ ```
724
+
725
+ Reinicia el backend para aplicar cambios. Los mensajes se truncan sin cortar palabras cuando es posible, los nombres se limitan a 40 caracteres y las URLs se sustituyen por «Enlace». Si la cola está llena, elimina el pendiente más antiguo; nunca interrumpe una voz para superponer otra. Al detener el backend cancela la voz activa y descarta los pendientes. Cada mensaje usa un proceso Node aislado para síntesis y reproducción, sin shell y con un tiempo máximo de 120 segundos. La cancelación termina ese proceso y su audio. En macOS, si Kokoro falla, se usa `say` por defecto; en Windows/Linux se registra el error y continúa la cola.
726
+
727
+ La moderación es un filtro local pequeño de patrones ES/EN para insultos discriminatorios graves, amenazas directas, llamados a violencia y algunas expresiones sexuales explícitas. Sustituye los fragmentos por «censurado» y descarta mensajes sin contenido restante. Conserva groserías coloquiales como «qué mierda de tirada» o «ese weón tuvo demasiada suerte». **No entiende contexto ni garantiza detectar todas las variantes o idiomas**; amplía `extraBlockedTerms` según el canal. También filtra los nombres y elimina instrucciones de voz embebidas antes de ejecutar `say`.
728
+
729
+ ### Verificación
730
+
731
+ `npm test` incluye OAuth con callback local y renovación simulada, gRPC local real, descubrimiento de directos, continuación tras desconexión, duplicados, fin del directo, backoff, sanitización y cola sin superposición. No requiere credenciales ni envía mensajes a YouTube. La prueba real de autorización, recepción de un directo y cambio físico de salida de audio requiere la cuenta del canal y comprobación del operador.
732
+
733
+ Referencias oficiales: [OAuth de escritorio](https://developers.google.com/youtube/v3/guides/auth/installed-apps), [descubrimiento de directos](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list), [chat por streaming](https://developers.google.com/youtube/v3/live/streaming-live-chat), [duración de tokens en Testing](https://developers.google.com/identity/protocols/oauth2).
734
+
735
+ ## Listas de ejército para OBS
736
+
737
+ En el administrador abre **Listas de ejército**. Cada jugador tiene su propia lista y un enlace **Abrir lista para OBS**. El nombre, la facción y los destacamentos se editan en **Partida** y aparecen en la cabecera de la lista.
738
+
739
+ 1. Pulsa **Añadir unidad** e introduce su nombre.
740
+ 2. Añade, si corresponde, el nombre de una mejora, varios personajes (uno por línea) y una nota breve.
741
+ 3. Ordena las unidades arrastrando el asa **⠿** y soltándola antes o después de otra unidad. **Subir / Bajar** también está disponible para teclado.
742
+ 4. Marca **En reservas** para mostrar la etiqueta de reserva, o **Unidad destruida** para tacharla y atenuarla. Una unidad destruida deja de mostrar la etiqueta En reservas.
743
+ 5. Pulsa **Guardar lista**. Los cambios llegan a OBS sin refrescar la fuente. Las modificaciones quedan como borrador hasta guardarlas; el navegador avisa si intentas cerrar con cambios pendientes.
744
+
745
+ En OBS crea dos escenas nuevas, cada una con una fuente **Navegador**, y pega el enlace de su jugador. Configura **1920 × 1080** (se escala proporcionalmente en otras resoluciones 16:9). Puedes cambiar de escena libremente. La URL lleva un identificador estable del jugador: sigue mostrando su lista aunque intercambies izquierda/derecha. Las etiquetas de color sí siguen el lado actual. Toda la lista aparece en una sola pantalla, sin páginas ni rotación. El diseño ajusta columnas y tamaño de texto para que quepa; las listas muy extensas tendrán texto más pequeño. Las unidades consecutivas con el mismo nombre se agrupan bajo un encabezado con su cantidad; cada copia conserva sus personajes, mejora, notas y estado. Si otra unidad interrumpe la secuencia, se inicia un grupo nuevo.
746
+
747
+ La vista está en `/army-list.html?player=IDENTIFICADOR`; copia la URL exacta desde el panel. También existen `/army-list.html?side=left` y `?side=right` para fuentes que deban seguir un lado, en vez de a una persona.
748
+
749
+ Puedes asignar una **URL de imagen de fondo** a cada jugador desde el editor. Debe ser un enlace HTTP/HTTPS directo y accesible desde OBS; deja el campo vacío para usar el fondo predeterminado. La imagen se carga en el navegador, no en el servidor, y se oscurece para mantener la legibilidad. El fondo se guarda junto con la lista y se incluye al exportar JSON.
750
+
751
+ ### Importar y exportar
752
+
753
+ **Exportar JSON** descarga la lista actual, incluidos los cambios del borrador. **Importar JSON** permite recuperarla o cargar un archivo con este formato:
754
+
755
+ ```json
756
+ {
757
+ "backgroundUrl": "",
758
+ "units": [
759
+ {
760
+ "name": "Rubric Marines",
761
+ "enhancement": "Nombre de la mejora",
762
+ "characters": ["Ahriman", "Exalted Sorcerer"],
763
+ "notes": "Vigilar el objetivo central",
764
+ "deepStrike": true,
765
+ "destroyed": false
766
+ }
767
+ ]
768
+ }
769
+ ```
770
+
771
+ También se acepta directamente el array de unidades. Los identificadores se generan al importar. La importación queda como borrador: revisa y guarda. No se interpretan automáticamente PDFs ni exportaciones de otras aplicaciones en esta versión.
772
+
773
+ Límites: 80 unidades por jugador; nombre y mejora hasta 100 caracteres; hasta 8 personajes de 80 caracteres cada uno; notas hasta 160 caracteres. No se impone que los nombres de unidades sean distintos: puedes tener varias escuadras iguales.
774
+
775
+ Las listas se guardan en `rosters.json` dentro de la carpeta de datos, fuera del paquete npm. Para respaldarlas, conserva también `overlay-state.json`, que contiene el vínculo de cada jugador. Reiniciar el servicio o restablecer los puntos de la partida no borra las listas. Al preparar otra partida puedes editar o importar las nuevas listas. Si otra pestaña modificó una lista, el servidor rechaza sobrescribirla con un borrador antiguo; exporta tu borrador si quieres conservarlo y recarga para obtener la versión actual.
776
+
777
+ Si el panel indica que el servicio está desactualizado al guardar un fondo, detén `npm start` con Ctrl+C y ejecútalo de nuevo. Recargar el navegador no actualiza el servidor Node. La URL queda en el borrador para guardarla después del reinicio. El overlay de listas ocupa toda la fuente de OBS incluso si no usa una proporción 16:9.
778
+
779
+ Cada lista admite un nombre opcional (máximo 80 caracteres), editable en «Listas de ejército». Se muestra encima del nombre del jugador en OBS y se conserva al importar/exportar JSON mediante el campo `name`. Sin nombre se muestra «ORDEN DE BATALLA». Reinicia el servicio después de actualizar para activar el guardado del campo.
780
+
781
+ Marca **Battleshocked** en una unidad y guarda la lista para mostrar su etiqueta ámbar en OBS. El estado se conserva en el campo `battleshocked` del JSON; las listas antiguas lo toman como falso. Las unidades destruidas ocultan esta etiqueta. Desmárcalo cuando deje de aplicarse.
782
+
783
+ Cuando una unidad tiene personajes asignados, sus nombres son el título principal y debajo aparece «Lidera:» con el nombre de la unidad. En los grupos consecutivos cada unidad conserva sus propios personajes y estados.
784
+
785
+ Los enlaces del administrador para OBS son fijos por color: `/army-list.html?side=blue` y `/army-list.html?side=red`. Usa «Copiar URL azul/roja» para copiarlos al portapapeles. Al intercambiar jugadores muestran la lista del nuevo ocupante de ese color, sin cambiar la URL en OBS. Los enlaces anteriores siguen siendo compatibles.
786
+
787
+ Las listas se guardan automáticamente al dejar de escribir brevemente, reordenar o cambiar estados. El panel muestra el estado del guardado y reintenta si falla la conexión. Completa los nombres de las unidades nuevas para guardar. No cierres el panel mientras indique cambios pendientes o errores.
788
+
789
+ Si se habilita `httpFallback` y `streamList` rechaza las credenciales con código 16, el chat cambia automáticamente a consultas HTTP de respaldo. Respeta el intervalo de YouTube con un mínimo de 15 segundos; puede haber esa demora adicional en la lectura. Este modo consume cuota por consulta (5 unidades, hasta unas 1.200 por hora a intervalos de 15 segundos).
790
+
791
+ ### Autenticación de streamList y respaldo opcional
792
+
793
+ Si gRPC rechaza OAuth aunque la API HTTP funcione, se puede probar la autenticación oficial mediante API key. En Google Cloud crea una clave restringida a YouTube Data API v3 y guárdala en un archivo local fuera del repositorio. En `youtube-tts.json` configura `"apiKeyFile": "/ruta/privada/youtube-api-key.txt"`. Reinicia el servicio. OAuth sigue usándose para detectar el directo; streamList envía solo la API key. La clave no se imprime ni se exporta al overlay.
794
+
795
+ El respaldo por consultas HTTP está **desactivado por defecto** para conservar cuota. Para habilitarlo explícitamente configura `"httpFallback": true`. Cada consulta consume cuota; no equivale a una conexión streamList persistente.
796
+
797
+
798
+ ### Instalación y configuración de Kokoro
799
+
800
+ Requiere Node 22+. `npm install` instala las dependencias TTS, pero no descarga el modelo. Kokoro descarga automáticamente su modelo v1.0 q8 y tres voces españolas (aproximadamente 96 MB) al leer el primer mensaje o ejecutar una prueba de voz con ese proveedor. Si el TTS está desactivado o eliges `say`, no descarga ni carga el modelo. La caché se guarda en `~/.cache/wargaming-overlay/kokoro/<revisión>`. Después de la primera descarga, la síntesis funciona sin internet. No hay pasos de instalación manual obligatorios para TTS. Opcionalmente puedes preparar la caché antes de transmitir con `npm run setup-tts`. Si una descarga falla, las siguientes lecturas reintentan y se aplica el respaldo configurado. La conexión a YouTube sí requiere internet.
801
+
802
+ Sistemas de audio compatibles: macOS Intel/Apple Silicon (CoreAudio), Windows x64 (PowerShell/SoundPlayer y ONNX WebAssembly), Linux x64/ARM64 con glibc y ALSA (`libasound.so.2`) y una salida de audio de escritorio. Windows ARM64, Alpine/musl y sistemas sin audio no se consideran compatibles con reproducción. No se requieren Python, Rust, CMake, ffmpeg ni espeak instalados en el sistema. Linux necesita sus bibliotecas de audio habituales; el paquete no instala componentes del sistema operativo.
803
+
804
+ Añade estas propiedades a `youtube-tts.json`, conservando las demás:
805
+
806
+ ```json
807
+ {
808
+ "ttsProvider": "kokoro",
809
+ "ttsFallback": "say",
810
+ "kokoroVoice": "ef_dora",
811
+ "kokoroSpeed": 1
812
+ }
813
+ ```
814
+
815
+ En Windows/Linux usa `"ttsFallback": "none"`. Voces: `ef_dora`, `em_alex`, `em_santa`. Velocidad: de 0.5 a 2. Para volver a la voz anterior en macOS cambia `"ttsProvider": "say"`. Reinicia el backend para aplicar los cambios. También puedes usar `TTS_PROVIDER`, `TTS_FALLBACK`, `KOKORO_VOICE`, `KOKORO_SPEED` y `KOKORO_CACHE_DIR`; las variables tienen prioridad sobre el JSON. `KOKORO_SKIP_DOWNLOAD=true` omite la descarga del comando opcional `npm run setup-tts`, pero no durante la primera lectura con Kokoro.
816
+
817
+ El modelo se fija a Kokoro v1.0 q8 y a una revisión verificada, para evitar cambios silenciosos. Cambiar el modelo requiere actualizar el manifiesto `tts/kokoro-assets.js` (revisión, archivos y SHA-256) y verificar que conserva las entradas/salida ONNX usadas por `tts/kokoro-engine.js`; no se aceptan IDs de modelos arbitrarios.
818
+
819
+ La cola FIFO, los filtros y el overlay conservan su funcionamiento. En Windows se usa ONNX WebAssembly para evitar instalar el runtime de Visual C++; la reproducción usa PowerShell incluido en Windows y termina con `PlaySync`. La cancelación termina el árbol de procesos. En macOS/Linux se usa ONNX nativo y CoreAudio/ALSA. La síntesis se realiza en un proceso aparte para no bloquear HTTP/SSE. Las pruebas de generación real se realizaron en macOS; la reproducción física en macOS y la ejecución real en Windows/Linux siguen pendientes. Las pruebas automáticas usan audio simulado y no reproducen sonido.
820
+
821
+ En la prueba local, la generación nativa tardó aproximadamente 2 s en caliente y 3.7 s en frío para una frase de 3.9 s. El backend WebAssembly que usa Windows tardó aproximadamente 6.5 s en la misma Mac; no es una medición de rendimiento en Windows.
822
+
823
+
824
+ ### Probar un texto sin iniciar el servicio
825
+
826
+ ```sh
827
+ wargaming-overlay --test-tts "Pedro dice: Magnus falló una carga de ocho pulgadas."
828
+ ```
829
+
830
+ También está disponible el alias `warhammer-overlay --test-tts "Hola mundo"`. Ambos nombres ejecutan el mismo CLI.
831
+
832
+ Desde el repositorio:
833
+
834
+ ```sh
835
+ npm run start -- --test-tts "Hola, esta es una prueba de voz."
836
+ npm run start -- --test-tts "Hola, soy Alex." --voice em_alex --fallback none
837
+ npm run start -- --test-tts "Esta es la voz anterior de macOS." --provider say
838
+ ```
839
+
840
+ El comando lee el texto por la salida de audio predeterminada, espera a que termine la reproducción y sale. No inicia el servidor, el lector del teléfono ni conexiones a YouTube/OBS; tampoco modifica la partida o la configuración guardada. El texto de prueba se envía directamente al TTS, sin añadir nombre de usuario ni aplicar los filtros del chat. Si falta el modelo de Kokoro, lo descarga automáticamente la primera vez.
841
+
842
+ Por defecto utiliza las opciones de voz de `youtube-tts.json` en la carpeta de datos habitual y las variables de entorno TTS. Puedes elegir otra configuración con `--data-dir RUTA`. Las opciones del comando tienen prioridad sobre las variables y el archivo, y solo se aplican a esa ejecución:
843
+
844
+ | Opción | Valores |
845
+ | --- | --- |
846
+ | `--test-tts` | Texto entre comillas; obligatorio y no vacío. |
847
+ | `--voice` | `ef_dora`, `em_alex`, `em_santa`. |
848
+ | `--provider` | `kokoro` o `say` (solo macOS). |
849
+ | `--fallback` | `say` (solo macOS) o `none`. Usa `none` para probar exclusivamente Kokoro. |
850
+ | `--speed` | Número de 0.5 a 2; afecta a Kokoro. |
851
+ | `--data-dir` | Carpeta que contiene `youtube-tts.json`. |
852
+
853
+ `Ctrl+C` cancela síntesis y reproducción. El comando termina con código 0 cuando finaliza sin error, o 1 si falla la configuración o la lectura. Evita ejecutarlo a la vez que otra instancia TTS para no superponer voces.
854
+
855
+
856
+ ### Elegir `say` en macOS
857
+
858
+ Puedes usar la voz nativa como proveedor principal, sin cargar Kokoro. Para una ejecución del servicio:
859
+
860
+ ```sh
861
+ YOUTUBE_CHAT_TTS_ENABLED=true TTS_PROVIDER=say npm run start
862
+ ```
863
+
864
+ Para conservar la elección, configura `"ttsProvider": "say"` en `youtube-tts.json`. Para una lectura puntual:
865
+
866
+ ```sh
867
+ npm run start -- --test-tts "Hola mundo" --provider say
868
+ ```
869
+
870
+ Para volver a Kokoro, configura `"ttsProvider": "kokoro"` y elimina cualquier `TTS_PROVIDER=say` exportado. `"ttsFallback": "say"` conserva la voz nativa como respaldo de Kokoro en macOS; `"ttsFallback": "none"` lo deshabilita. `say` no está disponible en Windows/Linux.
871
+
872
+
873
+ ### Cambios de la versión 3.0.0
874
+
875
+ Requiere Node 22 o posterior. Incorpora Kokoro local opcional, selección directa de `say` en macOS, prueba puntual con `--test-tts "texto"` y alias `warhammer-overlay`. El modelo se descarga automáticamente solo al usar Kokoro. Incluye las correcciones de lectura de objetivos primarios y continuidad/autenticación del chat que estaban preparadas en el proyecto.
@@ -5,3 +5,11 @@ Los PNG de `public/assets/` proceden del overlay local del usuario. Este proyect
5
5
  Antes de publicar el paquete, el titular debe revisar los recursos y añadir las atribuciones/licencias pertinentes o reemplazarlos por recursos redistribuibles. La licencia **0BSD** del código no concede derechos sobre imágenes de terceros.
6
6
 
7
7
  Los nombres de facciones se usan como referencias descriptivas. El paquete no declara afiliación oficial.
8
+
9
+
10
+ ## TTS local
11
+
12
+ - Kokoro v1.0 y recursos ONNX: Apache-2.0, https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX (revisión 1939ad2a8e416c0acfeecc08a694d14ef25f2231).
13
+ - ONNX Runtime: MIT, https://github.com/microsoft/onnxruntime.
14
+ - node-cpal: ISC, https://github.com/saeta-eth/node-cpal.
15
+ - eSpeak NG Emscripten: GPL-3.0, https://github.com/echogarden-project/espeak-ng-emscripten; fuente del motor: https://github.com/echogarden-project/espeak-ng. Se distribuye como dependencia npm y se usa para fonetizar español. Su licencia se incluye en `tts/licenses/espeak-ng-COPYING`; la licencia 0BSD del código propio no reemplaza las licencias de las dependencias.
package/battle_reader.py CHANGED
@@ -6,6 +6,12 @@ ROOT = pathlib.Path(__file__).resolve().parent
6
6
  def parse(xml):
7
7
  root = ET.fromstring(xml)
8
8
  nodes = [n for n in root.iter('node') if n.get('package') == 'com.goonhammer.ttba' and (n.get('content-desc') or n.get('text'))]
9
+ parents = {child: parent for parent in root.iter() for child in parent}
10
+ def enabled(node):
11
+ while node is not None:
12
+ if node.get('enabled') == 'false': return False
13
+ node = parents.get(node)
14
+ return True
9
15
  texts = [(n.get('content-desc') or n.get('text')).strip() for n in nodes]
10
16
  header = next((re.fullmatch(r'(\d+)\s*[-–]\s*(\d+)\s+(.+?) vs (.+)', t) for t in texts if re.fullmatch(r'(\d+)\s*[-–]\s*(\d+)\s+(.+?) vs (.+)', t)), None)
11
17
  rnd = next((int(m[1]) for t in texts if (m := re.fullmatch(r'Round (\d+)', t))), None)
@@ -36,22 +42,59 @@ def parse(xml):
36
42
  current[m[1].lower()] = {'score': int(m[2]), 'max': int(m[3])}
37
43
  if section == 'PRIMARY':
38
44
  details = re.fullmatch(r'(.+)\n(\d+VP.*)', t)
39
- if not details and i+1 < len(texts) and re.match(r'\d+VP', texts[i+1]):
45
+ counter_index = None
46
+ # Flutter exposes split rows in either label/rule/value or rule/label/value order.
47
+ label = '\n' not in t and not t.isdigit() and not re.match(r'\d+VP|PRIMARY:', t)
48
+ if not details and label and i+2 < len(texts) and re.match(r'\d+VP', texts[i+1]) and texts[i+2].isdigit():
40
49
  details = (None, t, texts[i+1])
50
+ counter_index = i+2
51
+ elif not details and label and i+2 < len(texts) and texts[i+1].isdigit() and re.match(r'\d+VP', texts[i+2]):
52
+ details = (None, t, texts[i+2])
53
+ counter_index = i+1
54
+ elif not details and label and i>0 and i+1<len(texts) and re.match(r'\d+VP',texts[i-1]) and texts[i+1].isdigit():
55
+ details = (None, t, texts[i-1])
56
+ counter_index = i+1
41
57
  if details:
42
58
  if current['primaryObjectives'] is None: current['primaryObjectives'] = []
43
59
  checkboxes = [n for n in nodes[i].iter('node') if n.get('checkable') == 'true']
44
60
  checked = checkboxes[0].get('checked') == 'true' if len(checkboxes) == 1 else None
45
61
  counter = None
46
- # Split counter rows expose label, scoring rule, then numeric value.
47
- if '\n' not in t and i+2 < len(texts) and texts[i+2].isdigit():
48
- counter = int(texts[i+2])
49
- current['primaryObjectives'].append({'name': details[1], 'details': details[2], 'checked': checked, 'counter': counter})
62
+ if counter_index is not None:
63
+ counter = int(texts[counter_index])
64
+ current['primaryObjectives'].append({'name': details[1], 'details': details[2], 'checked': checked, 'counter': counter, 'enabled': enabled(nodes[i])})
50
65
  m = re.fullmatch(r'(.+)\n(\d+)\n/(\d+)(?:\n\+(\d+)pts)?', t)
51
66
  if m:
52
67
  if current['secondaries'] is None: current['secondaries'] = []
53
- current['secondaries'].append(dict(name=m[1], score=int(m[2]), max=int(m[3])))
54
- return dict(round=rnd, half=None, players=players)
68
+ current['secondaries'].append(dict(name=m[1], score=int(m[2]), max=int(m[3]), enabled=enabled(nodes[i])))
69
+ # Only compare card membership when the entire secondary section is visible.
70
+ # A following player heading bounds it; for the final player require blank
71
+ # space below the last row and above the fixed round-navigation button.
72
+ def bounds(node):
73
+ v = re.fullmatch(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', node.get('bounds', ''))
74
+ return tuple(map(int, v.groups())) if v else None
75
+ for player in players:
76
+ player['secondariesComplete'] = False
77
+ owner = None
78
+ for i, text in enumerate(texts):
79
+ if text in names:
80
+ owner = names.index(text)
81
+ if owner is None or not re.fullmatch(r'SECONDARY:\s*\d+/\d+', text):
82
+ continue
83
+ end = next((j for j in range(i+1, len(texts)) if texts[j] in names or re.fullmatch(r'Round \d+', texts[j])), None)
84
+ if end is None:
85
+ continue
86
+ section = texts[i+1:end]
87
+ if 'Select missions' not in section or 'Discard missions' not in section:
88
+ continue
89
+ complete = texts[end] in names
90
+ if not complete and owner == 1 and end > i+1:
91
+ last, tab = bounds(nodes[end-1]), bounds(nodes[end])
92
+ complete = bool(last and tab and tab[1]-last[3] >= max(100, last[3]-last[1]))
93
+ if complete:
94
+ players[owner]['secondariesComplete'] = True
95
+ if players[owner]['secondaries'] is None:
96
+ players[owner]['secondaries'] = []
97
+ return dict(round=rnd, half=None, players=players, primarySchemaVersion=1)
55
98
 
56
99
  def merge(previous, fresh):
57
100
  now = time.time()
package/cli.js CHANGED
@@ -7,6 +7,8 @@ const version=require('./package.json').version;
7
7
  const help=`wargaming-overlay ${version} — overlay local para OBS
8
8
 
9
9
  wargaming-overlay start Inicia el overlay y lector
10
+ wargaming-overlay youtube-auth --client-secret RUTA Autoriza YouTube una vez
11
+ wargaming-overlay --test-tts "Hola" Lee un texto y termina
10
12
  wargaming-overlay doctor Revisa Python, ADB y teléfono
11
13
  wargaming-overlay setup Guía de configuración
12
14
  wargaming-overlay setup --install-adb Instala ADB con el gestor del sistema
@@ -21,20 +23,37 @@ Opciones de start/doctor:
21
23
  --python RUTA Python 3.8 o posterior
22
24
  --data-dir RUTA Carpeta para guardar configuración y partida
23
25
 
26
+ Opciones de prueba de voz:
27
+ --test-tts "TEXTO" Texto que se leerá (obligatorio)
28
+ --voice NOMBRE ef_dora, em_alex o em_santa
29
+ --provider NOMBRE kokoro o say (say solo macOS)
30
+ --fallback NOMBRE say o none
31
+ --speed NUMERO Velocidad entre 0.5 y 2
32
+ --data-dir RUTA Lee la configuración TTS de esa carpeta
33
+
24
34
  Por defecto: ~/Library/Application Support/wargaming-overlay en macOS;
25
35
  %LOCALAPPDATA%/wargaming-overlay en Windows; ~/.local/share/wargaming-overlay en Linux. Solo escucha en 127.0.0.1.
26
36
  Ctrl+C detiene el servidor y lector. El último estado queda guardado.
27
37
  `;
28
- function main(){
29
- const args=process.argv.slice(2);
38
+ async function main(argv=process.argv.slice(2),{voiceTest}={}){
39
+ const args=[...argv];
30
40
  if(args.includes('--help')||args.includes('-h')){console.log(help);return}
31
41
  if(args.includes('--version')){console.log(version);return}
32
- const command=args.shift()||'start',options={};let source;
33
- const keys={'--port':'port','--serial':'serial','--interval':'interval','--adb':'adb','--python':'python','--data-dir':'dataDir'};
34
- while(args.length){const a=args.shift();if(a==='--install-adb')options.install=true;else if(a==='--dry-run')options.dryRun=true;else if(keys[a]){const value=args.shift();if(!value||value.startsWith('--'))throw Error(`Falta el valor de ${a}`);options[keys[a]]=value}else if(command==='migrate'&&!source&&!a.startsWith('-'))source=a;else throw Error(`Argumento desconocido: ${a}. Usa --help.`)}
35
- if(!['start','doctor','setup','migrate'].includes(command))throw Error('Comando desconocido. Usa --help.');
42
+ let command=args[0]&&!args[0].startsWith('-')?args.shift():'start';
43
+ const options={};let source;
44
+ const keys={'--port':'port','--serial':'serial','--interval':'interval','--adb':'adb','--python':'python','--data-dir':'dataDir','--client-secret':'clientSecret','--test-tts':'testText','--text':'text','--voice':'voice','--provider':'provider','--fallback':'fallback','--speed':'speed'};
45
+ while(args.length){const a=args.shift();if(a==='--install-adb')options.install=true;else if(a==='--dry-run')options.dryRun=true;else if(keys[a]){const value=args.shift();if(value===undefined||(!['--text','--test-tts'].includes(a)&&(!value||value.startsWith('--'))))throw Error(`Falta el valor de ${a}`);options[keys[a]]=value}else if(command==='migrate'&&!source&&!a.startsWith('-'))source=a;else throw Error(`Argumento desconocido: ${a}. Usa --help.`)}
46
+ if(options.testText!==undefined){if(command!=='start')throw Error('--test-tts solo se usa sin subcomando o con start.');command='test-tts';options.text=options.testText;delete options.testText;}
47
+ if(!['start','doctor','setup','migrate','youtube-auth','test-tts'].includes(command))throw Error('Comando desconocido. Usa --help.');
48
+ for(const key of ['text','voice','provider','fallback','speed'])if(options[key]!==undefined&&command!=='test-tts')throw Error(`--${key} solo se usa con test-tts.`);
49
+ if(options.clientSecret && command!=='youtube-auth')throw Error('--client-secret solo se usa con youtube-auth.');
50
+ if(command==='youtube-auth'){await new (require('./youtube/auth').Auth)().authorize(options.clientSecret);return;}
36
51
  const dataDir=path.resolve(options.dataDir||dataDirectory());
37
52
  if((options.install||options.dryRun)&&command!=='setup')throw Error('--install-adb y --dry-run solo se usan con setup.');
53
+ if(command==='test-tts'){
54
+ for(const key of ['port','serial','interval','adb','python'])if(options[key]!==undefined)throw Error(`--${key} no se usa con test-tts.`);
55
+ await (voiceTest||require('./tts/voice-command').testVoice)(options.text,{...options,dataDir});return;
56
+ }
38
57
  if(command==='migrate'){
39
58
  if(!source)throw Error('Indica la carpeta anterior: wargaming-overlay migrate /ruta/al/overlay-anterior');
40
59
  const copies=['overlay-state.json','game-state.json'].filter(f=>fs.existsSync(path.join(source,f)));
@@ -75,4 +94,5 @@ function main(){
75
94
  process.env.OVERLAY_DATA_DIR=dataDir;process.env.PORT=String(port);process.env.OVERLAY_ADB=r.adb;process.env.OVERLAY_PYTHON=r.python;process.env.OVERLAY_SERIAL=options.serial||'';process.env.OVERLAY_INTERVAL=String(interval);
76
95
  require('./server');
77
96
  }
78
- try{main()}catch(e){console.error('Error: '+e.message);process.exitCode=1}
97
+ if(require.main===module)main().catch(e=>{console.error('Error: '+e.message);process.exitCode=1;});
98
+ module.exports={main};
@@ -58,24 +58,30 @@ class DeviceConnection:
58
58
  temp.write_text(json.dumps({'identity': self.identity, 'aliases': sorted(self.aliases)}))
59
59
  temp.replace(self.path)
60
60
 
61
- def reconnect(self):
61
+ def reconnect(self, discover_only=False):
62
62
  if not self.identity or self.now() < self.retry_at:
63
63
  return
64
64
  self.retry_at = self.now() + 15
65
- targets = {a for a in self.aliases if ':' in a and not a.startswith('adb-')}
65
+ known = {a for a in self.aliases if ':' in a and not a.startswith('adb-')}
66
+ discovered = set()
66
67
  # Only discover services advertising the remembered device, never arbitrary LAN phones.
67
68
  try:
68
69
  for line in self.run('mdns', 'services').splitlines():
69
70
  fields = line.split()
70
71
  if (len(fields) >= 3 and fields[0].startswith('adb-' + self.identity + '-')
71
72
  and '_adb-tls-connect._tcp' in fields[1]):
72
- targets.add(fields[2])
73
+ discovered.add(fields[2])
73
74
  except (OSError, subprocess.SubprocessError):
74
75
  pass
75
- for target in sorted(targets)[:4]:
76
+ # Current advertisements take priority over obsolete ports stored in history.
77
+ targets = sorted(discovered) + ([] if discover_only else sorted(known - discovered))
78
+ for target in targets[:4]:
76
79
  if re.fullmatch(r'[A-Za-z0-9.\-\[\]:]+:\d+', target):
77
80
  try:
78
- self.run('connect', target)
81
+ result = self.run('connect', target)
82
+ if 'connected to' in result and self.identify(target) == self.identity:
83
+ self.remember([{'serial': target}])
84
+ break
79
85
  except (OSError, subprocess.SubprocessError):
80
86
  pass
81
87
 
@@ -110,4 +116,7 @@ class DeviceConnection:
110
116
  if not self.identity:
111
117
  self.explicit = chosen['serial']
112
118
  self.remember(matching)
119
+ # Establish Wi-Fi while USB is still available, without delaying on stale IPs.
120
+ if chosen['transport'] == 'usb' and not any(d['transport'] == 'wifi' for d in matching):
121
+ self.reconnect(discover_only=True)
113
122
  return chosen
package/game-store.js CHANGED
@@ -3,18 +3,41 @@ const emptyGame=()=>({players:[],connected:false,updatedAt:null,error:null});
3
3
  function mergeGame(previous,incoming){
4
4
  if(!incoming.connected || !Array.isArray(incoming.players) || incoming.players.length!==2)
5
5
  return {...previous,connected:false,phoneConnected:incoming.phoneConnected===true,transport:incoming.transport||null,error:incoming.error||'Sin lectura válida'};
6
- const same=previous.round===incoming.round && previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
6
+ const samePlayers=previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
7
+ const same=samePlayers && previous.round===incoming.round;
8
+ const advance=samePlayers && incoming.round===previous.round+1 && incoming.round>(previous.turn?.highestRound??previous.round);
7
9
  const players=incoming.players.map(p=>{
8
- const old=same?previous.players.find(old=>old.name===p.name):null;
10
+ let prior=samePlayers?previous.players.find(old=>old.name===p.name):null;
11
+ if(prior&&incoming.primarySchemaVersion===1&&previous.primarySchemaVersion!==1)prior={...prior,primaryObjectives:null};
12
+ const old=same?prior:advance?{
13
+ // Primary/secondary totals are cumulative; objective progress belongs to the round.
14
+ cp:prior.cp,primary:prior.primary,secondary:prior.secondary,
15
+ secondaries:prior.secondaries?.filter(m=>m.score===0),
16
+ primaryObjectives:prior.primaryObjectives?.map(m=>({...m,checked:m.checked==null?null:false,counter:m.counter==null?null:0})),
17
+ observedAt:prior.observedAt
18
+ }:null;
9
19
  const merged={...p,observedAt:{}};
10
20
  for(const key of ['cp','primary','secondary','secondaries','primaryObjectives']){
11
21
  if(p[key]!=null)merged.observedAt[key]=incoming.updatedAt;
12
22
  else {merged[key]=old?.[key]??null;merged.observedAt[key]=old?.observedAt?.[key]??null;}
13
23
  }
24
+ // A partial viewport cannot establish that a remembered mission was removed.
25
+ for(const key of ['secondaries','primaryObjectives']){
26
+ if(Array.isArray(p[key]) && p[key].length && Array.isArray(old?.[key]) && !(key==='secondaries' && p.secondariesComplete)){
27
+ const identity=m=>key==='primaryObjectives'?JSON.stringify([m.name,m.details||'']):m.name;
28
+ const byName=new Map(old[key].map(m=>[identity(m),m]));
29
+ for(const m of p[key])byName.set(identity(m),m);
30
+ merged[key]=[...byName.values()];
31
+ }
32
+ }
14
33
  return merged;
15
34
  });
16
- const samePlayers=previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
17
35
  const turn=inferTurn(previous,incoming,samePlayers);
36
+ // Carried cards are known history, not newly drawn cards when first scrolled into view.
37
+ if(advance){
38
+ turn.cards=turn.cards||{};
39
+ for(const p of players)turn.cards[p.name]=[...new Set([...(turn.cards[p.name]||[]),...(p.secondaries||[]).map(m=>m.name)])];
40
+ }
18
41
  return {...incoming,players,turn,half:incoming.round===turn.highestRound?turn.half:null};
19
42
  }
20
43
  module.exports={emptyGame,mergeGame};