@ing.jorgeu/wargaming-overlay 2.2.1 → 2.4.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 +96 -2
- package/announcements.js +130 -0
- package/data/faction-facts.json +6118 -0
- package/device_connection.py +113 -0
- package/diagnostics.js +7 -1
- package/factions.js +20 -0
- package/game-store.js +5 -2
- package/listhammer.js +106 -0
- package/package.json +11 -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/reader.py +9 -18
- package/scripts/fact-coverage.js +9 -0
- package/server.js +49 -4
- package/turn-tracker.js +37 -0
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
|
+
})();
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
27
|
-
serial =
|
|
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,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.`); });
|
package/turn-tracker.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Accessibility snapshots can be partial. Only increases of previously seen
|
|
2
|
+
// values are activity; discovering a mission while scrolling is not activity.
|
|
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'};
|
|
5
|
+
let t=samePlayers && previous.turn ? structuredClone(previous.turn) : fresh;
|
|
6
|
+
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};}
|
|
8
|
+
const events=[];
|
|
9
|
+
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;
|
|
22
|
+
}
|
|
23
|
+
t.seen[p.name]=next;
|
|
24
|
+
if(activity)events.push(p.name);
|
|
25
|
+
}
|
|
26
|
+
if(events.length===1){
|
|
27
|
+
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.
|
|
31
|
+
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`;
|
|
33
|
+
}
|
|
34
|
+
if(events.length>1)t.reason='Cambios en ambos jugadores; se conserva el último turno detectado';
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
module.exports={inferTurn};
|