@ing.jorgeu/wargaming-overlay 2.5.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.
package/PUBLISHING.md CHANGED
@@ -6,7 +6,7 @@ Desde la raíz del repositorio:
6
6
 
7
7
  ```sh
8
8
  npm ci
9
- npm version 2.5.0 --no-git-tag-version
9
+ npm version 3.0.0 --no-git-tag-version
10
10
  npm test
11
11
  npm pack --dry-run
12
12
  npm login
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) |
@@ -372,15 +372,18 @@ Instala las dependencias con `npm ci` (o `npm install` si actualizas dependencia
372
372
  | `npm start` | Inicia el backend desde este checkout. |
373
373
  | `npm start -- --port 8766 --interval 5` | Pasa opciones al backend; el separador `--` es necesario. |
374
374
  | `npm test` | Ejecuta las pruebas automatizadas; no necesita el canal ni reproduce audio. |
375
- | `npm run test-tts` | Reproduce tres mensajes de prueba por la salida predeterminada de macOS. |
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. |
376
377
  | `npm run facts:coverage` | Informa la cobertura real de curiosidades por facción. |
377
378
  | `npm run facts:coverage -- --require-four-hours` | Devuelve código 1 si alguna facción no llega a 120 curiosidades. |
378
379
  | `npm run prepublishOnly` | Ejecuta las pruebas previas a publicar; npm lo invoca automáticamente en `npm publish`. |
379
380
  | `npm pack --dry-run` | Revisa los archivos que incluirá el paquete sin publicarlo. |
380
381
 
381
- 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@2.5.0`. Usa `wargaming-overlay --version` para comprobar qué instalación ejecuta tu terminal.
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.
382
383
 
383
- ### Activar y desactivar la voz de YouTube (macOS)
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`.
384
387
 
385
388
  ```sh
386
389
  # Desde el repositorio
@@ -393,7 +396,7 @@ YOUTUBE_CHAT_TTS_ENABLED=true wargaming-overlay start
393
396
  YOUTUBE_CHAT_TTS_ENABLED=false wargaming-overlay start
394
397
  ```
395
398
 
396
- 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-macos). `Ctrl+C` detiene lector, chat y voz; los cambios de código/configuración requieren reiniciar.
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.
397
400
 
398
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.
399
402
 
@@ -567,7 +570,7 @@ npm pack
567
570
 
568
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.
569
572
 
570
- 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.
571
574
 
572
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.
573
576
 
@@ -671,9 +674,9 @@ Activa la depuración inalámbrica y empareja el computador con el teléfono en
671
674
 
672
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.
673
676
 
674
- ## YouTube Live → voz local (macOS)
677
+ ## YouTube Live → voz local con Kokoro
675
678
 
676
- 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 sale por el dispositivo predeterminado de macOS; volumen y salida se cambian en macOS. No usa TTS externo ni IA.
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.
677
680
 
678
681
  ### Primera autorización
679
682
 
@@ -698,11 +701,11 @@ YOUTUBE_CHAT_TTS_ENABLED=true npm start
698
701
  npm run test-tts
699
702
  ```
700
703
 
701
- 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. En Windows/Linux el overlay continúa pero esta voz no se inicia.
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.
702
705
 
703
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.
704
707
 
705
- 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 en disco. La terminal muestra mensajes sanitizados y estados YouTube/TTS, sin tokens. Los fallos de OAuth, chat o voz no detienen el overlay.
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.
706
709
 
707
710
  ### Ajustes sencillos
708
711
 
@@ -719,7 +722,7 @@ Opcionalmente crea `youtube-tts.json` dentro de la carpeta de datos que muestra
719
722
  }
720
723
  ```
721
724
 
722
- 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 nuevo de `/usr/bin/say`, sin shell, sin seleccionar dispositivo, y con un tiempo máximo de 120 segundos.
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.
723
726
 
724
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`.
725
728
 
@@ -728,3 +731,145 @@ La moderación es un filtro local pequeño de patrones ES/EN para insultos discr
728
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.
729
732
 
730
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
@@ -48,6 +48,9 @@ def parse(xml):
48
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():
49
49
  details = (None, t, texts[i+1])
50
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
51
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():
52
55
  details = (None, t, texts[i-1])
53
56
  counter_index = i+1
@@ -91,7 +94,7 @@ def parse(xml):
91
94
  players[owner]['secondariesComplete'] = True
92
95
  if players[owner]['secondaries'] is None:
93
96
  players[owner]['secondaries'] = []
94
- return dict(round=rnd, half=None, players=players)
97
+ return dict(round=rnd, half=None, players=players, primarySchemaVersion=1)
95
98
 
96
99
  def merge(previous, fresh):
97
100
  now = time.time()
package/cli.js CHANGED
@@ -8,6 +8,7 @@ const help=`wargaming-overlay ${version} — overlay local para OBS
8
8
 
9
9
  wargaming-overlay start Inicia el overlay y lector
10
10
  wargaming-overlay youtube-auth --client-secret RUTA Autoriza YouTube una vez
11
+ wargaming-overlay --test-tts "Hola" Lee un texto y termina
11
12
  wargaming-overlay doctor Revisa Python, ADB y teléfono
12
13
  wargaming-overlay setup Guía de configuración
13
14
  wargaming-overlay setup --install-adb Instala ADB con el gestor del sistema
@@ -22,22 +23,37 @@ Opciones de start/doctor:
22
23
  --python RUTA Python 3.8 o posterior
23
24
  --data-dir RUTA Carpeta para guardar configuración y partida
24
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
+
25
34
  Por defecto: ~/Library/Application Support/wargaming-overlay en macOS;
26
35
  %LOCALAPPDATA%/wargaming-overlay en Windows; ~/.local/share/wargaming-overlay en Linux. Solo escucha en 127.0.0.1.
27
36
  Ctrl+C detiene el servidor y lector. El último estado queda guardado.
28
37
  `;
29
- async function main(){
30
- const args=process.argv.slice(2);
38
+ async function main(argv=process.argv.slice(2),{voiceTest}={}){
39
+ const args=[...argv];
31
40
  if(args.includes('--help')||args.includes('-h')){console.log(help);return}
32
41
  if(args.includes('--version')){console.log(version);return}
33
- const command=args.shift()||'start',options={};let source;
34
- const keys={'--port':'port','--serial':'serial','--interval':'interval','--adb':'adb','--python':'python','--data-dir':'dataDir','--client-secret':'clientSecret'};
35
- 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.`)}
36
- if(!['start','doctor','setup','migrate','youtube-auth'].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.`);
37
49
  if(options.clientSecret && command!=='youtube-auth')throw Error('--client-secret solo se usa con youtube-auth.');
38
50
  if(command==='youtube-auth'){await new (require('./youtube/auth').Auth)().authorize(options.clientSecret);return;}
39
51
  const dataDir=path.resolve(options.dataDir||dataDirectory());
40
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
+ }
41
57
  if(command==='migrate'){
42
58
  if(!source)throw Error('Indica la carpeta anterior: wargaming-overlay migrate /ruta/al/overlay-anterior');
43
59
  const copies=['overlay-state.json','game-state.json'].filter(f=>fs.existsSync(path.join(source,f)));
@@ -78,4 +94,5 @@ async function main(){
78
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);
79
95
  require('./server');
80
96
  }
81
- 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};
package/game-store.js CHANGED
@@ -7,7 +7,8 @@ function mergeGame(previous,incoming){
7
7
  const same=samePlayers && previous.round===incoming.round;
8
8
  const advance=samePlayers && incoming.round===previous.round+1 && incoming.round>(previous.turn?.highestRound??previous.round);
9
9
  const players=incoming.players.map(p=>{
10
- const prior=samePlayers?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};
11
12
  const old=same?prior:advance?{
12
13
  // Primary/secondary totals are cumulative; objective progress belongs to the round.
13
14
  cp:prior.cp,primary:prior.primary,secondary:prior.secondary,
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@ing.jorgeu/wargaming-overlay",
3
- "version": "2.5.0",
3
+ "version": "3.0.0",
4
4
  "description": "Overlay local para OBS con lectura Android por ADB",
5
5
  "bin": {
6
- "wargaming-overlay": "cli.js"
6
+ "wargaming-overlay": "cli.js",
7
+ "warhammer-overlay": "cli.js"
7
8
  },
8
9
  "scripts": {
9
10
  "start": "node cli.js start",
10
11
  "test": "node --test",
11
12
  "prepublishOnly": "npm test",
12
13
  "facts:coverage": "node scripts/fact-coverage.js",
13
- "test-tts": "node scripts/tts-demo.js"
14
+ "test-tts": "node scripts/tts-demo.js",
15
+ "setup-tts": "node scripts/setup-kokoro.js"
14
16
  },
15
17
  "engines": {
16
- "node": ">=20"
18
+ "node": ">=22"
17
19
  },
18
20
  "files": [
19
21
  "cli.js",
@@ -22,6 +24,7 @@
22
24
  "reader.py",
23
25
  "battle_reader.py",
24
26
  "game-store.js",
27
+ "rosters.js",
25
28
  "reader-status.js",
26
29
  "turn-tracker.js",
27
30
  "public",
@@ -38,7 +41,8 @@
38
41
  "device_connection.py",
39
42
  "youtube",
40
43
  "tts",
41
- "scripts/tts-demo.js"
44
+ "scripts/tts-demo.js",
45
+ "scripts/setup-kokoro.js"
42
46
  ],
43
47
  "license": "0BSD",
44
48
  "publishConfig": {
@@ -57,7 +61,13 @@
57
61
  "cli"
58
62
  ],
59
63
  "dependencies": {
64
+ "@echogarden/espeak-ng-emscripten": "0.3.5",
60
65
  "@grpc/grpc-js": "^1.14.5",
61
- "@grpc/proto-loader": "^0.8.1"
66
+ "@grpc/proto-loader": "^0.8.1",
67
+ "onnxruntime-node": "1.30.0",
68
+ "onnxruntime-web": "1.30.0"
69
+ },
70
+ "optionalDependencies": {
71
+ "node-cpal": "1.0.0"
62
72
  }
63
73
  }
@@ -0,0 +1,18 @@
1
+ *{box-sizing:border-box}body{margin:0;overflow:hidden;background:transparent;color:#f5f1e7;font-family:Arial,sans-serif}#army-stage{--accent:#6aabfa;position:absolute;left:50%;top:50%;width:1920px;height:1080px;transform-origin:center;background:radial-gradient(ellipse at top right,#263348 0,transparent 55%),#10151e;padding:58px 72px;border-top:8px solid var(--accent)}header{height:210px;display:flex;justify-content:space-between;gap:50px;border-bottom:1px solid #566171}.eyebrow{font-size:18px;color:#dfb56a;letter-spacing:.25em;margin:0 0 12px}h1{font-size:62px;line-height:1.05;margin:0 0 12px;max-width:1400px;overflow-wrap:anywhere}#army-faction{font-size:30px;margin:0 0 9px;color:var(--accent)}#army-detachments{font-size:22px;color:#c2cad6;margin:0;max-width:1450px}.army-summary{display:flex;flex-direction:column;text-align:right;justify-content:center;gap:16px;white-space:nowrap;font-size:24px;color:#dfb56a}#army-page{font-size:20px;color:#adb8c7}#army-units{height:724px;padding-top:24px;display:grid;grid-template-columns:1fr 1fr;gap:16px 22px;align-content:stretch}.army-unit{min-width:0;min-height:0;padding:19px 22px;background:#1c2532;border:1px solid #394759;border-left:4px solid var(--accent);border-radius:8px;display:flex;flex-direction:column;justify-content:center;gap:8px}.unit-title{display:flex;gap:14px;align-items:flex-start}.unit-number{color:#8b9aac;font-size:22px;padding-top:4px}.army-unit h2{font-size:30px;line-height:1.15;margin:0;overflow-wrap:anywhere}.unit-detail{font-size:21px;line-height:1.28;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.unit-detail strong{color:#dfb56a;font-weight:normal}.unit-notes{color:#bac5d4;font-size:20px}.unit-badges{display:flex;gap:10px}.unit-badge{font-size:15px;letter-spacing:.08em;text-transform:uppercase;color:#c9e5ff;border:1px solid #6085a7;padding:4px 8px;border-radius:4px}.army-unit.destroyed{opacity:.55;border-left-color:#89919c}.destroyed h2{text-decoration:line-through;text-decoration-thickness:3px}.destroyed .unit-badge{color:#eee;border-color:#858585}footer{position:absolute;bottom:27px;left:72px;right:72px;display:flex;justify-content:space-between;font-size:16px;letter-spacing:.12em;color:#929dad}.army-empty{grid-column:1/-1;align-self:center;text-align:center;font-size:30px;color:#a5b1c2}
2
+ #army-stage{isolation:isolate}#army-background{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-2}#army-stage:has(#army-background[src]):after{content:"";position:absolute;inset:0;background:rgba(8,13,22,.78);z-index:-1}#army-background[hidden]{display:none}#army-units{display:block;height:724px;padding-top:20px;column-count:2;column-gap:32px;column-fill:auto;font-size:28px;overflow:visible}.army-group{margin:0 0 .65em;padding:.5em .65em;background:#142031de;border:1px solid #53607188;border-left:3px solid var(--accent);border-radius:6px;box-decoration-break:clone}.group-name{font-size:1em;line-height:1.12;margin:0 0 .2em;overflow-wrap:anywhere;break-after:avoid}.all-destroyed{text-decoration:line-through;opacity:.55}.army-member{break-inside:avoid;padding:.15em 0}.army-member+.army-member{border-top:1px solid #53607166;margin-top:.18em;padding-top:.25em}.army-member.destroyed{opacity:.5}.army-member.destroyed .member-number{text-decoration:line-through}.unit-detail{font-size:.72em;line-height:1.25;margin:.12em 0;white-space:pre-wrap;overflow-wrap:anywhere}.unit-detail strong{color:#dfb56a}.unit-notes{color:#bec7d3}.member-status{display:flex;align-items:center;gap:.45em;margin-bottom:.12em}.member-number{font-size:.7em;color:#cbd5e4}.unit-badge{font-size:.55em;padding:.14em .4em;letter-spacing:.04em}.army-empty{font-size:1em}
3
+
4
+ /* Fill any OBS source aspect ratio without cropping content or leaving bars. */
5
+ html,body{width:100%;height:100%;background:#10151e}
6
+ #army-stage{display:flex;flex-direction:column}
7
+ #army-stage header{flex:0 0 210px}
8
+ #army-units{flex:1;min-height:0;height:auto}
9
+ #army-stage footer{position:static;flex:0 0 30px;padding-top:12px;margin-top:16px}
10
+
11
+ #army-list-name{max-width:1400px;overflow-wrap:anywhere;letter-spacing:.12em}
12
+
13
+ .unit-badge.battleshocked{color:#ffe1a0;border-color:#d5a145;background:#68471066}
14
+
15
+ .member-title{margin-bottom:.2em}.led-unit{color:#bec7d3;margin-bottom:.3em}.army-member.destroyed .member-title{text-decoration:line-through}
16
+
17
+ /* Keep the heading and every repeated unit together in one column. */
18
+ .army-group{break-inside:avoid;page-break-inside:avoid}
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Lista de ejército · OBS</title><link rel="stylesheet" href="army-list.css"></head><body><main id="army-stage"><img id="army-background" alt="" referrerpolicy="no-referrer"><header><div><p class="eyebrow" id="army-list-name">ORDEN DE BATALLA</p><h1 id="army-name">Lista de ejército</h1><p id="army-faction"></p><p id="army-detachments"></p></div><div class="army-summary"><span id="army-count"></span></div></header><div id="army-units"></div><footer><span>WARHAMMER 40,000</span><span id="army-status">Conectando…</span></footer></main><script src="roster-view.js"></script><script src="army-list.js"></script></body></html>
@@ -0,0 +1,59 @@
1
+ (()=>{
2
+ const $=s=>document.querySelector(s),stage=$('#army-stage'),params=new URLSearchParams(location.search),id=params.get('player');
3
+ let snapshot;
4
+ const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!=null)n.textContent=text;if(cls)n.className=cls;return n;};
5
+ function fit(){
6
+ const root=$('#army-units');
7
+ // Multicolumn flow retains order; reduce typography only as needed to fit all content.
8
+ let best={font:0,columns:2};
9
+ for(const columns of [2,3]){
10
+ root.style.columnCount=columns;
11
+ for(let font=28;font>=4;font--){root.style.fontSize=font+'px';if(root.scrollWidth<=root.clientWidth+1&&root.scrollHeight<=root.clientHeight+1&&[...root.querySelectorAll('.army-group')].every(group=>group.getClientRects().length===1)){if(font>best.font)best={font,columns};break;}}
12
+ }
13
+ root.style.columnCount=best.columns;root.style.fontSize=(best.font||4)+'px';
14
+ }
15
+ function render(){
16
+ if(!snapshot)return;
17
+ const requestedSide=params.get('side')||'blue',resolvedSide=({blue:'left',red:'right'})[requestedSide]||requestedSide;
18
+ const entry=Object.entries(snapshot.players).find(([side,p])=>p.rosterId===id||(!id&&side===resolvedSide));
19
+ const root=$('#army-units');root.replaceChildren();
20
+ if(!entry){$('#army-name').textContent='Jugador no encontrado';$('#army-status').textContent='Copia la URL desde el administrador.';return;}
21
+ const [side,p]=entry,roster=snapshot.rosters[p.rosterId]||{},units=roster.units||[];
22
+ $('#army-list-name').textContent=roster.name||'ORDEN DE BATALLA';
23
+ const background=$('#army-background');
24
+ if(roster.backgroundUrl){if(background.getAttribute('src')!==roster.backgroundUrl)background.src=roster.backgroundUrl;background.hidden=false;}else{background.removeAttribute('src');background.hidden=true;}
25
+ stage.style.setProperty('--accent',side==='left'?'#6aabfa':'#ed8c8a');
26
+ $('#army-name').textContent=p.name;$('#army-name').style.fontSize=p.name.length>45?'36px':p.name.length>28?'48px':'62px';$('#army-faction').textContent=p.faction;$('#army-detachments').textContent=(p.detachments||[]).join(' · ');
27
+ $('#army-count').textContent=`${units.filter(u=>!u.destroyed).length} / ${units.length} unidades`;
28
+ for(const group of RosterView.groupUnits(units)){
29
+ const block=el('section',null,'army-group'),heading=el('h2',group.name+(group.units.length>1?' ×'+group.units.length:''),'group-name');
30
+ const hasLeaders=group.units.some(u=>u.characters.length);
31
+ if(group.units.every(u=>u.destroyed))heading.classList.add('all-destroyed');if(!hasLeaders)block.append(heading);
32
+ group.units.forEach((u,i)=>{
33
+ const row=el('div',null,'army-member'+(u.destroyed?' destroyed':''));
34
+ if(hasLeaders){
35
+ row.append(el('h2',u.characters.length?u.characters.join(' · '):u.name,'group-name member-title'));
36
+ if(u.characters.length)row.append(el('p','Lidera: '+u.name,'unit-detail led-unit'));
37
+ }
38
+ const label=el('div',null,'member-status');if(group.units.length>1)label.append(el('span',`Unidad ${i+1}`,'member-number'));
39
+ if(u.destroyed)label.append(el('span','Destruida','unit-badge'));else if(u.deepStrike)label.append(el('span','En reservas','unit-badge'));
40
+ if(u.battleshocked&&!u.destroyed)label.append(el('span','Battleshocked','unit-badge battleshocked'));
41
+ if(label.childNodes.length)row.append(label);
42
+ const detail=(name,text)=>{if(!text)return;const line=el('p',null,'unit-detail');line.append(el('strong',name+' '),document.createTextNode(text));row.append(line);};
43
+ detail('Mejora:',u.enhancement);if(u.notes)row.append(el('p',u.notes,'unit-detail unit-notes'));
44
+ if(row.childNodes.length)block.append(row);
45
+ });root.append(block);
46
+ }
47
+ if(!units.length)root.append(el('p','Lista pendiente de cargar','army-empty'));
48
+ fit();
49
+ }
50
+ const stream=new EventSource('/rosters/events');stream.onmessage=e=>{snapshot=JSON.parse(e.data);$('#army-status').textContent='';render();};stream.onerror=()=>{$('#army-status').textContent='Reconectando · última lista guardada';};
51
+ $('#army-background').onerror=()=>{$('#army-background').hidden=true;$('#army-status').textContent='No se pudo cargar la imagen de fondo';};
52
+ function resize(){
53
+ const scale=Math.min(innerWidth/1920,innerHeight/1080);
54
+ stage.style.width=(innerWidth/scale)+'px';stage.style.height=(innerHeight/scale)+'px';
55
+ stage.style.transform=`translate(-50%,-50%) scale(${scale})`;
56
+ if(snapshot)fit();
57
+ }
58
+ addEventListener('resize',resize);resize();
59
+ })();
@@ -1,6 +1,6 @@
1
1
  <!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Battle Report Control</title><link rel="stylesheet" href="style.css"></head><body class="control">
2
2
  <main><header><p class="eyebrow">BATTLE REPORT</p><h1>Control de transmisión</h1><p>Los cambios se reflejan al instante en OBS.</p></header>
3
- <form id="form"><label>Rótulo del encuentro<input id="title" maxlength="40"></label><section id="players"></section><p>Top / Bottom se detecta automáticamente a partir de la puntuación registrada en Tabletop Battles.</p><div class="announcement-actions"><button type="submit" disabled>Actualizar overlay</button><button type="button" id="swap-players" class="secondary-button" disabled>Intercambiar jugadores ⇄</button></div><p>Intercambiar jugadores guarda los cambios del formulario y mueve nombres, facciones, destacamentos y marcadores al lado contrario.</p><p id="saved" aria-live="polite"></p></form><section><h2>Datos de la partida</h2><p id="phone-status" aria-live="polite"></p><div class="turn-indicator" aria-live="polite"><strong id="turn-status">RONDA —</strong><p id="turn-order">Esperando la lectura de Tabletop Battles.</p><p id="turn-reason"></p></div><button type="button" id="reset-game">Restablecer datos de la partida</button><p>Vacía los puntos y misiones guardados. Conserva nombres, facciones y diseño. Si el teléfono está conectado, la próxima lectura los volverá a completar.</p><p id="reset-status" aria-live="polite"></p></section><section class="announcement-panel" aria-labelledby="announcement-heading">
3
+ <nav class="admin-tabs" aria-label="Secciones"><button type="button" id="tab-match" aria-pressed="true">Partida</button><button type="button" id="tab-rosters" class="secondary-button" aria-pressed="false">Listas de ejército</button></nav><div id="match-panel"><form id="form"><label>Rótulo del encuentro<input id="title" maxlength="40"></label><section id="players"></section><p>Top / Bottom se detecta automáticamente al sacar nuevas secundarias en Tabletop Battles.</p><div class="announcement-actions"><button type="submit" disabled>Actualizar overlay</button><button type="button" id="swap-players" class="secondary-button" disabled>Intercambiar jugadores ⇄</button></div><p>Intercambiar jugadores guarda los cambios del formulario y mueve nombres, facciones, destacamentos y marcadores al lado contrario.</p><p id="saved" aria-live="polite"></p></form><section><h2>Datos de la partida</h2><p id="phone-status" aria-live="polite"></p><div class="turn-indicator" aria-live="polite"><strong id="turn-status">RONDA —</strong><p id="turn-order">Esperando la lectura de Tabletop Battles.</p><p id="turn-reason"></p></div><button type="button" id="reset-game">Restablecer datos de la partida</button><p>Vacía los puntos y misiones guardados. Conserva nombres, facciones y diseño. Si el teléfono está conectado, la próxima lectura los volverá a completar.</p><p id="reset-status" aria-live="polite"></p></section><section class="announcement-panel" aria-labelledby="announcement-heading">
4
4
  <h2 id="announcement-heading">Mensajes en pantalla</h2>
5
5
  <p>Se muestran abajo durante 15 segundos. Primero guarda las facciones y disposiciones del encuentro.</p>
6
6
  <form id="message-form"><label for="toast-message">Mensaje para la transmisión</label><textarea id="toast-message" rows="3" maxlength="280" placeholder="¡Bienvenidos! La próxima ronda comienza en unos minutos." required></textarea><div class="announcement-actions"><span id="message-count">0 / 280</span><button type="submit">Mostrar mensaje · 15 s</button></div></form>
@@ -9,4 +9,4 @@
9
9
  <p id="rotation-status" aria-live="polite"></p><p id="toast-feedback" role="status"></p>
10
10
  <div class="announcement-preview"><span class="eyebrow">ÚLTIMO MENSAJE</span><p id="last-toast">Todavía no has mostrado un mensaje.</p><a id="toast-source" target="_blank" rel="noopener noreferrer" hidden>Consultar fuente</a></div>
11
11
  <p class="announcement-help">El orden es aleatorio y cada dato aparece una sola vez por ciclo. Al agotarse, comienza otro ciclo automáticamente. El historial se conserva al recargar o reiniciar. «Nueva sesión» lo reinicia para otra transmisión. Los mensajes manuales reemplazan el aviso actual.</p>
12
- </section><aside><strong>En OBS:</strong> añade una fuente “Navegador” con la URL <code id="obs-url"></code>. Resolución de la fuente: <select id="resolution-preset"><option value="1280x720">720p · 1280 × 720</option><option value="1600x900">900p · 1600 × 900</option><option value="1664x936">936p · 1664 × 936</option><option value="1920x1080">1080p · 1920 × 1080</option><option value="2560x1440" selected>1440p · 2560 × 1440</option><option value="3840x2160">4K · 3840 × 2160</option></select><p id="resolution-help"></p></aside></main><script src="match-view.js"></script><script src="control.js"></script><script src="announcements-control.js"></script></body></html>
12
+ </section><aside><strong>En OBS:</strong> añade una fuente “Navegador” con la URL <code id="obs-url"></code>. Resolución de la fuente: <select id="resolution-preset"><option value="1280x720">720p · 1280 × 720</option><option value="1600x900">900p · 1600 × 900</option><option value="1664x936">936p · 1664 × 936</option><option value="1920x1080">1080p · 1920 × 1080</option><option value="2560x1440" selected>1440p · 2560 × 1440</option><option value="3840x2160">4K · 3840 × 2160</option></select><p id="resolution-help"></p></aside></div><div id="rosters-panel" hidden><h2>Listas de ejército</h2><p>Añade unidades o importa un archivo JSON. Los nombres, facciones y destacamentos se toman de la pestaña Partida. Los cambios se guardan automáticamente y se muestran en OBS.</p><div id="roster-editors"></div><p id="roster-feedback" role="status"></p></div></main><script src="match-view.js"></script><script src="control.js"></script><script src="announcements-control.js"></script><script src="rosters-control.js"></script></body></html>
package/public/control.js CHANGED
@@ -24,7 +24,8 @@ async function saveLayout(swap=false){
24
24
  const next=swap?MatchView.swapPlayers(form):form;
25
25
  const response=await fetch('/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});
26
26
  if(!response.ok)throw Error('No se pudo guardar');
27
- state=next;renderPlayers();
27
+ state=await fetch('/state').then(r=>r.json());renderPlayers();
28
+ window.dispatchEvent(new Event('layout-saved'));
28
29
  document.querySelector('#saved').textContent=swap?'Jugadores intercambiados. Overlay actualizado.':'Overlay actualizado.';
29
30
  }catch{
30
31
  document.querySelector('#saved').textContent='No se pudo confirmar el cambio. Comprueba la conexión con el servidor y recarga el panel antes de intentarlo de nuevo.';
@@ -1,5 +1,5 @@
1
- .detachment-list { position: absolute; top: 181px; display: flex; flex-wrap: nowrap; gap: 12px; width: max-content; }
1
+ .detachment-list { position: absolute; top: 181px; display: flex; flex-wrap: wrap; gap: 12px; width: calc(50% - 740px); }
2
2
  .detachment-list.left { left: 382px; }
3
3
  .detachment-list.right { right: 382px; justify-content: flex-end; }
4
4
  .detachment-list:empty { display: none; }
5
- .detachment { padding: 7px 12px; border: 1px solid rgba(232, 228, 216, .35); border-radius: 5px; background: rgba(7, 10, 14, .78); box-shadow: 0 3px 8px #0008; color: #f1ecdc; font-size: 20px; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; }
5
+ .detachment { padding: 7px 12px; border: 1px solid rgba(232, 228, 216, .35); border-radius: 5px; background: rgba(7, 10, 14, .78); box-shadow: 0 3px 8px #0008; color: #f1ecdc; font-size: 20px; letter-spacing: .05em; text-transform: uppercase; white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
@@ -0,0 +1,8 @@
1
+ (function(root){
2
+ function groupUnits(units){
3
+ const groups=[];
4
+ for(const unit of units){const key=unit.name.normalize('NFKC').trim().replace(/\s+/g,' ').toLocaleLowerCase();const last=groups.at(-1);if(last?.key===key)last.units.push(unit);else groups.push({key,name:unit.name,units:[unit]});}
5
+ return groups;
6
+ }
7
+ const api={groupUnits};if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.RosterView=api;
8
+ })(globalThis);