@ing.jorgeu/wargaming-overlay 2.3.0 → 2.5.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
@@ -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 2.5.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
@@ -354,13 +354,49 @@ 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
+ | `npm run test-tts` | Reproduce tres mensajes de prueba por la salida predeterminada de macOS. |
376
+ | `npm run facts:coverage` | Informa la cobertura real de curiosidades por facción. |
377
+ | `npm run facts:coverage -- --require-four-hours` | Devuelve código 1 si alguna facción no llega a 120 curiosidades. |
378
+ | `npm run prepublishOnly` | Ejecuta las pruebas previas a publicar; npm lo invoca automáticamente en `npm publish`. |
379
+ | `npm pack --dry-run` | Revisa los archivos que incluirá el paquete sin publicarlo. |
380
+
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
+
383
+ ### Activar y desactivar la voz de YouTube (macOS)
384
+
385
+ ```sh
386
+ # Desde el repositorio
387
+ YOUTUBE_CHAT_TTS_ENABLED=true npm start
388
+
389
+ # Con el paquete instalado globalmente
390
+ YOUTUBE_CHAT_TTS_ENABLED=true wargaming-overlay start
391
+
392
+ # Desactivar explícitamente
393
+ YOUTUBE_CHAT_TTS_ENABLED=false wargaming-overlay start
394
+ ```
395
+
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.
397
+
398
+ 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
+
364
400
  ### Opciones
365
401
 
366
402
  | Opción | Valor predeterminado | Uso |
@@ -371,6 +407,7 @@ Con otra relación de aspecto mantiene 16:9, centrado horizontalmente y alineado
371
407
  | `--adb` | Autodetección | Ruta al ejecutable ADB para `start`, `doctor` o `setup`. |
372
408
  | `--python` | Autodetección | Ruta al ejecutable Python para `start`, `doctor` o `setup`. |
373
409
  | `--data-dir` | Según el sistema | Carpeta de datos para `start` y destino de `migrate`. |
410
+ | `--client-secret` | Cliente guardado, si existe | Ruta al JSON OAuth de escritorio; solo para `youtube-auth`. |
374
411
 
375
412
  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
413
 
@@ -461,7 +498,7 @@ El botón **Restablecer datos de la partida** vacía los puntos y misiones guard
461
498
 
462
499
  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
500
 
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.
501
+ 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
502
 
466
503
  ## Solución de problemas
467
504
 
@@ -625,3 +662,69 @@ npm run facts:coverage -- --require-four-hours
625
662
  ```
626
663
 
627
664
  Al ampliar `data/faction-facts.json`, usa el nombre exacto del registro, conserva los IDs existentes para respetar el historial y asigna un ID estable a cada nueva curiosidad. Incluye un enlace específico, título de fuente y fecha de revisión. No añadas reformulaciones del mismo dato para aumentar artificialmente la duración.
665
+
666
+ ### Continuidad entre USB y Wi-Fi
667
+
668
+ El lector recuerda el teléfono por su identificador físico en `phone-connection.json`, dentro de la carpeta de datos. Si ADB presenta USB y Wi-Fi para ese mismo teléfono, el overlay prefiere USB. Al retirar el cable, utiliza la conexión inalámbrica autorizada; al reconectarlo, vuelve a USB. Nunca elige otro teléfono por compartir el modelo o una dirección IP anterior.
669
+
670
+ 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.
671
+
672
+ 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
+
674
+ ## YouTube Live → voz local (macOS)
675
+
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.
677
+
678
+ ### Primera autorización
679
+
680
+ 1. Crea un proyecto en [Google Cloud](https://console.cloud.google.com/) y habilita **YouTube Data API v3**.
681
+ 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.
682
+ 3. Desde este repositorio, ejecuta:
683
+
684
+ ```sh
685
+ node cli.js youtube-auth --client-secret "$HOME/Downloads/client_secret.json"
686
+ ```
687
+
688
+ 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.
689
+
690
+ 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.
691
+
692
+ **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.
693
+
694
+ ### Arranque y prueba
695
+
696
+ ```sh
697
+ YOUTUBE_CHAT_TTS_ENABLED=true npm start
698
+ npm run test-tts
699
+ ```
700
+
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.
702
+
703
+ 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
+
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.
706
+
707
+ ### Ajustes sencillos
708
+
709
+ Opcionalmente crea `youtube-tts.json` dentro de la carpeta de datos que muestra el backend al iniciar:
710
+
711
+ ```json
712
+ {
713
+ "enabled": true,
714
+ "maxMessageLength": 300,
715
+ "maxQueueLength": 10,
716
+ "readUsername": true,
717
+ "filterUrls": true,
718
+ "extraBlockedTerms": []
719
+ }
720
+ ```
721
+
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.
723
+
724
+ 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
+
726
+ ### Verificación
727
+
728
+ `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
+
730
+ 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).
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,21 +42,55 @@ 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>0 and i+1<len(texts) and re.match(r'\d+VP',texts[i-1]) and texts[i+1].isdigit():
52
+ details = (None, t, texts[i-1])
53
+ counter_index = i+1
41
54
  if details:
42
55
  if current['primaryObjectives'] is None: current['primaryObjectives'] = []
43
56
  checkboxes = [n for n in nodes[i].iter('node') if n.get('checkable') == 'true']
44
57
  checked = checkboxes[0].get('checked') == 'true' if len(checkboxes) == 1 else None
45
58
  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})
59
+ if counter_index is not None:
60
+ counter = int(texts[counter_index])
61
+ current['primaryObjectives'].append({'name': details[1], 'details': details[2], 'checked': checked, 'counter': counter, 'enabled': enabled(nodes[i])})
50
62
  m = re.fullmatch(r'(.+)\n(\d+)\n/(\d+)(?:\n\+(\d+)pts)?', t)
51
63
  if m:
52
64
  if current['secondaries'] is None: current['secondaries'] = []
53
- current['secondaries'].append(dict(name=m[1], score=int(m[2]), max=int(m[3])))
65
+ current['secondaries'].append(dict(name=m[1], score=int(m[2]), max=int(m[3]), enabled=enabled(nodes[i])))
66
+ # Only compare card membership when the entire secondary section is visible.
67
+ # A following player heading bounds it; for the final player require blank
68
+ # space below the last row and above the fixed round-navigation button.
69
+ def bounds(node):
70
+ v = re.fullmatch(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', node.get('bounds', ''))
71
+ return tuple(map(int, v.groups())) if v else None
72
+ for player in players:
73
+ player['secondariesComplete'] = False
74
+ owner = None
75
+ for i, text in enumerate(texts):
76
+ if text in names:
77
+ owner = names.index(text)
78
+ if owner is None or not re.fullmatch(r'SECONDARY:\s*\d+/\d+', text):
79
+ continue
80
+ 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)
81
+ if end is None:
82
+ continue
83
+ section = texts[i+1:end]
84
+ if 'Select missions' not in section or 'Discard missions' not in section:
85
+ continue
86
+ complete = texts[end] in names
87
+ if not complete and owner == 1 and end > i+1:
88
+ last, tab = bounds(nodes[end-1]), bounds(nodes[end])
89
+ complete = bool(last and tab and tab[1]-last[3] >= max(100, last[3]-last[1]))
90
+ if complete:
91
+ players[owner]['secondariesComplete'] = True
92
+ if players[owner]['secondaries'] is None:
93
+ players[owner]['secondaries'] = []
54
94
  return dict(round=rnd, half=None, players=players)
55
95
 
56
96
  def merge(previous, fresh):
package/cli.js CHANGED
@@ -7,6 +7,7 @@ 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
10
11
  wargaming-overlay doctor Revisa Python, ADB y teléfono
11
12
  wargaming-overlay setup Guía de configuración
12
13
  wargaming-overlay setup --install-adb Instala ADB con el gestor del sistema
@@ -25,14 +26,16 @@ Por defecto: ~/Library/Application Support/wargaming-overlay en macOS;
25
26
  %LOCALAPPDATA%/wargaming-overlay en Windows; ~/.local/share/wargaming-overlay en Linux. Solo escucha en 127.0.0.1.
26
27
  Ctrl+C detiene el servidor y lector. El último estado queda guardado.
27
28
  `;
28
- function main(){
29
+ async function main(){
29
30
  const args=process.argv.slice(2);
30
31
  if(args.includes('--help')||args.includes('-h')){console.log(help);return}
31
32
  if(args.includes('--version')){console.log(version);return}
32
33
  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
+ const keys={'--port':'port','--serial':'serial','--interval':'interval','--adb':'adb','--python':'python','--data-dir':'dataDir','--client-secret':'clientSecret'};
34
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.`)}
35
- if(!['start','doctor','setup','migrate'].includes(command))throw Error('Comando desconocido. Usa --help.');
36
+ if(!['start','doctor','setup','migrate','youtube-auth'].includes(command))throw Error('Comando desconocido. Usa --help.');
37
+ if(options.clientSecret && command!=='youtube-auth')throw Error('--client-secret solo se usa con youtube-auth.');
38
+ if(command==='youtube-auth'){await new (require('./youtube/auth').Auth)().authorize(options.clientSecret);return;}
36
39
  const dataDir=path.resolve(options.dataDir||dataDirectory());
37
40
  if((options.install||options.dryRun)&&command!=='setup')throw Error('--install-adb y --dry-run solo se usan con setup.');
38
41
  if(command==='migrate'){
@@ -75,4 +78,4 @@ function main(){
75
78
  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
79
  require('./server');
77
80
  }
78
- try{main()}catch(e){console.error('Error: '+e.message);process.exitCode=1}
81
+ main().catch(e=>{console.error('Error: '+e.message);process.exitCode=1;});
@@ -0,0 +1,122 @@
1
+ """Track one physical Android device across authorized ADB transports."""
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+
10
+ def run_adb(*args):
11
+ return subprocess.run([os.environ.get('OVERLAY_ADB', 'adb'), *args],
12
+ capture_output=True, text=True, encoding='utf-8',
13
+ errors='replace', check=True, timeout=5).stdout
14
+
15
+
16
+ def devices(output):
17
+ rows = []
18
+ for line in output.splitlines():
19
+ fields = line.split()
20
+ if len(fields) >= 2 and not line.startswith(('List of', '*')):
21
+ serial = fields[0]
22
+ wireless = ':' in serial or '._adb-tls-connect._tcp' in serial
23
+ rows.append({'serial': serial, 'state': fields[1],
24
+ 'transport': 'wifi' if wireless else 'usb'})
25
+ return rows
26
+
27
+
28
+ class DeviceConnection:
29
+ def __init__(self, serial='', path=None, run=run_adb, now=time.monotonic):
30
+ self.run, self.now, self.explicit = run, now, serial
31
+ self.path = Path(path) if path else None
32
+ self.identity, self.aliases = None, set()
33
+ self.retry_at = 0
34
+ if self.path:
35
+ try:
36
+ saved = json.loads(self.path.read_text())
37
+ if not serial or serial in saved.get('aliases', []):
38
+ self.identity = saved.get('identity')
39
+ self.aliases = set(saved.get('aliases', []))
40
+ except (OSError, ValueError, TypeError):
41
+ pass
42
+
43
+ def identify(self, serial):
44
+ for prop in ('ro.serialno', 'ro.boot.serialno'):
45
+ try:
46
+ value = self.run('-s', serial, 'shell', 'getprop', prop).strip()
47
+ if value and value.lower() not in ('unknown', 'null'):
48
+ return value
49
+ except (OSError, subprocess.SubprocessError):
50
+ pass
51
+ return None
52
+
53
+ def remember(self, matching):
54
+ self.aliases.update(d['serial'] for d in matching)
55
+ if self.path:
56
+ self.path.parent.mkdir(parents=True, exist_ok=True)
57
+ temp = self.path.with_suffix('.tmp')
58
+ temp.write_text(json.dumps({'identity': self.identity, 'aliases': sorted(self.aliases)}))
59
+ temp.replace(self.path)
60
+
61
+ def reconnect(self, discover_only=False):
62
+ if not self.identity or self.now() < self.retry_at:
63
+ return
64
+ self.retry_at = self.now() + 15
65
+ known = {a for a in self.aliases if ':' in a and not a.startswith('adb-')}
66
+ discovered = set()
67
+ # Only discover services advertising the remembered device, never arbitrary LAN phones.
68
+ try:
69
+ for line in self.run('mdns', 'services').splitlines():
70
+ fields = line.split()
71
+ if (len(fields) >= 3 and fields[0].startswith('adb-' + self.identity + '-')
72
+ and '_adb-tls-connect._tcp' in fields[1]):
73
+ discovered.add(fields[2])
74
+ except (OSError, subprocess.SubprocessError):
75
+ pass
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]:
79
+ if re.fullmatch(r'[A-Za-z0-9.\-\[\]:]+:\d+', target):
80
+ try:
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
85
+ except (OSError, subprocess.SubprocessError):
86
+ pass
87
+
88
+ def select(self):
89
+ rows = devices(self.run('devices', '-l'))
90
+ ready = [d for d in rows if d['state'] == 'device']
91
+ # Verify the physical identity even for remembered IP addresses: DHCP can reuse them.
92
+ for d in ready:
93
+ d['identity'] = self.identify(d['serial'])
94
+ if self.identity:
95
+ matching = [d for d in ready if d['identity'] == self.identity]
96
+ elif self.explicit:
97
+ matching = [d for d in ready if d['serial'] == self.explicit]
98
+ if matching:
99
+ self.identity = matching[0]['identity']
100
+ if self.identity:
101
+ matching = [d for d in ready if d['identity'] == self.identity]
102
+ else:
103
+ identities = {d['identity'] or d['serial'] for d in ready}
104
+ if len(identities) > 1:
105
+ raise ValueError('Hay varios teléfonos diferentes. Selecciona uno con --serial SERIAL.')
106
+ matching = ready
107
+ if matching:
108
+ self.identity = matching[0]['identity']
109
+ if not matching:
110
+ self.reconnect()
111
+ if any(d['state'] == 'unauthorized' for d in rows):
112
+ raise ValueError('Autoriza este computador en el teléfono; para Wi-Fi completa el emparejamiento de depuración inalámbrica.')
113
+ raise ValueError('Reconectando al teléfono seleccionado por USB o Wi-Fi. Comprueba la conexión y la depuración inalámbrica.')
114
+ # If identity is unavailable, stick to the chosen transport, never guess another phone.
115
+ chosen = sorted(matching, key=lambda d: d['transport'] != 'usb')[0]
116
+ if not self.identity:
117
+ self.explicit = chosen['serial']
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)
122
+ return chosen
package/diagnostics.js CHANGED
@@ -33,6 +33,12 @@ function check(options={}){
33
33
  if(py.status!==0)return {ok:false,code:'PYTHON_VERSION',message:'Se necesita Python 3.8 o posterior. Usa --python /ruta/a/python3.',adb,python};
34
34
  const result=spawnSync(adb,['devices','-l'],{encoding:'utf8',timeout:15000});
35
35
  if(result.error||result.status!==0)return {ok:false,code:'ADB_ERROR',message:'ADB no pudo consultar el teléfono: '+(result.error?.message||(result.stderr||'').trim()).slice(0,600),adb,python};
36
- return {...deviceStatus(result.stdout,options.serial),adb,python};
36
+ let status=deviceStatus(result.stdout,options.serial);
37
+ // USB and wireless transports can be the same physical phone.
38
+ if(status.code==='MULTIPLE_DEVICES'){
39
+ const probe=spawnSync(python,['-c', 'import json; from device_connection import DeviceConnection; print(json.dumps(DeviceConnection().select()))'],{cwd:__dirname,env:{...process.env,OVERLAY_ADB:adb},encoding:'utf8',timeout:20000,windowsHide:true});
40
+ if(probe.status===0){try{const d=JSON.parse(probe.stdout);status={ok:true,code:'READY',message:'Teléfono conectado por '+(d.transport==='wifi'?'Wi-Fi.':'USB.'),serial:d.serial};}catch{}}
41
+ }
42
+ return {...status,adb,python};
37
43
  }
38
44
  module.exports={locate,deviceStatus,check,findPython};
package/game-store.js CHANGED
@@ -2,19 +2,41 @@ const {inferTurn}=require('./turn-tracker');
2
2
  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
- return {...previous,connected:false,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));
5
+ return {...previous,connected:false,phoneConnected:incoming.phoneConnected===true,transport:incoming.transport||null,error:incoming.error||'Sin lectura válida'};
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
+ const prior=samePlayers?previous.players.find(old=>old.name===p.name):null;
11
+ const old=same?prior:advance?{
12
+ // Primary/secondary totals are cumulative; objective progress belongs to the round.
13
+ cp:prior.cp,primary:prior.primary,secondary:prior.secondary,
14
+ secondaries:prior.secondaries?.filter(m=>m.score===0),
15
+ primaryObjectives:prior.primaryObjectives?.map(m=>({...m,checked:m.checked==null?null:false,counter:m.counter==null?null:0})),
16
+ observedAt:prior.observedAt
17
+ }:null;
9
18
  const merged={...p,observedAt:{}};
10
19
  for(const key of ['cp','primary','secondary','secondaries','primaryObjectives']){
11
20
  if(p[key]!=null)merged.observedAt[key]=incoming.updatedAt;
12
21
  else {merged[key]=old?.[key]??null;merged.observedAt[key]=old?.observedAt?.[key]??null;}
13
22
  }
23
+ // A partial viewport cannot establish that a remembered mission was removed.
24
+ for(const key of ['secondaries','primaryObjectives']){
25
+ if(Array.isArray(p[key]) && p[key].length && Array.isArray(old?.[key]) && !(key==='secondaries' && p.secondariesComplete)){
26
+ const identity=m=>key==='primaryObjectives'?JSON.stringify([m.name,m.details||'']):m.name;
27
+ const byName=new Map(old[key].map(m=>[identity(m),m]));
28
+ for(const m of p[key])byName.set(identity(m),m);
29
+ merged[key]=[...byName.values()];
30
+ }
31
+ }
14
32
  return merged;
15
33
  });
16
- const samePlayers=previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
17
34
  const turn=inferTurn(previous,incoming,samePlayers);
35
+ // Carried cards are known history, not newly drawn cards when first scrolled into view.
36
+ if(advance){
37
+ turn.cards=turn.cards||{};
38
+ for(const p of players)turn.cards[p.name]=[...new Set([...(turn.cards[p.name]||[]),...(p.secondaries||[]).map(m=>m.name)])];
39
+ }
18
40
  return {...incoming,players,turn,half:incoming.round===turn.highestRound?turn.half:null};
19
41
  }
20
42
  module.exports={emptyGame,mergeGame};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ing.jorgeu/wargaming-overlay",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Overlay local para OBS con lectura Android por ADB",
5
5
  "bin": {
6
6
  "wargaming-overlay": "cli.js"
@@ -9,7 +9,8 @@
9
9
  "start": "node cli.js start",
10
10
  "test": "node --test",
11
11
  "prepublishOnly": "npm test",
12
- "facts:coverage": "node scripts/fact-coverage.js"
12
+ "facts:coverage": "node scripts/fact-coverage.js",
13
+ "test-tts": "node scripts/tts-demo.js"
13
14
  },
14
15
  "engines": {
15
16
  "node": ">=20"
@@ -21,6 +22,7 @@
21
22
  "reader.py",
22
23
  "battle_reader.py",
23
24
  "game-store.js",
25
+ "reader-status.js",
24
26
  "turn-tracker.js",
25
27
  "public",
26
28
  "README.md",
@@ -32,7 +34,11 @@
32
34
  "announcements.js",
33
35
  "data/faction-facts.json",
34
36
  "factions.js",
35
- "scripts/fact-coverage.js"
37
+ "scripts/fact-coverage.js",
38
+ "device_connection.py",
39
+ "youtube",
40
+ "tts",
41
+ "scripts/tts-demo.js"
36
42
  ],
37
43
  "license": "0BSD",
38
44
  "publishConfig": {
@@ -49,5 +55,9 @@
49
55
  "wargaming",
50
56
  "adb",
51
57
  "cli"
52
- ]
58
+ ],
59
+ "dependencies": {
60
+ "@grpc/grpc-js": "^1.14.5",
61
+ "@grpc/proto-loader": "^0.8.1"
62
+ }
53
63
  }
@@ -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><p id="turn-status" aria-live="polite"></p><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
+ <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">
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>
@@ -8,5 +8,5 @@
8
8
  <div class="announcement-actions"><button type="button" id="next-fact">Mostrar siguiente dato</button><button type="button" class="secondary-button" id="new-toast-session">Nueva sesión de mensajes</button></div>
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
- <p class="announcement-help">El orden es aleatorio y cada dato aparece una sola vez por sesión. Al agotarse, la rotación espera sin repetir. 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>
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
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>
package/public/control.js CHANGED
@@ -47,7 +47,7 @@ document.querySelector('#reset-game').addEventListener('click',async()=>{
47
47
  catch {document.querySelector('#reset-status').textContent='No se pudo restablecer. Comprueba que el servidor esté actualizado y funcionando.';}
48
48
  finally{button.disabled=false}
49
49
  });
50
- async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();document.querySelector('#turn-status').textContent=(g.half==='top'?'TOP · Primer turno. ':g.half==='bottom'?'BOTTOM · Segundo turno. ':'Turno automático: ')+(g.turn?.reason||'Esperando actividad de puntuación.');document.querySelector('#phone-status').textContent=g.connected?'Teléfono conectado.':(g.error||'Sin conexión.')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
50
+ async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();const turn=MatchView.turnStatus(g,state);document.querySelector('#turn-status').textContent=turn.label;document.querySelector('#turn-order').textContent=turn.order;document.querySelector('#turn-reason').textContent=turn.reason;document.querySelector('#phone-status').textContent=g.connected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}.`:(g.phoneConnected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}. `:'')+(g.error||'Reconectando…')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
51
51
 
52
52
  document.querySelector('#obs-url').textContent=location.origin+'/overlay-art.html';
53
53
 
@@ -1,7 +1,7 @@
1
- .live-score{color:#fffaf0;text-shadow:0 2px 4px #000,0 0 8px #000;pointer-events:none}.live-vp{position:absolute;transform:translateY(-50%);font-size:40px;white-space:nowrap;font-variant-numeric:tabular-nums}.live-cp{position:absolute;transform:translateX(-50%);font-size:34px;white-space:nowrap}.live-round{position:absolute;top:145px;left:50%;transform:translateX(-50%);font-size:28px;white-space:nowrap}.live-missions{position:absolute;top:248px;width:350px;font-size:28px;line-height:1.55;text-transform:none}.live-player.left .live-missions{left:24px}.live-player.right .live-missions{right:24px;text-align:right}.live-mission{overflow-wrap:anywhere}.live-note{display:none}
1
+ .live-score{color:#fffaf0;text-shadow:0 2px 4px #000,0 0 8px #000;pointer-events:none}.live-vp{position:absolute;transform:translateY(-50%);font-size:40px;white-space:nowrap;font-variant-numeric:tabular-nums}.live-cp{position:absolute;transform:translateX(-50%);font-size:34px;white-space:nowrap}.live-round{position:absolute;top:145px;left:50%;transform:translateX(-50%);font-size:28px;white-space:nowrap}.live-missions{position:absolute;top:248px;width:350px;font-size:30px;line-height:1.35;text-transform:none}.live-player.left .live-missions{left:24px}.live-player.right .live-missions{right:24px;text-align:right}.live-mission{overflow-wrap:anywhere}.live-note{display:none}
2
2
 
3
- .live-primary{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid rgba(255,250,240,.5)}.mission-heading{font-size:18px;letter-spacing:.12em;margin-bottom:6px;color:#e2ceb0}.primary-objective{font-size:22px;line-height:1.3;margin:7px 0}.live-missions{width:400px}
3
+ .live-primary{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid rgba(255,250,240,.5)}.mission-heading{font-size:18px;letter-spacing:.12em;margin-bottom:6px;color:#e2ceb0}.primary-objective{font-size:28px;line-height:1.25;margin:12px 0}.live-missions{width:400px}
4
4
 
5
- .primary-objective{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.objective-name{flex:1}.objective-check,.objective-counter{flex:0 0 auto;min-width:27px;height:27px;display:inline-flex;align-items:center;justify-content:center;font-size:23px;line-height:1;font-variant-numeric:tabular-nums}.objective-check{border:2px solid #fffaf0aa;border-radius:4px}.objective-check.completed{color:#bcf2c2;border-color:#bcf2c2}.objective-counter{font-weight:bold}
5
+ .primary-objective{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.objective-copy{flex:1;min-width:0}.objective-name{overflow-wrap:anywhere}.objective-details{font-size:20px;line-height:1.3;color:#e2ceb0;margin-top:4px;overflow-wrap:anywhere}.objective-check,.objective-counter{flex:0 0 auto;min-width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:26px;line-height:1;font-variant-numeric:tabular-nums}.objective-check{border:2px solid #fffaf0aa;border-radius:4px}.objective-check.completed{color:#bcf2c2;border-color:#bcf2c2}.objective-counter{font-weight:bold}
6
6
 
7
7
  .live-round{z-index:6;padding:7px 16px;background:rgba(7,10,14,.96);border:1px solid rgba(232,228,216,.65);border-radius:7px;box-shadow:0 3px 10px #0008;line-height:1.15}
@@ -15,9 +15,12 @@
15
15
  if(p?.primary){
16
16
  const primary=make('section','','live-primary');
17
17
  primary.append(make('div',`PRIMARIA · ${value(p.primary)}`,'mission-heading'));
18
- for(const objective of p.primaryObjectives||[]){
18
+ for(const objective of (p.primaryObjectives||[]).filter(m=>m.enabled!==false)){
19
19
  const row=make('div','','primary-objective');
20
- row.append(make('span',objective.name,'objective-name'));
20
+ const copy=make('div','','objective-copy');
21
+ copy.append(make('div',objective.name,'objective-name'));
22
+ if(objective.details)copy.append(make('div',objective.details,'objective-details'));
23
+ row.append(copy);
21
24
  const hasCounter=objective.counter!=null;
22
25
  const marker=make('span',hasCounter?String(objective.counter):objective.checked===true?'✓':objective.checked===false?'':'—',hasCounter?'objective-counter':'objective-check');
23
26
  marker.setAttribute('aria-label',hasCounter?`Contador: ${objective.counter}`:objective.checked===true?'Cumplida':objective.checked===false?'Pendiente':'Sin lectura');
@@ -26,8 +29,9 @@
26
29
  }
27
30
  missions.append(primary);
28
31
  }
29
- if(p?.secondaries?.length)missions.append(make('div','SECUNDARIAS','mission-heading'));
30
- for(const m of p?.secondaries||[])missions.append(make('div',`${m.name} ${m.score}/${m.max}`,'live-mission'));
32
+ const secondaries=(p?.secondaries||[]).filter(m=>m.enabled!==false);
33
+ if(secondaries.length)missions.append(make('div','SECUNDARIAS','mission-heading'));
34
+ for(const m of secondaries)missions.append(make('div',`${m.name} ${m.score}/${m.max}`,'live-mission'));
31
35
  if(!p)card.append(make('div','Sin jugador vinculado','live-note'));
32
36
  panel.append(card);
33
37
  const rect=document.querySelector('.team.'+side+' .faction-icon').getBoundingClientRect();
@@ -7,7 +7,7 @@
7
7
  }
8
8
  function matchPlayers(layout, game) {
9
9
  const matches = ['left', 'right'].map(side => {
10
- const key = normalize(layout[side].appName || layout[side].name);
10
+ const key = normalize(layout?.[side]?.appName || layout?.[side]?.name);
11
11
  if (!key) return null;
12
12
  const found = (game?.players || []).filter(player => normalize(player.name) === key);
13
13
  return found.length === 1 ? found[0] : null;
@@ -17,10 +17,21 @@
17
17
  }
18
18
  function roundLabel(layout, game, matched) {
19
19
  const round = matched && Number.isInteger(game?.round) && game.round > 0 ? game.round : '—';
20
- const half = {top: 'TOP · PRIMER TURNO', bottom: 'BOTTOM · SEGUNDO TURNO'}[matched ? game?.half : null];
21
- return `RONDA ${round}${half ? ` · ${half}` : ''}`;
20
+ const half = {top: 'TOP', bottom: 'BOTTOM'}[matched ? game?.half : null];
21
+ const first=game?.turn?.firstPlayer;
22
+ const active=half && first ? game?.players?.find(p=>game.half==='top' ? p.name===first : p.name!==first) : null;
23
+ const matches=matchPlayers(layout,game);
24
+ const side=active ? matches.indexOf(active) : -1;
25
+ const color=side===0?'AZUL':side===1?'ROJO':null;
26
+ return `RONDA ${round}${half ? ` · ${half}` : ''}${color ? ` · ${color}` : ''}`;
22
27
  }
23
- const api = {swapPlayers, matchPlayers, roundLabel};
28
+ function turnStatus(game, layout={}) {
29
+ const label=roundLabel(layout,game,Number.isInteger(game?.round));
30
+ const first=game?.turn?.orderSource==='ui-order' ? game.turn.firstPlayer : null;
31
+ const second=first ? game.players?.find(p=>p.name!==first)?.name : null;
32
+ return {label,order:first && second ? `TOP: ${first} · BOTTOM: ${second}` : 'Orden de jugadores pendiente de lectura',reason:game?.turn?.reason || 'Esperando nuevas secundarias en Tabletop Battles.'};
33
+ }
34
+ const api = {swapPlayers, matchPlayers, roundLabel, turnStatus};
24
35
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
25
36
  else root.MatchView = api;
26
37
  })(globalThis);
package/public/style.css CHANGED
@@ -8,3 +8,5 @@
8
8
  .control .secondary-button{background:transparent;border:1px solid #647284;color:#e8e4d8}.control button:disabled{opacity:.5;cursor:wait}
9
9
  .announcement-preview{background:#101620;border-left:3px solid #d9ad4a;padding:14px 18px;border-radius:3px}.announcement-preview p{white-space:pre-wrap;overflow-wrap:anywhere}.announcement-preview a{color:#e8bf70;font-size:13px}
10
10
  .announcement-help{font-size:13px;color:#b9c1cd}#toast-feedback{color:#e8bf70;min-height:24px}
11
+
12
+ .turn-indicator{margin:16px 0;padding:18px;border:2px solid #c59b52;border-radius:12px;background:#172335;color:#fff}.turn-indicator strong{display:block;font-size:1.35rem}.turn-indicator p{margin:8px 0 0}#turn-reason{font-size:.9rem;opacity:.8}
@@ -0,0 +1,19 @@
1
+ // Log state transitions, never every polling cycle or score update.
2
+ function createReaderStatus(log=console.log){
3
+ let last=null;
4
+ return incoming=>{
5
+ const transport=incoming.transport==='wifi'?'Wi-Fi':incoming.transport==='usb'?'USB':'conexión desconocida';
6
+ let key,message;
7
+ if(incoming.connected){
8
+ const names=(incoming.players||[]).map(p=>p.name).join(' vs ');
9
+ key=JSON.stringify(['reading',transport,names,incoming.round]);
10
+ message=`Leyendo teléfono por ${transport}: ${names} · ronda ${incoming.round}. Actualización automática activa.`;
11
+ }else{
12
+ const phone=incoming.phoneConnected?`Teléfono conectado por ${transport}; lectura pendiente`:'Sin lectura del teléfono';
13
+ key=JSON.stringify(['waiting',incoming.phoneConnected,transport,incoming.error]);
14
+ message=`${phone}: ${incoming.error||'buscando conexión USB o Wi-Fi; se reintentará automáticamente.'}`;
15
+ }
16
+ if(key!==last){log(message);last=key;}
17
+ };
18
+ }
19
+ module.exports={createReaderStatus};
package/reader.py CHANGED
@@ -5,32 +5,23 @@ from battle_reader import parse, capture
5
5
  ADB = os.environ.get('OVERLAY_ADB', 'adb')
6
6
  SERIAL = os.environ.get('OVERLAY_SERIAL', '')
7
7
 
8
- def select_device(output):
9
- devices = [line.split()[:2] for line in output.splitlines() if line.strip() and not line.startswith(('List of', '*'))]
10
- if not devices:
11
- raise ValueError('ADB no detecta un teléfono. Conecta un cable de datos, desbloquea Android y activa Opciones de desarrollador → Depuración USB. No se puede distinguir cable desconectado de depuración desactivada.')
12
- if not SERIAL and len(devices) > 1:
13
- raise ValueError('Hay varios dispositivos. Reinicia con --serial SERIAL: ' + ', '.join(d[0] for d in devices))
14
- device = next((d for d in devices if d[0] == SERIAL), None) if SERIAL else devices[0]
15
- if not device: raise ValueError('El dispositivo seleccionado no está conectado: ' + SERIAL)
16
- serial, status = device
17
- if status == 'unauthorized': raise ValueError('Teléfono sin autorizar. Desbloquéalo y acepta «Permitir depuración USB» para este equipo. Si no aparece, reconecta el cable.')
18
- if status == 'no': raise ValueError('Sin permisos USB. Revisa reglas udev y grupos del usuario; en Ubuntu, android-sdk-platform-tools-common y plugdev. Vuelve a iniciar sesión después de cambiar grupos.')
19
- if status == 'offline': raise ValueError('Teléfono offline. Desbloquéalo y reconecta el cable USB.')
20
- if status != 'device': raise ValueError('Dispositivo no disponible: ' + status)
21
- return serial
8
+ from device_connection import DeviceConnection
9
+ from pathlib import Path
22
10
 
23
11
  if __name__ == '__main__':
12
+ data_dir = os.environ.get('OVERLAY_DATA_DIR')
13
+ connection = DeviceConnection(SERIAL, Path(data_dir) / 'phone-connection.json' if data_dir else None)
24
14
  while True:
15
+ device = None
25
16
  try:
26
- result = subprocess.run([ADB, 'devices', '-l'], capture_output=True, text=True, encoding='utf-8', errors='replace', check=True, timeout=10)
27
- serial = select_device(result.stdout)
17
+ device = connection.select()
18
+ serial = device['serial']
28
19
  state = parse(capture(serial))
29
- print(json.dumps(dict(state, connected=True, updatedAt=time.time(), error=None)), flush=True)
20
+ print(json.dumps(dict(state, connected=True, phoneConnected=True, transport=device["transport"], deviceSerial=serial, updatedAt=time.time(), error=None)), flush=True)
30
21
  except Exception as e:
31
22
  message = str(e)
32
23
  if isinstance(e, subprocess.TimeoutExpired): message = 'El teléfono no respondió a tiempo. Desbloquéalo y abre la ronda; volveré a intentar la lectura.'
33
24
  elif isinstance(e, subprocess.CalledProcessError): message = 'No se pudo leer Android. Abre la ronda y cierra otros lectores UIAutomator. Se reintentará. ' + (e.stderr or '').strip()[:300]
34
25
  elif isinstance(e, FileNotFoundError): message = 'ADB no está disponible. Ejecuta wargaming-overlay setup --install-adb.'
35
- print(json.dumps({'connected':False, 'error':message}), flush=True)
26
+ print(json.dumps({'connected':False, 'phoneConnected':device is not None and isinstance(e, ValueError), 'transport':device['transport'] if device is not None else None, 'error':message}), flush=True)
36
27
  time.sleep(float(os.environ.get('OVERLAY_INTERVAL', '3')))
@@ -0,0 +1,11 @@
1
+ const {SpeechQueue}=require('../tts/queue');
2
+ const {sanitize}=require('../tts/sanitizer');
3
+ async function main(){
4
+ if(process.platform!=='darwin')throw Error('Esta prueba de voz requiere macOS.');
5
+ const q=new SpeechQueue();
6
+ for(const [author,text] of [['Pedro','Hola, esto es una prueba.'],['John','Este mensaje debe sonar después del anterior.'],['Pedro','Mira esto https://example.com/prueba']])q.enqueue(sanitize({author,text}));
7
+ const stop=()=>{q.stop();};process.once('SIGINT',stop);process.once('SIGTERM',stop);
8
+ await q.idle();process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
9
+ if(q.failed)throw Error('La prueba de voz falló. Revisa los mensajes de TTS.');
10
+ }
11
+ main().catch(e=>{console.error(e.message);process.exitCode=1;});
package/server.js CHANGED
@@ -14,17 +14,31 @@ function persistGame() { fs.writeFileSync(gamePath + '.tmp', JSON.stringify(game
14
14
  const readerOptions = { env: { ...process.env, PYTHONIOENCODING: "utf-8" }, stdio: ['ignore', 'pipe', 'inherit'] };
15
15
  if (process.env.SUDO_UID) { readerOptions.uid = Number(process.env.SUDO_UID); readerOptions.gid = Number(process.env.SUDO_GID); }
16
16
  let reader;
17
+ let youtube;
17
18
  function startReader(){
18
- let lastError = '';
19
+ const report = require('./reader-status').createReaderStatus();
20
+ console.log('Buscando teléfono por USB o Wi-Fi… Esperando la primera lectura de Tabletop Battles.');
21
+ let lastResponse=Date.now();
22
+ let stalled=false;
23
+ const watchdog=setInterval(()=>{
24
+ if(!stalled && Date.now()-lastResponse>30000){
25
+ stalled=true;console.error('El lector lleva más de 30 segundos sin responder. Comprueba el teléfono y la conexión; sigo esperando.');
26
+ }
27
+ },5000);
28
+ watchdog.unref();
19
29
  reader = spawn(process.env.OVERLAY_PYTHON || 'python3', [path.join(__dirname, 'reader.py')], readerOptions);
20
30
  readline.createInterface({ input: reader.stdout }).on('line', line => {
21
- try { const incoming = JSON.parse(line); game = mergeGame(game, incoming); persistGame(); if (incoming.error !== lastError) { if (incoming.error) console.error(incoming.error); else if(lastError) console.log('Teléfono conectado. Lectura recuperada.'); lastError = incoming.error; } } catch (e) { console.error('Lectura:', e.message); }
31
+ try {
32
+ const incoming=JSON.parse(line);lastResponse=Date.now();
33
+ if(stalled){console.log('El lector volvió a responder.');stalled=false;}
34
+ game=mergeGame(game,incoming);persistGame();report(incoming);
35
+ } catch(e){console.error('Error procesando la lectura:',e.message);}
22
36
  });
23
- reader.on('error', e => console.error('Lector:', e.message));
24
- reader.on('exit', () => { game = mergeGame(game, {connected:false, error:'Lector detenido. Reinicia el servidor.'}); persistGame(); });
37
+ reader.on('error', e => {clearInterval(watchdog);console.error('No se pudo iniciar el lector:',e.message);});
38
+ reader.on('exit', (code,signal) => { clearInterval(watchdog);console.error(`Lector detenido (${signal||'código '+code}). Reinicia el servidor.`); game = mergeGame(game, {connected:false, error:'Lector detenido. Reinicia el servidor.'}); persistGame(); });
25
39
  }
26
- for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { reader?.kill(); process.exit(); });
27
- process.on('exit', () => reader?.kill());
40
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { reader?.kill(); youtube?.stop(); process.exit(); });
41
+ process.on('exit', () => {reader?.kill();youtube?.queue?.clear();});
28
42
  const {syncStats} = require('./listhammer');
29
43
  const root = path.join(__dirname, 'public');
30
44
  const statePath = path.join(dataDir, 'overlay-state.json');
@@ -109,4 +123,4 @@ const server = http.createServer(async (req, res) => {
109
123
  send(res, 200, types[path.extname(filePath)] || 'application/octet-stream', fs.readFileSync(filePath));
110
124
  });
111
125
  server.on('error', e => { console.error(e.code==='EADDRINUSE' ? 'El puerto está ocupado. Detén el otro servidor o usa --port 8766.' : 'No se pudo iniciar: '+e.message); reader?.kill(); process.exitCode=1; });
112
- server.listen(Number(process.env.PORT || 8765), '127.0.0.1', () => { startReader(); syncStats(dataDir).then(stats => { statsCache = stats; announcements.refresh(); }).catch(e => console.error('Listhammer: ' + e.message + ' Se conserva la última copia completa, si existe. El overlay sigue funcionando; se reintentará en el próximo inicio.')); console.log(`Panel: http://127.0.0.1:${process.env.PORT || 8765}\nOBS: http://127.0.0.1:${process.env.PORT || 8765}/overlay-art.html\nDatos: ${dataDir}\nCtrl+C para detener.`); });
126
+ server.listen(Number(process.env.PORT || 8765), '127.0.0.1', () => { startReader(); youtube=require('./youtube').startYouTube(dataDir); syncStats(dataDir).then(stats => { statsCache = stats; announcements.refresh(); }).catch(e => console.error('Listhammer: ' + e.message + ' Se conserva la última copia completa, si existe. El overlay sigue funcionando; se reintentará en el próximo inicio.')); console.log(`Panel: http://127.0.0.1:${process.env.PORT || 8765}\nOBS: http://127.0.0.1:${process.env.PORT || 8765}/overlay-art.html\nDatos: ${dataDir}\nCtrl+C para detener.`); });
package/tts/queue.js ADDED
@@ -0,0 +1,22 @@
1
+ const {speak}=require('./speech');
2
+ class SpeechQueue {
3
+ constructor({maxQueueLength=10,speech=speak,log=console.log}={}){this.max=maxQueueLength;this.speech=speech;this.log=log;this.queue=[];this.state='IDLE';this.closed=false;this.task=null;this.failed=0;}
4
+ enqueue(message){
5
+ if(this.closed)return;
6
+ if(this.queue.length>=this.max){this.queue.shift();this.log('[TTS] Se descartó el mensaje pendiente más antiguo: cola llena.');}
7
+ this.queue.push(message);this.log(`[TTS] En cola: ${message.author}. Pendientes: ${this.queue.length}.`);
8
+ if(!this.task)this.task=this.drain().finally(()=>{this.task=null;});
9
+ }
10
+ async drain(){
11
+ while(!this.closed&&this.queue.length){
12
+ const item=this.queue.shift();this.state='SPEAKING';this.controller=new AbortController();
13
+ this.log(`[TTS] Leyendo mensaje de ${item.author}.`);
14
+ try{await this.speech(item.spoken,{signal:this.controller.signal});}catch{this.failed++;this.log('[TTS] Falló la reproducción; continúo con el siguiente mensaje.');}
15
+ }
16
+ this.state='IDLE';
17
+ }
18
+ clear(){this.queue=[];this.controller?.abort();}
19
+ async stop(){this.closed=true;this.clear();await this.task;}
20
+ async idle(){await this.task;}
21
+ }
22
+ module.exports={SpeechQueue};
@@ -0,0 +1,31 @@
1
+ // Deliberately small local ES/EN filter, not semantic moderation.
2
+ // Ordinary profanity and in-game violence are not blanket-blocked.
3
+ const severe=[
4
+ /\b(?:n[i1]gg(?:er|a)s?|f[a4]gg?[o0]ts?|k[i1]kes?|sp[i1]cs?|sudacas?|maricones)\b/giu,
5
+ /\b(?:heil\s+hitler|sieg\s+heil)\b/giu,
6
+ /\b(?:te\s+voy\s+a\s+(?:matar|violar)|voy\s+a\s+(?:matarte|violarte)|i(?:'ll|\s+will)\s+(?:kill|rape)\s+you)\b/giu,
7
+ /\b(?:maten|matad|exterminen|hay\s+que\s+matar|kill|exterminate)\s+(?:(?:a|a\s+los|los|las|all|the)\s+)*(?:judios|negros|gays|musulmanes|inmigrantes|jews|blacks|muslims|immigrants)\b/giu,
8
+ /\b(?:chupa(?:me)?\s+(?:la\s+)?(?:polla|verga)|suck\s+my\s+(?:dick|cock)|(?:sexo|pornografia|porn)\s+(?:con\s+)?(?:ninos|menores|children))\b/giu
9
+ ];
10
+ function truncate(text,max){const chars=Array.from(text);if(chars.length<=max)return text;let cut=chars.slice(0,max-1).join('');const space=cut.lastIndexOf(' ');if(space>max*.7)cut=cut.slice(0,space);return cut.trimEnd()+'…';}
11
+ function clean(input,{filterUrls=true,extraBlockedTerms=[]}={}){
12
+ let text=String(input||'').slice(0,10000).normalize('NFKC').replace(/[\p{Cc}\p{Cf}]/gu,' ').replace(/[\[\]<>]/g,' ').replace(/\s+/g,' ').trim();
13
+ if(filterUrls)text=text.replace(/(?:[a-z][a-z\d+.-]*:\/\/|www\.)[^\s]+|\b(?:[\p{L}\d-]+\.)+[a-z]{2,63}(?:\/[^\s]*)?/giu,' Enlace ');
14
+ // Accent-insensitive matching with offsets mapped back to the original spelling.
15
+ const chars=Array.from(text),folded=[],offsets=[];let offset=0;
16
+ for(const ch of chars){const f=ch.normalize('NFD').replace(/\p{M}/gu,'');for(let j=0;j<f.length;j++)offsets.push([offset,offset+ch.length]);folded.push(f);offset+=ch.length;}
17
+ const normalized=folded.join(''),spans=[];
18
+ const patterns=[...severe,...extraBlockedTerms.filter(t=>typeof t==='string'&&t.trim()).slice(0,100).map(t=>new RegExp(t.normalize('NFD').replace(/\p{M}/gu,'').replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'giu'))];
19
+ for(const re of patterns){re.lastIndex=0;for(const m of normalized.matchAll(re))if(m[0].length)spans.push([offsets[m.index][0],offsets[m.index+m[0].length-1][1]]);}
20
+ spans.sort((a,b)=>a[0]-b[0]);const merged=[];
21
+ for(const span of spans){const last=merged.at(-1);if(last&&span[0]<=last[1])last[1]=Math.max(last[1],span[1]);else merged.push([...span]);}
22
+ for(const [start,end] of merged.reverse())text=text.slice(0,start)+'[censurado]'+text.slice(end);
23
+ return text.replace(/\s+/g,' ').trim();
24
+ }
25
+ function sanitize(message,config={}){
26
+ const text=truncate(clean(message.text,config),config.maxMessageLength||300);
27
+ if(!text || !text.replace(/\[censurado\]|[^\p{L}\p{N}]/gu,''))return null;
28
+ const author=truncate(clean(message.author,config),40)||'Usuario';
29
+ return {author,text,spoken:config.readUsername===false?text:`${author} dice: ${text}`};
30
+ }
31
+ module.exports={sanitize,clean,truncate};
package/tts/speech.js ADDED
@@ -0,0 +1,20 @@
1
+ const {spawn}=require('node:child_process');
2
+ function speak(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=120000}={}){
3
+ if(platform!=='darwin')return Promise.reject(Error('El TTS local requiere macOS.'));
4
+ if(signal?.aborted)return Promise.resolve();
5
+ return new Promise((resolve,reject)=>{
6
+ // Feed text through stdin, not a shell/option. Remove Apple's embedded speech commands.
7
+ const child=spawnImpl('/usr/bin/say',[],{stdio:['pipe','ignore','pipe'],shell:false});
8
+ let timedOut=false,done=false;
9
+ const stop=()=>child.kill('SIGKILL');
10
+ const timer=setTimeout(()=>{timedOut=true;stop();},timeoutMs);
11
+ const finish=(error)=>{if(done)return;done=true;clearTimeout(timer);signal?.removeEventListener('abort',stop);error?reject(error):resolve();};
12
+ signal?.addEventListener('abort',stop,{once:true});
13
+ child.stderr?.resume();child.stdin.on('error',()=>{});
14
+ child.once('error',()=>finish(Error('No se pudo iniciar la voz nativa de macOS.')));
15
+ child.once('close',code=>finish(signal?.aborted?null:timedOut?Error('Se agotó el tiempo de voz.'):code===0?null:Error('La voz de macOS terminó con un error.')));
16
+ child.stdin.end(String(text).replace(/[\[\]]/g,''));
17
+ if(signal?.aborted)stop();
18
+ });
19
+ }
20
+ module.exports={speak};
package/turn-tracker.js CHANGED
@@ -1,35 +1,47 @@
1
- // Accessibility snapshots can be partial. Only increases of previously seen
2
- // values are activity; discovering a mission while scrolling is not activity.
1
+ // Keep the union of observed cards for each player and round across partial snapshots.
2
+ // A first-seen unscored card after the initial reading is a draw heuristic, not proof.
3
3
  function inferTurn(previous, incoming, samePlayers) {
4
- const fresh={firstPlayer:null,activePlayer:null,half:null,highestRound:incoming.round,eligible:incoming.round===1 && incoming.players.every(p=>p.score===0),seen:{},reason:'Esperando actividad para detectar el turno'};
4
+ // The parser keeps Tabletop Battles player order even when a heading is off-screen.
5
+ const firstPlayer=incoming.players[0].name;
6
+ const fresh={firstPlayer,orderSource:'ui-order',detectionSource:'secondary-draws',activePlayer:null,half:null,highestRound:incoming.round,seen:{},reason:'Orden de Tabletop Battles identificado; esperando nuevas secundarias para detectar el turno'};
5
7
  let t=samePlayers && previous.turn ? structuredClone(previous.turn) : fresh;
6
8
  if(incoming.round<t.highestRound) return {...t,reason:'Ronda histórica: turno sin determinar'};
7
- if(incoming.round>t.highestRound){t={...t,highestRound:incoming.round,eligible:true,seen:{},activePlayer:null,half:null};}
9
+ // Old versions learned order from scoring; discard that inference on migration.
10
+ if(t.orderSource!=='ui-order' || t.firstPlayer!==firstPlayer)t=fresh;
11
+ if(incoming.round>t.highestRound){t={...t,highestRound:incoming.round,seen:{},cards:{},cardBaselines:{},activePlayer:null,half:null,reason:fresh.reason};}
12
+ // Preserve card history, but discard halves inferred by the former scoring policy.
13
+ if(t.detectionSource!=='secondary-draws'){
14
+ t.detectionSource='secondary-draws';t.activePlayer=null;t.half=null;t.reason=fresh.reason;
15
+ }
8
16
  const events=[];
17
+ t.cards=t.cards||{};
9
18
  for(const p of incoming.players){
10
- const old=t.seen[p.name]||{},next={...old};let activity=false;
11
- const values={};
12
- for(const key of ['primary','secondary']) if(Number.isFinite(p[key]?.score))values[key]=p[key].score;
13
- if(Number.isFinite(p.score))values.score=p.score;
14
- for(const m of p.secondaries||[])if(Number.isFinite(m.score))values[`mission:${m.name}`]=m.score;
15
- for(const m of p.primaryObjectives||[]){
16
- if(m.checked!=null)values[`checked:${m.name}`]=Number(m.checked);
17
- if(Number.isFinite(m.counter))values[`counter:${m.name}`]=m.counter;
18
- }
19
- for(const [key,value] of Object.entries(values)){
20
- if(Number.isFinite(old[key]) && value>old[key])activity=true;
21
- next[key]=value;
19
+ let activity=false;
20
+ if(Array.isArray(p.secondaries)){
21
+ const names=[...new Set(p.secondaries.map(m=>m.name))];
22
+ const prior=t.cards[p.name];
23
+ const known=Array.isArray(prior)?prior:[];
24
+ const added=p.secondaries.filter(m=>m.enabled!==false && !known.includes(m.name));
25
+ // A complete baseline proves an addition. Otherwise a new zero-point card is
26
+ // useful evidence, including the first visible BOTTOM card after TOP played.
27
+ if(t.cardBaselines?.[p.name] && added.length)activity=true;
28
+ else if(Object.hasOwn(t.seen,p.name) && added.some(m=>m.score===0))activity=true;
29
+ t.cards[p.name]=[...new Set([...known,...names])];
30
+ if(p.secondariesComplete){
31
+ t.cardBaselines=t.cardBaselines||{};
32
+ t.cardBaselines[p.name]=true;
33
+ }
22
34
  }
23
- t.seen[p.name]=next;
35
+ t.seen[p.name]=true;
24
36
  if(activity)events.push(p.name);
25
37
  }
26
38
  if(events.length===1){
27
39
  const name=events[0];
28
- if(!t.firstPlayer && t.eligible)t.firstPlayer=name;
29
- t.activePlayer=name;
30
- // Bottom is terminal for this round: delayed scoring cannot rewind it.
40
+ // Late card changes by TOP must not rewind a round after BOTTOM began.
41
+ if(t.half!=='bottom')t.activePlayer=name;
42
+ // Bottom is terminal for this round.
31
43
  if(t.firstPlayer && t.half!=='bottom')t.half=name===t.firstPlayer?'top':'bottom';
32
- t.reason=t.half?'Turno inferido por actividad de puntuación':`Actividad de ${name}; esperando conocer el orden de juego`;
44
+ t.reason='Turno identificado por nuevas secundarias; orden de Tabletop Battles';
33
45
  }
34
46
  if(events.length>1)t.reason='Cambios en ambos jugadores; se conserva el último turno detectado';
35
47
  return t;
@@ -0,0 +1,71 @@
1
+ const fs=require('node:fs');
2
+ const path=require('node:path');
3
+ const os=require('node:os');
4
+ const http=require('node:http');
5
+ const crypto=require('node:crypto');
6
+ const {spawn}=require('node:child_process');
7
+ const SCOPE='https://www.googleapis.com/auth/youtube.readonly';
8
+ const credentialDir=()=>path.join(os.homedir(),'.config','wargaming-overlay','youtube');
9
+ function privateWrite(file,value){
10
+ fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});
11
+ const tmp=file+'.'+crypto.randomBytes(6).toString('hex')+'.tmp';
12
+ try{fs.writeFileSync(tmp,JSON.stringify(value),{mode:0o600,flag:'wx'});fs.renameSync(tmp,file);}finally{try{fs.unlinkSync(tmp)}catch{}}
13
+ }
14
+ function read(file){try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch(e){if(e.code==='ENOENT')return null;throw Error('No se pueden leer las credenciales locales de YouTube.');}}
15
+ function clientConfig(json){
16
+ const c=json?.installed;
17
+ if(!c?.client_id || !c.client_secret)throw Error('Usa el JSON OAuth de tipo Aplicación de escritorio de Google.');
18
+ return {client_id:c.client_id,client_secret:c.client_secret};
19
+ }
20
+ class Auth {
21
+ constructor({dir=credentialDir(),fetchImpl=fetch,now=Date.now}={}){this.dir=dir;this.fetch=fetchImpl;this.now=now;this.pending=null;}
22
+ load(){const c=read(path.join(this.dir,'client.json'));if(!c)throw Error('Configura OAuth: wargaming-overlay youtube-auth --client-secret /ruta/client.json');return c;}
23
+ async exchange(params,signal){
24
+ const response=await this.fetch('https://oauth2.googleapis.com/token',{method:'POST',body:new URLSearchParams(params),signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
25
+ const data=await response.json();
26
+ if(!response.ok || !data.access_token)throw Error(data.error==='invalid_grant'?'Autorización revocada o vencida. Ejecuta youtube-auth de nuevo.':'No se pudo obtener autorización de Google; se reintentará.');
27
+ return data;
28
+ }
29
+ save(data,old={}){
30
+ const tokens={refresh_token:data.refresh_token||old.refresh_token,access_token:data.access_token,expires_at:this.now()+Number(data.expires_in||3600)*1000,scope:data.scope||old.scope||SCOPE};
31
+ if(!tokens.refresh_token)throw Error('Google no entregó refresh token. Autoriza de nuevo con youtube-auth.');
32
+ if(!tokens.scope.split(' ').includes(SCOPE))throw Error('No se concedió el permiso de lectura de YouTube.');
33
+ privateWrite(path.join(this.dir,'tokens.json'),tokens);return tokens;
34
+ }
35
+ async token(signal){
36
+ const t=read(path.join(this.dir,'tokens.json'));
37
+ if(!t?.refresh_token)throw Error('YouTube sin autorizar. Ejecuta wargaming-overlay youtube-auth --client-secret /ruta/client.json');
38
+ if(t.access_token && t.expires_at>this.now()+60000)return t.access_token;
39
+ if(!this.pending)this.pending=(async()=>{const c=this.load();return this.save(await this.exchange({...c,grant_type:'refresh_token',refresh_token:t.refresh_token},signal),t).access_token;})().finally(()=>{this.pending=null;});
40
+ return this.pending;
41
+ }
42
+ invalidate(){const file=path.join(this.dir,'tokens.json'),t=read(file);if(t)privateWrite(file,{...t,expires_at:0});}
43
+ async authorize(clientFile,{open=url=>{const child=spawn(process.platform==='darwin'?'open':process.platform==='win32'?'explorer.exe':'xdg-open',[url],{stdio:'ignore',shell:false});child.on('error',()=>{});child.unref();},log=console.log,timeoutMs=180000}={}){
44
+ const c=clientFile?clientConfig(read(path.resolve(clientFile))):this.load();
45
+ const state=crypto.randomBytes(24).toString('hex'),verifier=crypto.randomBytes(32).toString('base64url');
46
+ const challenge=crypto.createHash('sha256').update(verifier).digest('base64url');
47
+ let resolveCode,rejectCode;
48
+ const codePromise=new Promise((resolve,reject)=>{resolveCode=resolve;rejectCode=reject;});
49
+ const server=http.createServer((req,res)=>{
50
+ const u=new URL(req.url,'http://127.0.0.1');
51
+ res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');
52
+ if(u.pathname!=='/oauth/callback'){res.writeHead(404);res.end();return;}
53
+ if(u.searchParams.get('state')!==state){res.writeHead(400);res.end('Solicitud no válida.');return;}
54
+ if(u.searchParams.get('error')){res.end('Autorización cancelada. Vuelve a la terminal.');rejectCode(Error('Autorización cancelada por el usuario.'));return;}
55
+ const code=u.searchParams.get('code');if(!code){res.writeHead(400);res.end('Falta autorización.');return;}
56
+ res.end('Autorización recibida. Puedes cerrar esta ventana y volver a la terminal.');resolveCode(code);
57
+ });
58
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
59
+ const timer=setTimeout(()=>rejectCode(Error('Se agotó el tiempo de autorización. Ejecuta youtube-auth de nuevo.')),timeoutMs);
60
+ const redirect=`http://127.0.0.1:${server.address().port}/oauth/callback`;
61
+ try{
62
+ const url=new URL('https://accounts.google.com/o/oauth2/v2/auth');url.search=new URLSearchParams({client_id:c.client_id,redirect_uri:redirect,response_type:'code',scope:SCOPE,access_type:'offline',prompt:'consent',state,code_challenge:challenge,code_challenge_method:'S256'});
63
+ log('[YouTube] Autoriza el canal en el navegador. Si no se abre: '+url.href);open(url.href);
64
+ const code=await codePromise;
65
+ const tokens=await this.exchange({...c,code,redirect_uri:redirect,grant_type:'authorization_code',code_verifier:verifier});
66
+ this.save(tokens);privateWrite(path.join(this.dir,'client.json'),c);
67
+ log('[YouTube] Autorización guardada. Ya puedes iniciar el backend con YOUTUBE_CHAT_TTS_ENABLED=true.');
68
+ }finally{clearTimeout(timer);server.close();server.closeAllConnections();}
69
+ }
70
+ }
71
+ module.exports={Auth,credentialDir,privateWrite,SCOPE};
@@ -0,0 +1,29 @@
1
+ // Minimal wire-compatible subset of the official YouTube streaming schema.
2
+ // https://developers.google.com/youtube/v3/live/streaming-live-chat
3
+ syntax = "proto2";
4
+ package youtube.api.v3;
5
+ service V3DataLiveChatMessageService {
6
+ rpc StreamList(LiveChatMessageListRequest) returns (stream LiveChatMessageListResponse) {}
7
+ }
8
+ message LiveChatMessageListRequest {
9
+ optional string live_chat_id = 1;
10
+ optional string page_token = 99;
11
+ repeated string part = 100;
12
+ }
13
+ message LiveChatMessageListResponse {
14
+ optional string offline_at = 2;
15
+ optional string next_page_token = 100602;
16
+ repeated LiveChatMessage items = 1007;
17
+ }
18
+ message LiveChatMessage {
19
+ optional string id = 101;
20
+ optional LiveChatMessageSnippet snippet = 2;
21
+ optional LiveChatMessageAuthorDetails author_details = 3;
22
+ }
23
+ message LiveChatMessageAuthorDetails { optional string display_name = 103; }
24
+ message LiveChatMessageSnippet {
25
+ optional int32 type = 1;
26
+ optional string published_at = 4;
27
+ optional LiveChatTextMessageDetails text_message_details = 19;
28
+ }
29
+ message LiveChatTextMessageDetails { optional string message_text = 1; }
@@ -0,0 +1,20 @@
1
+ const fs=require('node:fs'),path=require('node:path');
2
+ function configFor(dataDir,env=process.env){
3
+ let file={};try{file=JSON.parse(fs.readFileSync(path.join(dataDir,'youtube-tts.json'),'utf8'));}catch(e){if(e.code!=='ENOENT')throw Error('Configuración youtube-tts.json inválida.');}
4
+ const config={enabled:true,maxMessageLength:300,maxQueueLength:10,readUsername:true,filterUrls:true,extraBlockedTerms:[],...file};
5
+ for(const [key,min,max] of [['maxMessageLength',50,300],['maxQueueLength',1,100]])if(!Number.isInteger(config[key])||config[key]<min||config[key]>max)throw Error(`Configuración ${key} fuera de rango.`);
6
+ for(const key of ['enabled','readUsername','filterUrls'])if(typeof config[key]!=='boolean')throw Error(`Configuración ${key} debe ser true/false.`);
7
+ if(!Array.isArray(config.extraBlockedTerms)||config.extraBlockedTerms.some(t=>typeof t!=='string'||t.length>100))throw Error('extraBlockedTerms debe ser una lista de textos cortos.');
8
+ return config;
9
+ }
10
+ function startYouTube(dataDir,{env=process.env,platform=process.platform,log=console.log}={}){
11
+ if(env.YOUTUBE_CHAT_TTS_ENABLED!=='true'){log('[YouTube] Chat TTS desactivado. Actívalo con YOUTUBE_CHAT_TTS_ENABLED=true.');return null;}
12
+ try{
13
+ if(platform!=='darwin')throw Error('El TTS local requiere macOS.');
14
+ const config=configFor(dataDir,env);if(!config.enabled){log('[YouTube] TTS desactivado en configuración.');return null;}
15
+ const {YouTubeService}=require('./service');
16
+ log('[YouTube] Iniciando chat TTS. Si falta autorización, ejecuta wargaming-overlay youtube-auth --client-secret /ruta/client.json.');
17
+ return new YouTubeService({config,log}).start();
18
+ }catch(e){log('[YouTube] '+e.message+' El overlay continúa funcionando.');return null;}
19
+ }
20
+ module.exports={startYouTube,configFor};
@@ -0,0 +1,13 @@
1
+ async function findBroadcast(auth,{signal,fetchImpl=fetch}={}){
2
+ const token=await auth.token(signal);
3
+ const url=new URL('https://www.googleapis.com/youtube/v3/liveBroadcasts');
4
+ // API accepts exactly one filter: active, NOT active + mine.
5
+ url.search=new URLSearchParams({part:'snippet,status',broadcastStatus:'active',broadcastType:'all',maxResults:'50'});
6
+ const response=await fetchImpl(url,{headers:{Authorization:`Bearer ${token}`},signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
7
+ if(response.status===401){auth.invalidate();throw Error('Autorización vencida; renovando.');}
8
+ if(!response.ok)throw Error(`Consulta de directos falló (HTTP ${response.status}). Revisa permisos/cuota de YouTube.`);
9
+ const data=await response.json();
10
+ const active=(data.items||[]).filter(b=>b.snippet?.liveChatId).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
11
+ return active[0]?{id:active[0].id,liveChatId:active[0].snippet.liveChatId,title:active[0].snippet.title,multiple:active.length>1}:null;
12
+ }
13
+ module.exports={findBroadcast};
@@ -0,0 +1,22 @@
1
+ const path=require('node:path');
2
+ function normalize(item){
3
+ if(item.snippet?.type!==1 || !item.id || typeof item.snippet.textMessageDetails?.messageText!=='string')return null;
4
+ return {id:item.id,type:'youtube_chat_message',author:item.authorDetails?.displayName||'Usuario',text:item.snippet.textMessageDetails.messageText,timestamp:item.snippet.publishedAt,messageType:'textMessageEvent'};
5
+ }
6
+ async function* streamChat({auth,liveChatId,pageToken,signal,createClient}){
7
+ const grpc=require('@grpc/grpc-js'),loader=require('@grpc/proto-loader');
8
+ const def=loader.loadSync(path.join(__dirname,'chat.proto'));
9
+ const Service=grpc.loadPackageDefinition(def).youtube.api.v3.V3DataLiveChatMessageService;
10
+ const client=createClient?createClient(Service,grpc):new Service('youtube.googleapis.com:443',grpc.credentials.createSsl());
11
+ let call;
12
+ const cancel=()=>call?.cancel();
13
+ try{
14
+ const token=await auth.token(signal);if(signal?.aborted)return;
15
+ const meta=new grpc.Metadata();meta.set('authorization',`Bearer ${token}`);
16
+ // Reopen periodically with fresh credentials, retaining the continuation token.
17
+ call=client.streamList({liveChatId,pageToken,part:['id','snippet','authorDetails']},meta,{deadline:Date.now()+45*60*1000});
18
+ signal?.addEventListener('abort',cancel,{once:true});
19
+ for await(const batch of call)yield batch;
20
+ }finally{signal?.removeEventListener('abort',cancel);call?.cancel();client.close();}
21
+ }
22
+ module.exports={streamChat,normalize};
@@ -0,0 +1,64 @@
1
+ const {EventEmitter}=require('node:events');
2
+ const {setTimeout:delay}=require('node:timers/promises');
3
+ const {Auth}=require('./auth');
4
+ const {findBroadcast}=require('./live-broadcast');
5
+ const {streamChat,normalize}=require('./live-chat');
6
+ const {SpeechQueue}=require('../tts/queue');
7
+ const {sanitize}=require('../tts/sanitizer');
8
+ const backoff=[1000,2000,5000,10000,30000];
9
+ class YouTubeService extends EventEmitter {
10
+ constructor({config={},auth=new Auth(),discover=findBroadcast,stream=streamChat,queue,log=console.log,sleep=(ms,signal)=>delay(ms,null,{signal}),now=Date.now}={}){
11
+ super();this.config=config;this.auth=auth;this.discover=discover;this.stream=stream;this.log=log;this.sleep=sleep;this.now=now;
12
+ this.queue=queue||new SpeechQueue({maxQueueLength:config.maxQueueLength||10,log});this.state='DISCONNECTED';this.seen=new Set();this.controller=new AbortController();this.chat=null;this.pageToken=undefined;this.task=null;
13
+ }
14
+ status(state,message){if(this.state!==state||this.lastMessage!==message){this.state=state;this.lastMessage=message;this.log('[YouTube] '+message);}}
15
+ start(){if(!this.task)this.task=this.run().catch(()=>{if(!this.controller.signal.aborted)this.status('DISCONNECTED','Servicio detenido por un error inesperado; el overlay continúa.');});return this;}
16
+ async stop(){this.controller.abort();await this.queue.stop();await this.task;}
17
+ endChat(){this.chat=null;this.pageToken=undefined;this.queue.clear();this.status('WAITING_FOR_STREAM','Directo terminado o chat no disponible; buscando otro directo.');}
18
+ async run(){
19
+ const signal=this.controller.signal;let failures=0;
20
+ while(!signal.aborted){
21
+ try{
22
+ if(!this.chat){
23
+ await this.auth.token(signal);this.status('AUTHENTICATED','Autorización cargada.');
24
+ const broadcast=await this.discover(this.auth,{signal});
25
+ if(!broadcast){this.status('WAITING_FOR_STREAM','Sin directo activo con chat; volveré a comprobar en 60 segundos.');await this.sleep(60000,signal);continue;}
26
+ this.chat=broadcast.liveChatId;this.pageToken=undefined;this.startedAt=this.now();
27
+ this.log(`[YouTube] Directo detectado${broadcast.multiple?' (hay varios; se usa el primero por ID)':''}.`);
28
+ }
29
+ let ended=false;
30
+ this.status('CONNECTED_TO_CHAT','Conectando al chat en vivo mediante streamList…');
31
+ for await(const batch of this.stream({auth:this.auth,liveChatId:this.chat,pageToken:this.pageToken,signal})){
32
+ if(signal.aborted)break;
33
+ failures=0;this.status('CONNECTED_TO_CHAT','Chat conectado; esperando mensajes nuevos.');
34
+ if(batch.offlineAt || (batch.items||[]).some(i=>i.snippet?.type===4)){ended=true;break;}
35
+ for(const item of batch.items||[]){
36
+ if(!item.id||this.seen.has(item.id))continue;
37
+ this.seen.add(item.id);if(this.seen.size>1000)this.seen.delete(this.seen.values().next().value);
38
+ const event=normalize(item);if(!event)continue;
39
+ // Initial streamList replies include history: never speak pre-connection messages.
40
+ if(!Number.isFinite(Date.parse(event.timestamp)) || Date.parse(event.timestamp)<this.startedAt)continue;
41
+ const safe=sanitize(event,this.config);if(!safe)continue;
42
+ this.log(`[Chat] ${safe.author}: ${safe.text}`);
43
+ // Future consumers receive the normalized event; listener failures stay isolated.
44
+ try{this.emit('message',event);}catch{this.log('[YouTube] Falló un consumidor de mensajes.');}
45
+ this.queue.enqueue(safe);
46
+ }
47
+ this.pageToken=batch.nextPageToken||this.pageToken;
48
+ }
49
+ if(ended){this.endChat();await this.sleep(60000,signal);continue;}
50
+ if(!signal.aborted)throw Error('Chat connection closed');
51
+ }catch(e){
52
+ if(signal.aborted)break;
53
+ if(e.code===16)this.auth.invalidate();
54
+ if([5,9].includes(e.code)){this.endChat();await this.sleep(60000,signal);continue;}
55
+ if(e.code===3)this.pageToken=undefined;
56
+ const ms=backoff[Math.min(failures++,backoff.length-1)];
57
+ // Do not print upstream payloads or request objects: they may contain credentials.
58
+ this.status('DISCONNECTED',`Conexión/autorización no disponible${Number.isInteger(e.code)?' (código '+e.code+')':''}. Reintento en ${ms/1000}s. Si persiste, revisa OAuth, API y cuota.`);
59
+ await this.sleep(ms,signal);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ module.exports={YouTubeService,backoff};