@ing.jorgeu/wargaming-overlay 2.2.0 → 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 +170 -14
- package/announcements.js +130 -0
- package/battle_reader.py +9 -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/factions.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const groups = require('./public/factions.json');
|
|
2
|
+
const normalize = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
3
|
+
const names = Object.values(groups).flat().filter(name=>name!=='Custom');
|
|
4
|
+
const alternateNames = {
|
|
5
|
+
'Space Marines': ['Space Marines (Astartes)', 'Adeptus Astartes'],
|
|
6
|
+
'Agents of the Imperium': ['Imperial Agents'],
|
|
7
|
+
'Genestealer Cults': ['Genestealer Cult'],
|
|
8
|
+
"T'au Empire": ['Tau', 'Tau Empire', 'T’au Empire'],
|
|
9
|
+
'Aeldari': ['Eldar', 'Craftworld Eldar', 'Asuryani'],
|
|
10
|
+
'Chaos Daemons': ['Chaos Demons', 'Daemons of Chaos']
|
|
11
|
+
};
|
|
12
|
+
const lookup = new Map();
|
|
13
|
+
for (const name of names) for (const alias of [name,...(alternateNames[name] || [])]) {
|
|
14
|
+
const id=normalize(alias);
|
|
15
|
+
if (lookup.has(id) && lookup.get(id)!==name) throw new Error('Alias de facción ambiguo: '+alias);
|
|
16
|
+
lookup.set(id,name);
|
|
17
|
+
}
|
|
18
|
+
const canonicalFaction = value => lookup.get(normalize(value)) || null;
|
|
19
|
+
const factionKey = value => {const name=canonicalFaction(value);return name ? normalize(name) : null;};
|
|
20
|
+
module.exports={names,groups,alternateNames,canonicalFaction,factionKey};
|
package/game-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const {inferTurn}=require('./turn-tracker');
|
|
1
2
|
const emptyGame=()=>({players:[],connected:false,updatedAt:null,error:null});
|
|
2
3
|
function mergeGame(previous,incoming){
|
|
3
4
|
if(!incoming.connected || !Array.isArray(incoming.players) || incoming.players.length!==2)
|
|
@@ -12,6 +13,8 @@ function mergeGame(previous,incoming){
|
|
|
12
13
|
}
|
|
13
14
|
return merged;
|
|
14
15
|
});
|
|
15
|
-
|
|
16
|
+
const samePlayers=previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
|
|
17
|
+
const turn=inferTurn(previous,incoming,samePlayers);
|
|
18
|
+
return {...incoming,players,turn,half:incoming.round===turn.highestRound?turn.half:null};
|
|
16
19
|
}
|
|
17
20
|
module.exports={emptyGame,mergeGame};
|
package/listhammer.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const {setTimeout: delay} = require('node:timers/promises');
|
|
4
|
+
const ORIGIN = 'https://listhammer.info';
|
|
5
|
+
|
|
6
|
+
// Decode only JSON reference data; never execute scripts from the source page.
|
|
7
|
+
function pageData(html) {
|
|
8
|
+
const script = html.match(/<script\b[^>]*\bid=["']__NUXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
9
|
+
if (!script) throw new Error('Listhammer cambió de formato: faltan los datos de la página.');
|
|
10
|
+
const values = JSON.parse(script[1]);
|
|
11
|
+
const active = new Set();
|
|
12
|
+
function decode(index, depth = 0) {
|
|
13
|
+
if (index === -1) return null;
|
|
14
|
+
if (!Number.isInteger(index) || index < 0 || index >= values.length || depth > 80 || active.has(index)) throw new Error('Referencias de Listhammer no válidas.');
|
|
15
|
+
active.add(index);
|
|
16
|
+
const value = values[index];
|
|
17
|
+
let result = value;
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
if (typeof value[0] === 'string') {
|
|
20
|
+
if (!['ShallowReactive', 'Reactive', 'Ref', 'ShallowRef'].includes(value[0])) throw new Error('Formato de Listhammer no compatible.');
|
|
21
|
+
result = decode(value[1], depth + 1);
|
|
22
|
+
} else result = value.map(i => decode(i, depth + 1));
|
|
23
|
+
} else if (value && typeof value === 'object') {
|
|
24
|
+
result = Object.fromEntries(Object.entries(value).map(([key, i]) => [key, decode(i, depth + 1)]));
|
|
25
|
+
}
|
|
26
|
+
active.delete(index);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
let root = values[0];
|
|
30
|
+
if (Array.isArray(root)) root = values[root[1]];
|
|
31
|
+
let data = values[root.data];
|
|
32
|
+
if (Array.isArray(data)) data = values[data[1]];
|
|
33
|
+
// Recent lists contain player information and are deliberately excluded.
|
|
34
|
+
return Object.entries(data).filter(([key]) => !key.startsWith('recentLists')).map(([, i]) => decode(i));
|
|
35
|
+
}
|
|
36
|
+
function period(html) {
|
|
37
|
+
const value = html.match(/>\s*(\d{1,2} [A-Z][a-z]{2} - \d{1,2} [A-Z][a-z]{2} \d{4})\s*</);
|
|
38
|
+
if (!value) throw new Error('No se pudo identificar el período de Listhammer.');
|
|
39
|
+
return value[1];
|
|
40
|
+
}
|
|
41
|
+
function normalize(name) {
|
|
42
|
+
return name.replace(/&/g, '&').replace(/'|'/g, "'").replace(/\(Astartes\)/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
43
|
+
}
|
|
44
|
+
function checkRows(rows, key) {
|
|
45
|
+
if (!Array.isArray(rows) || rows.some(r => !r || typeof r[key] !== 'string' || !Number.isFinite(r.total) || r.total < 0 || !Number.isFinite(Number(r.winRate)) || Number(r.winRate) < 0 || Number(r.winRate) > 100)) throw new Error('Tabla de estadísticas de Listhammer no válida.');
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
function parseOverview(html) {
|
|
49
|
+
const data = pageData(html);
|
|
50
|
+
const factions = data.find(d => Array.isArray(d?.result) && d.result[0]?.faction)?.result;
|
|
51
|
+
const dispositions = data.find(d => Array.isArray(d?.overall) && d.overall[0]?.disposition);
|
|
52
|
+
if (!factions?.length || !dispositions?.overall?.length) throw new Error('Faltan tablas de facciones o disposiciones.');
|
|
53
|
+
checkRows(factions, 'faction'); checkRows(dispositions.overall, 'disposition'); checkRows(dispositions.matchups, 'opponentDisposition');
|
|
54
|
+
const links = new Map();
|
|
55
|
+
for (const match of html.matchAll(/<a\b[^>]*href="(\/factions\/[a-z0-9-]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
|
56
|
+
const label = match[2].replace(/<[^>]*>/g, '').trim();
|
|
57
|
+
links.set(normalize(label), match[1]);
|
|
58
|
+
}
|
|
59
|
+
return {period: period(html), factions: factions.map(row => {
|
|
60
|
+
const link = links.get(normalize(row.faction));
|
|
61
|
+
if (!link) throw new Error(`Falta el enlace de ${row.faction}.`);
|
|
62
|
+
return {...row, sourceUrl: ORIGIN + link};
|
|
63
|
+
}), dispositions: {overall: dispositions.overall, matchups: dispositions.matchups}};
|
|
64
|
+
}
|
|
65
|
+
function parseFaction(html, expectedName, expectedPeriod) {
|
|
66
|
+
const data = pageData(html).find(d => d?.faction === expectedName && d.gameType === '40k' && d.headline);
|
|
67
|
+
if (!data || period(html) !== expectedPeriod) throw new Error(`Datos o período incompatibles para ${expectedName}.`);
|
|
68
|
+
checkRows(data.matchups, 'opponentFaction');
|
|
69
|
+
// Explicit allowlist avoids storing player names, lists or identifiers.
|
|
70
|
+
return Object.fromEntries(['faction', 'headline', 'overall', 'players', 'undefeated', 'xMinus1', 'overrep', 'eventWins', 'eventCount', 'weekly', 'matchups', 'dispositions', 'detachments'].filter(k => k in data).map(k => [k, data[k]]));
|
|
71
|
+
}
|
|
72
|
+
async function download(url) {
|
|
73
|
+
const response = await fetch(url, {signal: AbortSignal.timeout(20000), redirect: 'error', headers: {'User-Agent': 'wargaming-overlay (local statistics cache)', Accept: 'text/html'}});
|
|
74
|
+
if (!response.ok) throw new Error(`Listhammer respondió HTTP ${response.status} (${url}).`);
|
|
75
|
+
const chunks = []; let size = 0;
|
|
76
|
+
for await (const chunk of response.body) {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > 8 * 1024 * 1024) throw new Error('La página de Listhammer supera el tamaño permitido.');
|
|
79
|
+
chunks.push(chunk);
|
|
80
|
+
}
|
|
81
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
82
|
+
}
|
|
83
|
+
async function syncStats(dataDir, {fetchPage = download, pause = () => delay(250), log = console.log} = {}) {
|
|
84
|
+
const destination = path.join(dataDir, 'listhammer-stats.json');
|
|
85
|
+
log('Listhammer: descargando estadísticas en segundo plano…');
|
|
86
|
+
const overview = parseOverview(await fetchPage(ORIGIN + '/stats'));
|
|
87
|
+
const details = [];
|
|
88
|
+
for (const row of overview.factions) {
|
|
89
|
+
await pause();
|
|
90
|
+
const detail = parseFaction(await fetchPage(row.sourceUrl), row.faction, overview.period);
|
|
91
|
+
for (const key of ['wins', 'losses', 'total', 'winRate']) {
|
|
92
|
+
if (Number(detail.headline[key]) !== Number(row[key])) throw new Error(`Listhammer cambió durante la descarga (${row.faction}). Reintenta al iniciar de nuevo.`);
|
|
93
|
+
}
|
|
94
|
+
details.push({...detail, sourceUrl: row.sourceUrl});
|
|
95
|
+
}
|
|
96
|
+
const snapshot = {schemaVersion: 1, sourceUrl: ORIGIN + '/stats', fetchedAt: new Date().toISOString(), selection: {window: 'This Weekend', period: overview.period, includeRTTs: false, gameType: '40k', edition: 11, points: 2000, minimumRounds: 5, minimumPlayers: 16, mirrorMatchesExcluded: true}, ...overview, factionDetails: details};
|
|
97
|
+
await fs.mkdir(dataDir, {recursive: true});
|
|
98
|
+
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
await fs.writeFile(temporary, JSON.stringify(snapshot, null, 2) + '\n');
|
|
101
|
+
await fs.rename(temporary, destination);
|
|
102
|
+
} finally { await fs.rm(temporary, {force: true}); }
|
|
103
|
+
log(`Listhammer: ${details.length} facciones y ${overview.dispositions.overall.length} disposiciones guardadas en ${destination}`);
|
|
104
|
+
return snapshot;
|
|
105
|
+
}
|
|
106
|
+
module.exports = {pageData, parseOverview, parseFaction, syncStats};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ing.jorgeu/wargaming-overlay",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Overlay local para OBS con lectura Android por ADB",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wargaming-overlay": "cli.js"
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "node cli.js start",
|
|
10
10
|
"test": "node --test",
|
|
11
|
-
"prepublishOnly": "npm test"
|
|
11
|
+
"prepublishOnly": "npm test",
|
|
12
|
+
"facts:coverage": "node scripts/fact-coverage.js"
|
|
12
13
|
},
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=20"
|
|
@@ -20,12 +21,18 @@
|
|
|
20
21
|
"reader.py",
|
|
21
22
|
"battle_reader.py",
|
|
22
23
|
"game-store.js",
|
|
24
|
+
"turn-tracker.js",
|
|
23
25
|
"public",
|
|
24
26
|
"README.md",
|
|
25
27
|
"platform.js",
|
|
26
28
|
"PUBLISHING.md",
|
|
27
29
|
"THIRD_PARTY_ASSETS.md",
|
|
28
|
-
"LICENSE"
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"listhammer.js",
|
|
32
|
+
"announcements.js",
|
|
33
|
+
"data/faction-facts.json",
|
|
34
|
+
"factions.js",
|
|
35
|
+
"scripts/fact-coverage.js"
|
|
29
36
|
],
|
|
30
37
|
"license": "0BSD",
|
|
31
38
|
"publishConfig": {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const $ = id => document.getElementById(id);
|
|
3
|
+
const feedback = $('toast-feedback');
|
|
4
|
+
const controls = ['auto-facts','include-lore','include-meta'];
|
|
5
|
+
let settings = null;
|
|
6
|
+
let lastStatus = null;
|
|
7
|
+
function render(status) {
|
|
8
|
+
lastStatus = status;
|
|
9
|
+
settings = status.settings;
|
|
10
|
+
$('auto-facts').checked=settings.enabled; $('include-lore').checked=settings.lore; $('include-meta').checked=settings.meta;
|
|
11
|
+
const minutes = status.remainingMinutes || 0;
|
|
12
|
+
const coverage = `${Math.floor(minutes/60)} h ${minutes%60} min hasta completar este ciclo`;
|
|
13
|
+
const exhausted = status.exhausted ? ' Ciclo completado; el siguiente dato iniciará otro automáticamente.' : '';
|
|
14
|
+
const shortfall = status.remaining < 120 && !status.exhausted ? ' La rotación continúa al completar cada ciclo.' : '';
|
|
15
|
+
const unmatched = status.unmatchedFactions?.length ? ` Sin correspondencia: ${status.unmatchedFactions.join(', ')}.` : '';
|
|
16
|
+
$('rotation-status').textContent = `${settings.enabled ? 'Rotación activada · cada 2 min' : 'Rotación automática pausada'} · ${status.total} datos disponibles (${status.loreTotal} curiosidades + ${status.metaTotal} estadísticas) · ciclo ${status.cycle} · ${status.remaining} pendientes en este ciclo · ${coverage}.${exhausted}${shortfall}${unmatched}${settings.meta && !status.statsAvailable ? ' Listhammer todavía no está disponible; se usarán las curiosidades habilitadas.' : ''}`;
|
|
17
|
+
$('next-fact').disabled=!status.total;
|
|
18
|
+
}
|
|
19
|
+
function preview(toast) {
|
|
20
|
+
if (!toast) return;
|
|
21
|
+
$('last-toast').textContent=toast.title+' '+toast.text;
|
|
22
|
+
const link=$('toast-source'); link.hidden=true; link.removeAttribute('href');
|
|
23
|
+
if (toast.sourceUrl) {
|
|
24
|
+
try {const url=new URL(toast.sourceUrl);if(url.protocol==='https:'){link.href=url.href;link.textContent='Fuente: '+(toast.sourceTitle || url.hostname);link.hidden=false;}} catch {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function request(route, body={}) {
|
|
28
|
+
const response=await fetch('/toasts/'+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
29
|
+
const result=await response.json(); if(!response.ok) throw new Error(result.error || 'No se pudo actualizar.');
|
|
30
|
+
render(result.status); if(result.toast) preview(result.toast); return result;
|
|
31
|
+
}
|
|
32
|
+
async function action(button, work) {
|
|
33
|
+
button.disabled=true;
|
|
34
|
+
try {await work();} catch(error) {feedback.textContent=error.message || 'Servidor no disponible.';}
|
|
35
|
+
finally {button.disabled=false; if(lastStatus) render(lastStatus);}
|
|
36
|
+
}
|
|
37
|
+
$('toast-message').addEventListener('input',()=>{$('message-count').textContent=$('toast-message').value.length+' / 280';});
|
|
38
|
+
$('message-form').addEventListener('submit', event => {
|
|
39
|
+
event.preventDefault(); action(event.submitter,async()=>{await request('message',{text:$('toast-message').value});feedback.textContent='Mensaje enviado al overlay durante 15 segundos.';});
|
|
40
|
+
});
|
|
41
|
+
$('next-fact').addEventListener('click',event=>action(event.currentTarget,async()=>{await request('next');feedback.textContent='Siguiente dato enviado al overlay durante 15 segundos.';}));
|
|
42
|
+
controls.forEach(id=>$(id).addEventListener('change',async()=>{
|
|
43
|
+
controls.forEach(id=>$(id).disabled=true);
|
|
44
|
+
try {await request('config',{enabled:$('auto-facts').checked,lore:$('include-lore').checked,meta:$('include-meta').checked});feedback.textContent='Opciones guardadas.';}
|
|
45
|
+
catch(error){feedback.textContent=error.message;if(lastStatus)render(lastStatus);}
|
|
46
|
+
finally{controls.forEach(id=>$(id).disabled=false);}
|
|
47
|
+
}));
|
|
48
|
+
$('new-toast-session').addEventListener('click',event=>action(event.currentTarget,async()=>{
|
|
49
|
+
await request('reset');$('last-toast').textContent='Nueva sesión: el historial de mensajes está vacío.';$('toast-source').hidden=true;feedback.textContent='Nueva sesión de mensajes iniciada. La partida se conserva.';
|
|
50
|
+
}));
|
|
51
|
+
const events=new EventSource('/toasts/events?client=control');
|
|
52
|
+
events.addEventListener('status',event=>render(JSON.parse(event.data)));
|
|
53
|
+
events.addEventListener('toast',event=>preview(JSON.parse(event.data)));
|
|
54
|
+
events.onerror=()=>{feedback.textContent='Sin conexión con el servidor. Intentando reconectar…';};
|
|
55
|
+
events.onopen=()=>{feedback.textContent='Panel de mensajes conectado.';};
|
|
56
|
+
window.addEventListener('pagehide',()=>events.close());
|
|
57
|
+
})();
|
package/public/control.html
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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><button>Actualizar overlay</button><p id="saved" aria-live="polite"></p></form><section><h2>Datos de la partida</h2><p id="phone-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><
|
|
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">
|
|
4
|
+
<h2 id="announcement-heading">Mensajes en pantalla</h2>
|
|
5
|
+
<p>Se muestran abajo durante 15 segundos. Primero guarda las facciones y disposiciones del encuentro.</p>
|
|
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>
|
|
7
|
+
<div class="announcement-options"><label><input type="checkbox" id="auto-facts"> Mostrar un dato cada 2 minutos</label><label><input type="checkbox" id="include-lore" checked> Curiosidades de las facciones</label><label><input type="checkbox" id="include-meta" checked> Estadísticas y enfrentamiento actual</label></div>
|
|
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
|
+
<p id="rotation-status" aria-live="polite"></p><p id="toast-feedback" role="status"></p>
|
|
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>
|
|
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
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
'Imperium': [
|
|
3
|
-
'Adepta Sororitas', 'Adeptus Custodes', 'Adeptus Mechanicus', 'Agents of the Imperium',
|
|
4
|
-
'Astra Militarum', 'Black Templars', 'Blood Angels', 'Dark Angels', 'Deathwatch',
|
|
5
|
-
'Grey Knights', 'Imperial Knights', 'Space Marines', 'Space Wolves'
|
|
6
|
-
],
|
|
7
|
-
'Chaos': [
|
|
8
|
-
'Chaos Daemons', 'Chaos Knights', 'Chaos Space Marines', 'Death Guard',
|
|
9
|
-
'Emperor\'s Children', 'Thousand Sons', 'World Eaters'
|
|
10
|
-
],
|
|
11
|
-
'Xenos': [
|
|
12
|
-
'Aeldari', 'Drukhari', 'Genestealer Cults', 'Leagues of Votann', 'Necrons',
|
|
13
|
-
'Orks', 'T\'au Empire', 'Tyranids'
|
|
14
|
-
],
|
|
15
|
-
'Other': ['Custom']
|
|
16
|
-
};
|
|
1
|
+
let factions;
|
|
17
2
|
let state;
|
|
18
3
|
const players = document.querySelector('#players');
|
|
19
4
|
const formations = ['Take and Hold', 'Purge the Foe', 'Disruption', 'Reconnaissance', 'Priority Assets'];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
5
|
+
const escapeAttribute=value=>String(value??'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
6
|
+
function card(side, label, player) { const options = Object.entries(factions).map(([group, names]) => `<optgroup label="${group}">${names.map(name => `<option ${name === player.faction ? 'selected' : ''}>${name}</option>`).join('')}</optgroup>`).join(''); const formation = formations.includes(player.formation) ? player.formation : 'Take and Hold'; const detachments = Array.isArray(player.detachments) ? player.detachments : []; return `<fieldset><legend>${label}</legend><label>Nombre<input data-side="${side}" data-key="name" maxlength="80" value="${escapeAttribute(player.name)}"></label><label>Nombre en la app (opcional)<input data-side="${side}" data-key="appName" maxlength="80" value="${(player.appName || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')}"></label><label>Facción<select data-side="${side}" data-key="faction">${options}</select></label><label>Disposición<select data-side="${side}" data-key="formation">${formations.map(name => `<option ${name === formation ? 'selected' : ''}>${name}</option>`).join('')}</select></label><label>Destacamento 1 (opcional)<input data-side="${side}" data-detachment="0" maxlength="42" value="${escapeAttribute(detachments[0])}"></label><label>Destacamento 2 (opcional)<input data-side="${side}" data-detachment="1" maxlength="42" value="${escapeAttribute(detachments[1])}"></label><label>Destacamento 3 (opcional)<input data-side="${side}" data-detachment="2" maxlength="42" value="${escapeAttribute(detachments[2])}"></label><label>Color de fondo<input data-side="${side}" data-key="color" type="color" value="${player.color}"></label></fieldset>`; }
|
|
7
|
+
function renderPlayers(){
|
|
8
|
+
players.innerHTML=card('left','Jugador izquierda',state.left)+card('right','Jugador derecha',state.right);
|
|
9
|
+
document.querySelectorAll('input[type=color]').forEach(input=>input.closest('label').remove());
|
|
10
|
+
}
|
|
11
|
+
function readForm(){
|
|
12
|
+
const next=structuredClone(state);
|
|
13
|
+
next.title=document.querySelector('#title').value;
|
|
14
|
+
delete next.roundHalf;
|
|
15
|
+
document.querySelectorAll('[data-side][data-key]').forEach(i=>next[i.dataset.side][i.dataset.key]=i.value);
|
|
16
|
+
['left','right'].forEach(side=>{next[side].detachments=[...document.querySelectorAll(`[data-side="${side}"][data-detachment]`)].map(i=>i.value.trim()).filter(Boolean);});
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
async function saveLayout(swap=false){
|
|
20
|
+
const buttons=[...document.querySelectorAll('#form button')];
|
|
21
|
+
buttons.forEach(b=>b.disabled=true);
|
|
22
|
+
try {
|
|
23
|
+
const form=readForm();
|
|
24
|
+
const next=swap?MatchView.swapPlayers(form):form;
|
|
25
|
+
const response=await fetch('/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});
|
|
26
|
+
if(!response.ok)throw Error('No se pudo guardar');
|
|
27
|
+
state=next;renderPlayers();
|
|
28
|
+
document.querySelector('#saved').textContent=swap?'Jugadores intercambiados. Overlay actualizado.':'Overlay actualizado.';
|
|
29
|
+
}catch{
|
|
30
|
+
document.querySelector('#saved').textContent='No se pudo confirmar el cambio. Comprueba la conexión con el servidor y recarga el panel antes de intentarlo de nuevo.';
|
|
31
|
+
}finally{buttons.forEach(b=>b.disabled=false);}
|
|
32
|
+
}
|
|
33
|
+
async function init(){
|
|
34
|
+
factions=await fetch('/factions.json').then(r=>{if(!r.ok)throw Error('No se pudieron cargar las facciones.');return r.json();});
|
|
35
|
+
state=await fetch('/state').then(r=>{if(!r.ok)throw Error('No se pudo cargar el overlay.');return r.json();});
|
|
36
|
+
document.querySelector('#title').value=state.title;
|
|
37
|
+
renderPlayers();
|
|
38
|
+
document.querySelectorAll('#form button').forEach(b=>b.disabled=false);
|
|
39
|
+
}
|
|
40
|
+
document.querySelector('#form').addEventListener('submit',e=>{e.preventDefault();if(state)saveLayout();});
|
|
41
|
+
document.querySelector('#swap-players').addEventListener('click',()=>saveLayout(true));
|
|
42
|
+
init().catch(()=>{document.querySelector('#saved').textContent='No se pudo cargar el panel. Comprueba el servidor y recarga la página.';});
|
|
23
43
|
|
|
24
44
|
document.querySelector('#reset-game').addEventListener('click',async()=>{
|
|
25
45
|
const button=document.querySelector('#reset-game');button.disabled=true;
|
|
@@ -27,7 +47,7 @@ document.querySelector('#reset-game').addEventListener('click',async()=>{
|
|
|
27
47
|
catch {document.querySelector('#reset-status').textContent='No se pudo restablecer. Comprueba que el servidor esté actualizado y funcionando.';}
|
|
28
48
|
finally{button.disabled=false}
|
|
29
49
|
});
|
|
30
|
-
async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();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();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();
|
|
31
51
|
|
|
32
52
|
document.querySelector('#obs-url').textContent=location.origin+'/overlay-art.html';
|
|
33
53
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Imperium": [
|
|
3
|
+
"Adepta Sororitas",
|
|
4
|
+
"Adeptus Custodes",
|
|
5
|
+
"Adeptus Mechanicus",
|
|
6
|
+
"Agents of the Imperium",
|
|
7
|
+
"Astra Militarum",
|
|
8
|
+
"Black Templars",
|
|
9
|
+
"Blood Angels",
|
|
10
|
+
"Dark Angels",
|
|
11
|
+
"Deathwatch",
|
|
12
|
+
"Grey Knights",
|
|
13
|
+
"Imperial Knights",
|
|
14
|
+
"Space Marines",
|
|
15
|
+
"Space Wolves"
|
|
16
|
+
],
|
|
17
|
+
"Chaos": [
|
|
18
|
+
"Chaos Daemons",
|
|
19
|
+
"Chaos Knights",
|
|
20
|
+
"Chaos Space Marines",
|
|
21
|
+
"Death Guard",
|
|
22
|
+
"Emperor's Children",
|
|
23
|
+
"Thousand Sons",
|
|
24
|
+
"World Eaters"
|
|
25
|
+
],
|
|
26
|
+
"Xenos": [
|
|
27
|
+
"Aeldari",
|
|
28
|
+
"Drukhari",
|
|
29
|
+
"Genestealer Cults",
|
|
30
|
+
"Leagues of Votann",
|
|
31
|
+
"Necrons",
|
|
32
|
+
"Orks",
|
|
33
|
+
"T'au Empire",
|
|
34
|
+
"Tyranids"
|
|
35
|
+
],
|
|
36
|
+
"Other": [
|
|
37
|
+
"Custom"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/public/live-score.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
(() => {
|
|
2
|
-
const normalize=s=>(s||'').normalize('NFKC').trim().replace(/\s+/g,' ').toLocaleLowerCase();
|
|
3
2
|
const make=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
|
|
4
3
|
const panel=make('section','','live-score');document.querySelector('#overlay-stage').append(panel);
|
|
5
4
|
let settings=null,game=null;
|
|
6
5
|
function renderLive(){
|
|
7
6
|
panel.replaceChildren();if(!settings)return;
|
|
8
|
-
const matches=
|
|
9
|
-
|
|
10
|
-
const round=make('div',matches.every(Boolean)?`RONDA ${game.round}`:'RONDA —','live-round');panel.append(round);
|
|
7
|
+
const matches=MatchView.matchPlayers(settings,game);
|
|
8
|
+
const round=make('div',MatchView.roundLabel(settings,game,matches.every(Boolean)),'live-round');panel.append(round);
|
|
11
9
|
['left','right'].forEach((side,i)=>{
|
|
12
10
|
const p=matches[i],card=make('article','',`live-player ${side}`);
|
|
13
11
|
const value=v=>v==null?'—':typeof v==='object'?`${v.score}/${v.max}`:v;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
const normalize = value => String(value || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
|
|
3
|
+
function swapPlayers(layout) {
|
|
4
|
+
const next = structuredClone(layout);
|
|
5
|
+
[next.left, next.right] = [next.right, next.left];
|
|
6
|
+
return next;
|
|
7
|
+
}
|
|
8
|
+
function matchPlayers(layout, game) {
|
|
9
|
+
const matches = ['left', 'right'].map(side => {
|
|
10
|
+
const key = normalize(layout[side].appName || layout[side].name);
|
|
11
|
+
if (!key) return null;
|
|
12
|
+
const found = (game?.players || []).filter(player => normalize(player.name) === key);
|
|
13
|
+
return found.length === 1 ? found[0] : null;
|
|
14
|
+
});
|
|
15
|
+
if (matches[0] === matches[1]) matches.fill(null);
|
|
16
|
+
return matches;
|
|
17
|
+
}
|
|
18
|
+
function roundLabel(layout, game, matched) {
|
|
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}` : ''}`;
|
|
22
|
+
}
|
|
23
|
+
const api = {swapPlayers, matchPlayers, roundLabel};
|
|
24
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
25
|
+
else root.MatchView = api;
|
|
26
|
+
})(globalThis);
|
package/public/overlay-art.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!doctype html><html><head><meta charset="utf-8"><title>Battle Report Overlay</title><link rel="stylesheet" href="overlay-v4-pre-art.css"><link rel="stylesheet" href="generic-texture.css?v=1"><link rel="stylesheet" href="faction-medallion.css?v=1"><link rel="stylesheet" href="formations.css?v=1"><link rel="stylesheet" href="battle-title.css?v=1"><link rel="stylesheet" href="detachments.css?v=1"><link rel="stylesheet" href="live-score.css?v=1"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="resolution.css"></head><body class="overlay"><main id="overlay-stage"><div class="battle-title" id="battleTitle"></div><section class="upper"><article class="team left"><div class="faction" id="leftFaction"></div><div class="name fit" id="leftName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article><div class="versus">VS</div><article class="team right"><div class="faction" id="rightFaction"></div><div class="name fit" id="rightName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article></section><div class="formation left" id="leftFormation"><span class="formation-mark">I</span><span>Disposición I</span></div><div class="formation right" id="rightFormation"><span>Disposición I</span><span class="formation-mark">I</span></div><div class="detachment-list left" id="leftDetachments"></div><div class="detachment-list right" id="rightDetachments"></div></main><script src="resolution.js"></script><script src="overlay.js?detachments=1"></script><script src="live-score.js?v=
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>Battle Report Overlay</title><link rel="stylesheet" href="overlay-v4-pre-art.css"><link rel="stylesheet" href="generic-texture.css?v=1"><link rel="stylesheet" href="faction-medallion.css?v=1"><link rel="stylesheet" href="formations.css?v=1"><link rel="stylesheet" href="battle-title.css?v=1"><link rel="stylesheet" href="detachments.css?v=1"><link rel="stylesheet" href="live-score.css?v=1"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="resolution.css"><link rel="stylesheet" href="toast.css"></head><body class="overlay"><main id="overlay-stage"><div class="battle-title" id="battleTitle"></div><section class="upper"><article class="team left"><div class="faction" id="leftFaction"></div><div class="name fit" id="leftName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article><div class="versus">VS</div><article class="team right"><div class="faction" id="rightFaction"></div><div class="name fit" id="rightName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article></section><div class="formation left" id="leftFormation"><span class="formation-mark">I</span><span>Disposición I</span></div><div class="formation right" id="rightFormation"><span>Disposición I</span><span class="formation-mark">I</span></div><div class="detachment-list left" id="leftDetachments"></div><div class="detachment-list right" id="rightDetachments"></div><aside id="stream-toast" class="stream-toast" role="status" aria-live="polite" aria-hidden="true"><div class="toast-heading" id="toast-heading"></div><p id="toast-text"></p><div class="toast-footer" id="toast-footer"></div></aside></main><script src="resolution.js"></script><script src="overlay.js?detachments=1"></script><script src="match-view.js"></script><script src="live-score.js?v=2"></script><script src="toast.js"></script></body></html>
|
package/public/style.css
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
1
|
*{box-sizing:border-box}body{margin:0;font-family:Arial,sans-serif}.control{min-height:100vh;background:#11151d;color:#e8e4d8;padding:42px}.control main{max-width:880px;margin:auto}.eyebrow{color:#d9ad4a;letter-spacing:.16em;font-size:12px;font-weight:bold}.control h1{margin:4px 0;font-size:34px}.control form{margin-top:30px}.control label{display:block;margin:15px 0;font-size:13px;font-weight:bold;letter-spacing:.04em}.control input,.control select{display:block;width:100%;margin-top:6px;padding:11px;background:#202936;border:1px solid #536171;border-radius:5px;color:#fff;font-size:16px}.control input[type=color]{height:44px;padding:3px}.control section{display:grid;grid-template-columns:1fr 1fr;gap:18px}.control fieldset{border:1px solid #536171;border-radius:8px;padding:14px}.control legend{color:#d9ad4a}.control button{margin-top:10px;background:#d9ad4a;color:#17130b;border:0;padding:12px 20px;border-radius:5px;font-size:16px;font-weight:bold}.control aside{margin-top:30px;padding:16px;border-left:3px solid #d9ad4a;background:#1b222c}.control code{color:#d9ad4a}.overlay{width:100vw;height:100vh;overflow:hidden;background:transparent;color:white;font-weight:bold;text-transform:uppercase}.upper{position:absolute;top:12px;left:12px;right:12px;display:flex;align-items:center;gap:20px}.team{--team:#333;flex:1;min-width:0;height:96px;padding:13px 22px;background:linear-gradient(115deg,color-mix(in srgb,var(--team),#111 40%),color-mix(in srgb,var(--team),transparent 24%));border:1px solid color-mix(in srgb,var(--team),white 25%);box-shadow:0 5px 16px #0006;backdrop-filter:blur(5px)}.team.left{clip-path:polygon(0 0,100% 0,94% 100%,0 100%)}.team.right{text-align:right;clip-path:polygon(6% 0,100% 0,100% 100%,0 100%)}.faction{font-size:14px;letter-spacing:.12em;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.name{height:66px;line-height:66px;white-space:nowrap;overflow:hidden;font-size:58px;text-shadow:2px 3px 4px #000}.versus{flex:0 0 48px;text-align:center;font-size:21px;letter-spacing:.1em;text-shadow:2px 3px 4px #000}@media(max-width:700px){.control section{grid-template-columns:1fr}.control{padding:20px}}
|
|
2
|
+
.control .announcement-panel{display:block;margin-top:32px;padding:24px;border:1px solid #536171;border-radius:10px;background:#171e29}
|
|
3
|
+
.announcement-panel h2{margin-top:0}.announcement-panel p{line-height:1.5}.control .announcement-panel form{margin-top:20px}
|
|
4
|
+
.control .announcement-panel textarea{display:block;width:100%;resize:vertical;padding:12px;background:#202936;color:#fff;border:1px solid #536171;border-radius:5px;font:16px/1.5 Arial,sans-serif}
|
|
5
|
+
.control .announcement-options{margin-top:24px}.control .announcement-options label{display:flex;align-items:center;gap:10px;letter-spacing:0;font-size:15px}
|
|
6
|
+
.control .announcement-options input[type=checkbox]{width:18px;height:18px;margin:0;accent-color:#d9ad4a}
|
|
7
|
+
.announcement-actions{display:flex;gap:14px;align-items:center;flex-wrap:wrap;justify-content:space-between}.announcement-actions span{font-size:13px;color:#aeb9c7}
|
|
8
|
+
.control .secondary-button{background:transparent;border:1px solid #647284;color:#e8e4d8}.control button:disabled{opacity:.5;cursor:wait}
|
|
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
|
+
.announcement-help{font-size:13px;color:#b9c1cd}#toast-feedback{color:#e8bf70;min-height:24px}
|
package/public/toast.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.stream-toast {
|
|
2
|
+
position: absolute; bottom: 64px; left: 50%; width: 1880px; max-width: calc(100% - 160px);
|
|
3
|
+
transform: translate(-50%, 14px); opacity: 0; visibility: hidden; pointer-events: none; z-index: 100;
|
|
4
|
+
padding: 26px 38px 28px; border: 1px solid rgba(234,223,199,.27); border-left: 5px solid #dfb762;
|
|
5
|
+
border-radius: 14px; background: rgba(12,18,26,.82); box-shadow: 0 10px 30px #0004;
|
|
6
|
+
color: #fff; text-transform: none; font-family: Arial, sans-serif; font-weight: 400;
|
|
7
|
+
transition: opacity .3s ease, transform .3s ease, visibility .3s;
|
|
8
|
+
}
|
|
9
|
+
.stream-toast.is-visible { opacity: 1; visibility: visible; transform: translate(-50%,0); }
|
|
10
|
+
.toast-heading { color: #e8bf70; font-size: 27px; line-height: 1.2; font-weight: 700; letter-spacing: .035em; }
|
|
11
|
+
#toast-text { font-size: 42px; line-height: 1.26; font-weight: 600; margin: 10px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; text-shadow: 0 2px 3px #0008; }
|
|
12
|
+
.toast-footer { color: #d2d7df; font-size: 24px; line-height: 1.25; margin-top: 14px; }
|
|
13
|
+
.toast-footer:empty { display: none; }
|
|
14
|
+
@media (prefers-reduced-motion: reduce) { .stream-toast { transition: none; } }
|
package/public/toast.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const toast = document.querySelector('#stream-toast');
|
|
3
|
+
let timer, token;
|
|
4
|
+
function hide() { toast.classList.remove('is-visible'); toast.setAttribute('aria-hidden','true'); }
|
|
5
|
+
const events = new EventSource('/toasts/events');
|
|
6
|
+
events.addEventListener('toast', event => {
|
|
7
|
+
const message = JSON.parse(event.data);
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
if (!message || message.remainingMs <= 0) { token=null; hide(); return; }
|
|
10
|
+
// Reconnection reuses the remaining lifetime rather than replaying 15 seconds.
|
|
11
|
+
if (message.token !== token) {
|
|
12
|
+
document.querySelector('#toast-heading').textContent = message.title;
|
|
13
|
+
document.querySelector('#toast-text').textContent = message.text;
|
|
14
|
+
document.querySelector('#toast-footer').textContent = message.footer || '';
|
|
15
|
+
token = message.token;
|
|
16
|
+
}
|
|
17
|
+
toast.classList.add('is-visible'); toast.setAttribute('aria-hidden','false');
|
|
18
|
+
timer = setTimeout(hide, Math.min(message.remainingMs,15000));
|
|
19
|
+
});
|
|
20
|
+
window.addEventListener('pagehide',()=>{clearTimeout(timer);events.close();});
|
|
21
|
+
})();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Reports actual lore coverage; statistics are deliberately excluded.
|
|
2
|
+
const {names,canonicalFaction}=require('../factions');
|
|
3
|
+
const facts=require('../data/faction-facts.json').facts;
|
|
4
|
+
const target=120;
|
|
5
|
+
const rows=names.map(faction=>({faction,facts:new Set(facts.filter(f=>canonicalFaction(f.faction)===faction).map(f=>f.id)).size}));
|
|
6
|
+
const deficient=rows.filter(r=>r.facts<target);
|
|
7
|
+
console.table(rows.map(r=>({...r,minutes:r.facts*2,missingForMirror:Math.max(0,target-r.facts)})));
|
|
8
|
+
console.log(`Goal: ${target} distinct facts per faction, including mirror games; ${rows.length-deficient.length}/${rows.length} factions ready.`);
|
|
9
|
+
if(process.argv.includes('--require-four-hours')&&deficient.length)process.exitCode=1;
|
package/server.js
CHANGED
|
@@ -25,10 +25,12 @@ reader.on('exit', () => { game = mergeGame(game, {connected:false, error:'Lector
|
|
|
25
25
|
}
|
|
26
26
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { reader?.kill(); process.exit(); });
|
|
27
27
|
process.on('exit', () => reader?.kill());
|
|
28
|
+
const {syncStats} = require('./listhammer');
|
|
28
29
|
const root = path.join(__dirname, 'public');
|
|
29
30
|
const statePath = path.join(dataDir, 'overlay-state.json');
|
|
30
31
|
const defaultState = {
|
|
31
32
|
title: '',
|
|
33
|
+
roundHalf: '',
|
|
32
34
|
left: { name: 'PLAYER ONE', faction: 'Space Marines', color: '#245eb8' },
|
|
33
35
|
right: { name: 'PLAYER TWO', faction: 'Chaos Space Marines', color: '#7d1520' }
|
|
34
36
|
};
|
|
@@ -40,10 +42,53 @@ function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listene
|
|
|
40
42
|
function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
|
|
41
43
|
function saveState() { const temporaryPath = `${statePath}.tmp`; fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2)); fs.renameSync(temporaryPath, statePath); }
|
|
42
44
|
|
|
43
|
-
const
|
|
45
|
+
const {Announcements} = require('./announcements');
|
|
46
|
+
const toastSessionPath = path.join(dataDir, 'toast-session.json');
|
|
47
|
+
let toastSaved = {}, statsCache = null;
|
|
48
|
+
try { toastSaved = JSON.parse(fs.readFileSync(toastSessionPath, 'utf8')); } catch {}
|
|
49
|
+
try { statsCache = JSON.parse(fs.readFileSync(path.join(dataDir, 'listhammer-stats.json'), 'utf8')); } catch {}
|
|
50
|
+
const toastListeners = new Map();
|
|
51
|
+
const announcements = new Announcements({
|
|
52
|
+
layout: () => state, stats: () => statsCache, saved: toastSaved,
|
|
53
|
+
save: data => { const temp = toastSessionPath + '.tmp'; fs.writeFileSync(temp, JSON.stringify(data, null, 2)); fs.renameSync(temp, toastSessionPath); },
|
|
54
|
+
emit: (event, data) => toastListeners.forEach((_, res) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
|
|
55
|
+
});
|
|
56
|
+
const toastTimer = setInterval(() => { try { announcements.tick([...toastListeners.values()].includes('overlay')); } catch(e) { console.error('Mensajes: '+e.message); } }, 1000);
|
|
57
|
+
toastTimer.unref();
|
|
58
|
+
async function readToastBody(req) {
|
|
59
|
+
const chunks = []; let size = 0;
|
|
60
|
+
for await (const chunk of req) {
|
|
61
|
+
size += chunk.length;
|
|
62
|
+
if (size > 8192) throw new Error('El mensaje es demasiado largo.');
|
|
63
|
+
chunks.push(chunk);
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const server = http.createServer(async (req, res) => {
|
|
44
69
|
res.setHeader('Cache-Control','no-store');
|
|
45
70
|
const url = new URL(req.url, 'http://localhost');
|
|
46
|
-
if (url.pathname === '/
|
|
71
|
+
if (url.pathname === '/toasts/events' && req.method === 'GET') {
|
|
72
|
+
res.writeHead(200, {'Content-Type':'text/event-stream', 'Cache-Control':'no-cache', Connection:'keep-alive'});
|
|
73
|
+
toastListeners.set(res, url.searchParams.get('client') === 'control' ? 'control' : 'overlay');
|
|
74
|
+
res.write(`event: toast\ndata: ${JSON.stringify(announcements.current())}\n\n`);
|
|
75
|
+
res.write(`event: status\ndata: ${JSON.stringify(announcements.status())}\n\n`);
|
|
76
|
+
req.on('close', () => toastListeners.delete(res)); return;
|
|
77
|
+
}
|
|
78
|
+
if (url.pathname === '/toasts/state' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify(announcements.status()));
|
|
79
|
+
if (['/toasts/message','/toasts/next','/toasts/config','/toasts/reset'].includes(url.pathname)) {
|
|
80
|
+
if (req.method !== 'POST') return send(res, 405, 'application/json', JSON.stringify({error:'Usa POST.'}));
|
|
81
|
+
try {
|
|
82
|
+
const input = await readToastBody(req);
|
|
83
|
+
let toast;
|
|
84
|
+
if (url.pathname === '/toasts/message') toast = announcements.manual(input.text);
|
|
85
|
+
if (url.pathname === '/toasts/next') toast = announcements.next();
|
|
86
|
+
if (url.pathname === '/toasts/config') announcements.configure(input);
|
|
87
|
+
if (url.pathname === '/toasts/reset') announcements.reset();
|
|
88
|
+
return send(res, 200, 'application/json', JSON.stringify({ok:true,toast,status:announcements.status()}));
|
|
89
|
+
} catch(e) { return send(res, 400, 'application/json', JSON.stringify({error:e.message})); }
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname === '/game/reset' && req.method === 'POST') { game = emptyGame(); persistGame(); state = {...state, roundHalf:''}; saveState(); broadcast(); return send(res, 200, 'application/json', JSON.stringify({ok:true})); }
|
|
47
92
|
if (url.pathname === '/game-state.json') { res.setHeader('Cache-Control', 'no-store'); return send(res, 200, 'application/json', JSON.stringify(game)); }
|
|
48
93
|
if (url.pathname === '/events') {
|
|
49
94
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
@@ -53,7 +98,7 @@ const server = http.createServer((req, res) => {
|
|
|
53
98
|
if (url.pathname === '/state' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify(state));
|
|
54
99
|
if (url.pathname === '/state' && req.method === 'POST') {
|
|
55
100
|
let body = ''; req.on('data', c => body += c); req.on('end', () => {
|
|
56
|
-
try { state = JSON.parse(body); saveState(); broadcast(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
101
|
+
try { state = JSON.parse(body); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
57
102
|
catch { send(res, 400, 'application/json', JSON.stringify({ error: 'Invalid data' })); }
|
|
58
103
|
}); return;
|
|
59
104
|
}
|
|
@@ -64,4 +109,4 @@ const server = http.createServer((req, res) => {
|
|
|
64
109
|
send(res, 200, types[path.extname(filePath)] || 'application/octet-stream', fs.readFileSync(filePath));
|
|
65
110
|
});
|
|
66
111
|
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; });
|
|
67
|
-
server.listen(Number(process.env.PORT || 8765), '127.0.0.1', () => { startReader(); 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.`); });
|
|
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.`); });
|