@ing.jorgeu/wargaming-overlay 2.2.1 → 2.3.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/README.md +88 -2
- package/announcements.js +130 -0
- package/data/faction-facts.json +6118 -0
- package/factions.js +20 -0
- package/game-store.js +4 -1
- package/listhammer.js +106 -0
- package/package.json +10 -3
- package/public/announcements-control.js +57 -0
- package/public/control.html +10 -1
- package/public/control.js +40 -20
- package/public/factions.json +39 -0
- package/public/live-score.js +2 -4
- package/public/match-view.js +26 -0
- package/public/overlay-art.html +1 -1
- package/public/style.css +9 -0
- package/public/toast.css +14 -0
- package/public/toast.js +21 -0
- package/scripts/fact-coverage.js +9 -0
- package/server.js +49 -4
- package/turn-tracker.js +37 -0
package/README.md
CHANGED
|
@@ -316,7 +316,7 @@ Si la consulta sigue fallando, revisa `npm config get registry` y la [página de
|
|
|
316
316
|
```
|
|
317
317
|
|
|
318
318
|
4. Abre el panel en [http://127.0.0.1:8765](http://127.0.0.1:8765).
|
|
319
|
-
5. Configura título, nombres, facciones, disposiciones y destacamentos. Si el nombre visible del overlay es distinto al de la app, completa **Nombre en la app** para cada jugador. Pulsa **Actualizar overlay**.
|
|
319
|
+
5. Configura título, nombres, facciones, disposiciones y destacamentos. Si el nombre visible del overlay es distinto al de la app, completa **Nombre en la app** para cada jugador. Pulsa **Actualizar overlay**. Para cambiar de lado, pulsa **Intercambiar jugadores ⇄**: guarda también los cambios pendientes del formulario e intercambia ambos jugadores completos. Los VP, CP y misiones siguen vinculados por nombre; el cambio se refleja al instante en OBS y se conserva al reiniciar.
|
|
320
320
|
6. En OBS añade una fuente **Navegador**, desmarca **Archivo local** y usa:
|
|
321
321
|
|
|
322
322
|
```text
|
|
@@ -461,7 +461,7 @@ El botón **Restablecer datos de la partida** vacía los puntos y misiones guard
|
|
|
461
461
|
|
|
462
462
|
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
463
|
|
|
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.
|
|
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.
|
|
465
465
|
|
|
466
466
|
## Solución de problemas
|
|
467
467
|
|
|
@@ -539,3 +539,89 @@ Consulta [PUBLISHING.md](PUBLISHING.md) para autenticarte y publicar. `npm publi
|
|
|
539
539
|
El código se distribuye bajo **[0BSD](LICENSE)**, una licencia permisiva que permite uso comercial, modificación y redistribución sin atribución obligatoria y sin garantías. [Texto oficial](https://opensource.org/license/0bsd).
|
|
540
540
|
|
|
541
541
|
Se conservan las imágenes del overlay. Su procedencia y derechos se documentan por separado en [THIRD_PARTY_ASSETS.md](THIRD_PARTY_ASSETS.md); la licencia del código no concede derechos adicionales sobre recursos de terceros. El proyecto no declara afiliación oficial con las aplicaciones o juegos a los que hace referencia.
|
|
542
|
+
|
|
543
|
+
## Caché local de estadísticas de Listhammer
|
|
544
|
+
|
|
545
|
+
Al iniciar el servidor con `wargaming-overlay start` o `npm start`, se descargan en segundo plano los datos públicos de [Listhammer Stats](https://listhammer.info/stats) y las páginas de las facciones presentes en su tabla. No requiere dependencias nuevas, cuenta ni configuración. La lectura del teléfono y OBS arrancan sin esperar esta descarga.
|
|
546
|
+
|
|
547
|
+
Se guarda `listhammer-stats.json` en la misma [carpeta de datos](#ubicación-de-los-datos) que `game-state.json`; `--data-dir` también cambia ese destino. En macOS, por defecto:
|
|
548
|
+
|
|
549
|
+
```text
|
|
550
|
+
~/Library/Application Support/wargaming-overlay/listhammer-stats.json
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
El JSON contiene:
|
|
554
|
+
|
|
555
|
+
- `factions`: victorias, derrotas, partidas, porcentaje de victorias, jugadores y demás agregados por facción.
|
|
556
|
+
- `dispositions.overall` y `dispositions.matchups`: resultados por disposición y contra cada disposición rival.
|
|
557
|
+
- `factionDetails`: resultados de cada facción contra las facciones rivales (`matchups`), diferencial de puntos y estadísticas y tamaño de muestra jugando primero cuando la fuente los ofrece. Incluye también el histórico semanal y los agregados de la página.
|
|
558
|
+
- `sourceUrl`, `fetchedAt`, `schemaVersion` y `selection`: procedencia, fecha de descarga, versión del formato, período y filtros.
|
|
559
|
+
|
|
560
|
+
Se recoge la selección inicial del sitio: **This Weekend**, sin RTTs; actualmente partidas de 40k de 11.ª edición, 2000 puntos, eventos de al menos 5 rondas y 16 jugadores, excluyendo enfrentamientos espejo. `headline` y `matchups` corresponden al período indicado; `overall` de cada facción es su histórico general y `weekly` distingue las semanas. Se conservan los porcentajes tal como los entrega Listhammer (cadenas, escala 0–100) y sus tamaños de muestra: un cero con cero partidas no representa una tasa observada. No se guardan nombres de jugadores ni sus listas.
|
|
561
|
+
|
|
562
|
+
La descarga es secuencial, con una pausa de 250 ms entre facciones y un límite de 20 segundos por petición. Se realiza una vez en cada arranque, sin consultas periódicas durante la emisión. La consola informa al terminar e indica la ruta guardada.
|
|
563
|
+
|
|
564
|
+
Si falta internet, el sitio devuelve un error o cambia de formato, aparece un aviso y se conserva la última copia completa. Si es el primer arranque y falla, aún no habrá archivo. Para reintentar, reinicia el servidor. El reemplazo es atómico y solo ocurre después de validar todas las facciones y comprobar que sus resultados y períodos coinciden con la tabla principal.
|
|
565
|
+
|
|
566
|
+
Estos datos alimentan los avisos opcionales descritos abajo. El archivo completo no tiene una ruta HTTP pública. Restablecer la partida no borra esta caché. No se incluyen copias descargadas en el paquete npm. La disponibilidad y los datos dependen de Listhammer; la licencia del código no concede derechos sobre sus datos.
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
## Mensajes, curiosidades y estadísticas en pantalla
|
|
570
|
+
|
|
571
|
+
En el panel encontrarás **Mensajes en pantalla**. Los avisos aparecen en la parte inferior de `overlay-art.html`, sobre una franja oscura translúcida, durante **15 segundos**. Se escalan junto con el diseño original de 1440p para todas las resoluciones compatibles.
|
|
572
|
+
|
|
573
|
+
1. Elige las facciones y disposiciones de ambos jugadores y pulsa **Actualizar overlay**. Se usan esas selecciones guardadas, no el nombre del jugador, para escoger las curiosidades y estadísticas.
|
|
574
|
+
2. Para un aviso propio, escribe hasta 280 caracteres y pulsa **Mostrar mensaje · 15 s**. Por ejemplo: `¡Bienvenidos! La siguiente ronda comienza en unos minutos.` Los mensajes son texto plano; los saltos de línea se convierten en espacios.
|
|
575
|
+
3. Activa **Mostrar un dato cada 2 minutos** para iniciar la rotación. El primer aviso automático aparece después de dos minutos. Puedes incluir **Curiosidades de las facciones**, **Estadísticas y enfrentamiento actual**, o ambas. Las opciones se guardan de inmediato, sin pulsar Actualizar overlay.
|
|
576
|
+
4. **Mostrar siguiente dato** adelanta la rotación incluso cuando el modo automático está pausado. Cada pulsación reemplaza el aviso actual y usa el mismo historial que la rotación automática.
|
|
577
|
+
5. Al empezar otra transmisión, pulsa **Nueva sesión de mensajes** para reiniciar el historial. No modifica jugadores, puntos, misiones ni la caché de estadísticas.
|
|
578
|
+
|
|
579
|
+
Un mensaje manual reemplaza cualquier aviso visible, no consume curiosidades y deja dos minutos hasta el siguiente aviso automático. Los avisos automáticos solo consumen datos cuando hay al menos una fuente del overlay conectada; tener únicamente el panel abierto no consume la rotación. Varias fuentes de OBS reciben el mismo aviso, sin crear temporizadores independientes. Si OBS se reconecta mientras hay un aviso, recibe solo el tiempo restante de sus 15 segundos.
|
|
580
|
+
|
|
581
|
+
### Selección y repetición
|
|
582
|
+
|
|
583
|
+
El catálogo contiene **764 curiosidades aprobadas en español para las 28 facciones**, con al menos 20 por facción. Solo incluye entradas auditadas; cada una conserva su enlace y fecha de revisión. Genestealer Cults dispone de 32 y Thousand Sons de 20: juntas ofrecen 52 curiosidades, más las estadísticas disponibles. Custom no tiene curiosidades inventadas.
|
|
584
|
+
|
|
585
|
+
El orden es aleatorio, con más peso para las estadísticas del enfrentamiento actual. Cada dato aparece una vez por ciclo. Al completar la lista empieza otro ciclo automáticamente, sin repetir inmediatamente el último dato si hay alternativas. El botón «Mostrar siguiente dato» también inicia el siguiente ciclo. Las partidas espejo usan una sola copia del catálogo de su facción. El panel separa curiosidades y estadísticas, e indica el ciclo y los datos pendientes. La cobertura mostrada es el tiempo hasta completar el ciclo; la rotación puede continuar indefinidamente.
|
|
586
|
+
|
|
587
|
+
El historial y las opciones se guardan en `toast-session.json`, dentro de la carpeta de datos (`--data-dir`). Recargar el panel, refrescar OBS, pausar la rotación o reiniciar el servidor no reinicia ese historial. «Nueva sesión de mensajes» es independiente de «Restablecer datos de la partida».
|
|
588
|
+
|
|
589
|
+
### Qué estadísticas se muestran
|
|
590
|
+
|
|
591
|
+
La caché local de Listhammer proporciona porcentajes generales de las facciones elegidas, resultados entre ambas facciones, diferencial de puntos y resultados jugando primero cuando hay al menos 10 partidas para esos dos últimos indicadores. También aparecen las disposiciones seleccionadas y su enfrentamiento: se identifican expresamente como resultados **entre todas las facciones**, no como estadísticas particulares de los jugadores.
|
|
592
|
+
|
|
593
|
+
Cada aviso estadístico incluye período, fecha de la copia y número de partidas. Una tasa con menos de 10 partidas se marca como **muestra pequeña**. Los resultados con cero partidas se omiten. No se calcula una probabilidad de victoria para esta partida ni se inventa un 50 % para enfrentamientos espejo. Los nombres equivalentes del selector y Listhammer se reconocen, por ejemplo Space Marines / Space Marines (Astartes), Agents of the Imperium / Imperial Agents y Genestealer Cults / Genestealer Cult.
|
|
594
|
+
|
|
595
|
+
No se consulta internet cada dos minutos: las curiosidades vienen incluidas con el paquete y los avisos de estadísticas usan la copia descargada al arrancar. Si Listhammer falla, se utiliza la última copia disponible indicando su fecha. Sin ninguna copia, continúan las curiosidades habilitadas y el panel avisa de que las estadísticas no están disponibles. Las pruebas usan un reloj simulado para verificar la cadencia, la expiración y la ausencia de repeticiones dentro de cada ciclo y continuidad entre ciclos, además de probar los endpoints y la entrega de eventos.
|
|
596
|
+
|
|
597
|
+
### Si no aparece un aviso
|
|
598
|
+
|
|
599
|
+
- Reinicia el servidor con esta versión del código y refresca el panel y la fuente Navegador de OBS. La fuente debe apuntar a `/overlay-art.html` en el mismo puerto que el panel.
|
|
600
|
+
- Si ejecutas una instalación global anterior, los cambios del repositorio no la actualizan automáticamente: reinstala esa versión o prueba con `npm start` desde el repositorio.
|
|
601
|
+
- Comprueba que guardaste las facciones, que hay contenido habilitado y que el panel muestra datos disponibles. El botón «Mostrar siguiente dato» permite comprobarlo sin esperar dos minutos.
|
|
602
|
+
- «Nueva sesión» borra solo el historial de avisos, no las estadísticas descargadas.
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
### Correspondencia de facciones y cobertura de una transmisión
|
|
606
|
+
|
|
607
|
+
El selector y los mensajes usan el mismo registro, `public/factions.json`. Las equivalencias de `factions.js` traducen **Space Marines (Astartes) → Space Marines**, **Imperial Agents → Agents of the Imperium** y **Genestealer Cult → Genestealer Cults**. Se toleran diferencias de mayúsculas, espacios y puntuación. Los capítulos se mantienen separados: Blood Angels no recibe estadísticas de Space Marines. Los nombres desconocidos se señalan en el panel y no reciben contenido de otra facción por semejanza.
|
|
608
|
+
|
|
609
|
+
Las estadísticas de un enfrentamiento se toman desde la perspectiva de la facción indicada en el texto. No se calcula el porcentaje del contrario restándolo de 100. Las disposiciones globales se identifican como datos de todas las facciones.
|
|
610
|
+
|
|
611
|
+
A un aviso cada dos minutos, cuatro horas requieren **120 datos distintos**. Para garantizar esa duración con solo curiosidades en cualquier combinación, incluidas partidas espejo, hacen falta 120 por facción. **Esa ampliación general todavía está pendiente**: Adepta Sororitas y Tyranids ya tienen 120 curiosidades por facción y cubren incluso partidas espejo durante 240 minutos. El catálogo actual cubre Aeldari vs Chaos Daemons durante 248 minutos, pero un espejo de cualquiera de ellas cubre 124 minutos y otras combinaciones tienen menos contenido. Las estadísticas disponibles pueden aumentar la duración; no se cuentan como una garantía porque dependen de la copia local de Listhammer.
|
|
612
|
+
|
|
613
|
+
El panel muestra el número de datos aún no vistos y su duración a intervalos de dos minutos. Advierte si no quedan cuatro horas. Usar «Mostrar siguiente dato» consume una entrada y reduce esa cobertura; los mensajes manuales no la consumen. La estimación presupone que mantienes las facciones y opciones seleccionadas.
|
|
614
|
+
|
|
615
|
+
Para revisar el catálogo desde el repositorio:
|
|
616
|
+
|
|
617
|
+
```sh
|
|
618
|
+
npm run facts:coverage
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Para exigir cuatro horas incluso en partidas espejo (termina con código 1 mientras falten curiosidades):
|
|
622
|
+
|
|
623
|
+
```sh
|
|
624
|
+
npm run facts:coverage -- --require-four-hours
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
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.
|
package/announcements.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const {randomUUID} = require('node:crypto');
|
|
2
|
+
const catalog = require('./data/faction-facts.json').facts;
|
|
3
|
+
const DURATION = 15000;
|
|
4
|
+
const INTERVAL = 120000;
|
|
5
|
+
const key = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
6
|
+
const {factionKey, canonicalFaction} = require('./factions');
|
|
7
|
+
function validRate(row) {
|
|
8
|
+
return row && Number.isInteger(row.total) && row.total > 0 && row.winRate != null && row.winRate !== '' && Number.isFinite(Number(row.winRate)) && Number(row.winRate) >= 0 && Number(row.winRate) <= 100;
|
|
9
|
+
}
|
|
10
|
+
const percent = n => Number(n).toLocaleString('es-ES', {maximumFractionDigits:1}) + ' %';
|
|
11
|
+
const sample = n => `${n} partidas${n < 10 ? ' · muestra pequeña' : ''}`;
|
|
12
|
+
function buildPool(layout, stats, settings) {
|
|
13
|
+
const players = [layout?.left, layout?.right].filter(p => canonicalFaction(p?.faction)).map(p=>({...p,faction:canonicalFaction(p.faction)}));
|
|
14
|
+
const selected = new Set(players.map(p => factionKey(p.faction)));
|
|
15
|
+
const pool = settings.lore ? catalog.filter(f => selected.has(factionKey(f.faction))).map(f => ({...f, kind:'lore', weight:2, title:'¿Sabías qué?:', footer:f.sourceTitle})) : [];
|
|
16
|
+
if (!settings.meta || !stats?.period || !Array.isArray(stats.factions) || !Array.isArray(stats.factionDetails)) return pool;
|
|
17
|
+
const sourceDate = /^\d{4}-\d{2}-\d{2}/.exec(stats.fetchedAt || '')?.[0];
|
|
18
|
+
const footer = `Listhammer · ${stats.period}${sourceDate ? ' · copia ' + sourceDate : ''}`;
|
|
19
|
+
function add(id, text, row, sourceUrl, weight = 1) {
|
|
20
|
+
if (!validRate(row)) return;
|
|
21
|
+
pool.push({id:'meta:'+id, kind:'meta', title:'¿Sabías qué?:', text, footer:footer+' · '+sample(row.total), sourceTitle:'Listhammer', sourceUrl:sourceUrl || stats.sourceUrl, weight});
|
|
22
|
+
}
|
|
23
|
+
for (const id of selected) {
|
|
24
|
+
const row = stats.factions.find(f => factionKey(f.faction) === id);
|
|
25
|
+
if (validRate(row)) add('overall:'+id, `${canonicalFaction(row.faction)} registra un ${percent(row.winRate)} de victorias en el período de Listhammer.`, row, row.sourceUrl);
|
|
26
|
+
}
|
|
27
|
+
if (players.length === 2) {
|
|
28
|
+
const [left, right] = players;
|
|
29
|
+
const a = factionKey(left.faction), b = factionKey(right.faction);
|
|
30
|
+
const detail = stats.factionDetails.find(f => factionKey(f.faction) === a);
|
|
31
|
+
const matchup = detail?.matchups?.find(m => factionKey(m.opponentFaction) === b);
|
|
32
|
+
// A mirrored matchup is omitted by the source, never inferred as 50%.
|
|
33
|
+
if (a !== b && validRate(matchup)) {
|
|
34
|
+
const pair = [a,b].sort().join(':');
|
|
35
|
+
add('matchup:'+pair, `En este cruce, ${left.faction} obtuvo un ${percent(matchup.winRate)} de victorias frente a ${right.faction}.`, matchup, detail.sourceUrl, 4);
|
|
36
|
+
if (Number.isFinite(matchup.avgDifferential) && matchup.differentialGames >= 10) {
|
|
37
|
+
const diff = matchup.avgDifferential;
|
|
38
|
+
const value = Math.abs(diff).toLocaleString('es-ES',{maximumFractionDigits:1});
|
|
39
|
+
add('differential:'+pair, `${left.faction} promedió ${value} puntos ${diff < 0 ? 'menos' : 'más'} que ${right.faction} en sus enfrentamientos.`, {...matchup,total:matchup.differentialGames}, detail.sourceUrl, 3);
|
|
40
|
+
}
|
|
41
|
+
if (validRate(matchup.goingFirst) && matchup.goingFirst.total >= 10) {
|
|
42
|
+
add('first:'+a+':'+b, `Cuando jugó primero, ${left.faction} ganó el ${percent(matchup.goingFirst.winRate)} de sus partidas contra ${right.faction}.`, matchup.goingFirst, detail.sourceUrl, 3);
|
|
43
|
+
}
|
|
44
|
+
// Also retain the other faction's first-turn record when the source has a sufficient sample.
|
|
45
|
+
const reverse = stats.factionDetails.find(f => factionKey(f.faction) === b);
|
|
46
|
+
const first = reverse?.matchups?.find(m => factionKey(m.opponentFaction) === a)?.goingFirst;
|
|
47
|
+
if (validRate(first) && first.total >= 10) add('first:'+b+':'+a, `Cuando jugó primero, ${right.faction} ganó el ${percent(first.winRate)} de sus partidas contra ${left.faction}.`, first, reverse.sourceUrl, 3);
|
|
48
|
+
}
|
|
49
|
+
const formations = [...new Set(players.map(p => p.formation || 'Take and Hold'))];
|
|
50
|
+
for (const formation of formations) {
|
|
51
|
+
const row = stats.dispositions?.overall?.find(r => r.disposition === formation);
|
|
52
|
+
if (validRate(row)) add('disposition:'+key(formation), `La disposición ${formation} registra un ${percent(row.winRate)} de victorias entre todas las facciones.`, row);
|
|
53
|
+
}
|
|
54
|
+
if (formations.length === 2) {
|
|
55
|
+
const row = stats.dispositions?.matchups?.find(r => r.disposition === formations[0] && r.opponentDisposition === formations[1]);
|
|
56
|
+
if (validRate(row)) add('dispositions:'+formations.map(key).sort().join(':'), `${formations[0]} obtuvo un ${percent(row.winRate)} de victorias contra ${formations[1]}, contando todas las facciones.`, row, null, 2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return pool;
|
|
60
|
+
}
|
|
61
|
+
class Announcements {
|
|
62
|
+
constructor({layout, stats, saved = {}, save = () => {}, emit = () => {}, now = Date.now, random = Math.random}) {
|
|
63
|
+
Object.assign(this, {layout, stats, save, emit, now, random});
|
|
64
|
+
this.settings = {enabled: saved.settings?.enabled === true, lore:saved.settings?.lore !== false, meta:saved.settings?.meta !== false};
|
|
65
|
+
this.seen = new Set(Array.isArray(saved.seen) ? saved.seen.filter(s => typeof s === 'string') : []);
|
|
66
|
+
this.lastId = saved.lastId || null;
|
|
67
|
+
this.sessionId = saved.sessionId || randomUUID();
|
|
68
|
+
this.cycle = Number.isInteger(saved.cycle) && saved.cycle > 0 ? saved.cycle : 1;
|
|
69
|
+
this.active = null;
|
|
70
|
+
this.nextAt = this.settings.enabled ? now() + INTERVAL : null;
|
|
71
|
+
}
|
|
72
|
+
pool() { return buildPool(this.layout(), this.stats(), this.settings); }
|
|
73
|
+
persist() { this.save({schemaVersion:1,sessionId:this.sessionId,settings:this.settings,seen:[...this.seen],lastId:this.lastId,cycle:this.cycle}); }
|
|
74
|
+
status() {
|
|
75
|
+
const pool = this.pool();
|
|
76
|
+
const remaining = pool.filter(f=>!this.seen.has(f.id)).length;
|
|
77
|
+
const selected = [this.layout()?.left?.faction, this.layout()?.right?.faction].filter(Boolean);
|
|
78
|
+
const unmatchedFactions = [...new Set(selected.filter(name=>!canonicalFaction(name)))];
|
|
79
|
+
return {unmatchedFactions, exhausted:pool.length > 0 && remaining === 0, cyclic:true, loreTotal:pool.filter(f=>f.kind==='lore').length, metaTotal:pool.filter(f=>f.kind==='meta').length, fourHourReady:remaining >= 120, settings:this.settings,sessionId:this.sessionId,cycle:this.cycle,total:pool.length,remaining:pool.filter(f=>!this.seen.has(f.id)).length,coverageMinutes:pool.length*INTERVAL/60000,remainingMinutes:pool.filter(f=>!this.seen.has(f.id)).length*INTERVAL/60000,nextAt:this.nextAt,active:this.current(),statsAvailable:!!this.stats()?.factions?.length};
|
|
80
|
+
}
|
|
81
|
+
current() { return this.active?.expiresAt > this.now() ? {...this.active,remainingMs:this.active.expiresAt-this.now()} : null; }
|
|
82
|
+
refresh() { this.emit('status',this.status()); }
|
|
83
|
+
configure(input) {
|
|
84
|
+
if (!input || typeof input !== 'object' || Object.keys(input).some(k => !['enabled','lore','meta'].includes(k) || typeof input[k] !== 'boolean')) throw new Error('Configuración de mensajes no válida.');
|
|
85
|
+
const enabled = this.settings.enabled;
|
|
86
|
+
this.settings = {...this.settings,...input};
|
|
87
|
+
if (this.settings.enabled && !this.settings.lore && !this.settings.meta) { this.settings.enabled = false; }
|
|
88
|
+
if (enabled !== this.settings.enabled) this.nextAt = this.settings.enabled ? this.now()+INTERVAL : null;
|
|
89
|
+
this.persist(); this.refresh();
|
|
90
|
+
}
|
|
91
|
+
show(item) {
|
|
92
|
+
const now = this.now();
|
|
93
|
+
this.active = {...item,token:randomUUID(),startedAt:now,expiresAt:now+DURATION};
|
|
94
|
+
if (this.settings.enabled) this.nextAt = now+INTERVAL;
|
|
95
|
+
this.emit('toast',this.current()); this.refresh();
|
|
96
|
+
return this.current();
|
|
97
|
+
}
|
|
98
|
+
manual(text) {
|
|
99
|
+
if (typeof text !== 'string' || !text.trim() || text.trim().length > 280) throw new Error('Escribe un mensaje de 1 a 280 caracteres.');
|
|
100
|
+
return this.show({kind:'manual',title:'EN DIRECTO',text:text.trim().replace(/\s+/g, ' '),footer:''});
|
|
101
|
+
}
|
|
102
|
+
next() {
|
|
103
|
+
const pool = this.pool();
|
|
104
|
+
if (!pool.length) throw new Error('No hay datos disponibles para estas facciones y opciones. Activa curiosidades o estadísticas y guarda las facciones del encuentro.');
|
|
105
|
+
let choices = pool.filter(f=>!this.seen.has(f.id));
|
|
106
|
+
if (!choices.length) {
|
|
107
|
+
// Start another pass through the current pool, retaining history for other matchups.
|
|
108
|
+
for (const item of pool) this.seen.delete(item.id);
|
|
109
|
+
this.cycle += 1;
|
|
110
|
+
choices = pool.length > 1 ? pool.filter(f=>f.id !== this.lastId) : pool;
|
|
111
|
+
}
|
|
112
|
+
let draw = this.random()*choices.reduce((sum,f)=>sum+f.weight,0);
|
|
113
|
+
const chosen = choices.find(f=>(draw-=f.weight)<0) || choices.at(-1);
|
|
114
|
+
this.seen.add(chosen.id); this.lastId = chosen.id;
|
|
115
|
+
this.persist();
|
|
116
|
+
return this.show(chosen);
|
|
117
|
+
}
|
|
118
|
+
tick(hasAudience) {
|
|
119
|
+
if (!this.settings.enabled || this.now() < this.nextAt) return;
|
|
120
|
+
this.nextAt = this.now()+INTERVAL;
|
|
121
|
+
if (hasAudience && !this.current() && this.pool().length > 0) this.next();
|
|
122
|
+
else this.refresh();
|
|
123
|
+
}
|
|
124
|
+
reset() {
|
|
125
|
+
this.seen.clear(); this.lastId=null; this.cycle=1; this.sessionId=randomUUID();
|
|
126
|
+
this.active=null; this.nextAt=this.settings.enabled ? this.now()+INTERVAL : null;
|
|
127
|
+
this.persist(); this.emit('toast',null); this.refresh();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
module.exports = {Announcements,buildPool,factionKey,DURATION,INTERVAL};
|