@ing.jorgeu/wargaming-overlay 2.5.0 → 3.1.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.
Files changed (46) hide show
  1. package/PUBLISHING.md +9 -1
  2. package/README.md +324 -11
  3. package/THIRD_PARTY_ASSETS.md +8 -0
  4. package/battle_reader.py +4 -1
  5. package/cli.js +27 -7
  6. package/game-store.js +2 -1
  7. package/package.json +19 -6
  8. package/public/army-list.css +28 -0
  9. package/public/army-list.html +1 -0
  10. package/public/army-list.js +106 -0
  11. package/public/control.html +2 -2
  12. package/public/control.js +7 -1
  13. package/public/detachments.css +2 -2
  14. package/public/remote-lists-control.js +21 -0
  15. package/public/roster-view.js +12 -0
  16. package/public/rosters-control.js +82 -0
  17. package/public/style.css +7 -0
  18. package/remote-lists/.env.example +16 -0
  19. package/remote-lists/backend.js +70 -0
  20. package/remote-lists/demo.js +4 -0
  21. package/remote-lists/dev.js +2 -0
  22. package/remote-lists/examples/demo-list.txt +10 -0
  23. package/remote-lists/importer.js +60 -0
  24. package/remote-lists/local.js +55 -0
  25. package/remote-lists/public/player.css +7 -0
  26. package/remote-lists/public/player.html +1 -0
  27. package/remote-lists/public/player.js +13 -0
  28. package/remote-lists/references.js +39 -0
  29. package/remote-lists/start.js +10 -0
  30. package/rosters.js +46 -0
  31. package/scripts/setup-kokoro.js +2 -0
  32. package/scripts/tts-demo.js +0 -1
  33. package/server.js +46 -4
  34. package/tts/kokoro-assets.js +27 -0
  35. package/tts/kokoro-engine.js +31 -0
  36. package/tts/kokoro-worker.js +13 -0
  37. package/tts/licenses/espeak-ng-COPYING +674 -0
  38. package/tts/playback.js +28 -0
  39. package/tts/speech.js +55 -3
  40. package/tts/voice-command.js +14 -0
  41. package/tts/wav.js +6 -0
  42. package/tts/windows-playback.js +14 -0
  43. package/youtube/index.js +5 -2
  44. package/youtube/live-chat.js +5 -3
  45. package/youtube/poll-chat.js +15 -0
  46. package/youtube/service.js +18 -9
@@ -0,0 +1,55 @@
1
+ const fs=require('node:fs'),path=require('node:path');const {validateUnits,validateName}=require('../rosters');const {cleanList}=require('./importer');const {readJSON}=require('./backend');
2
+ function backendURL(value){let u;try{u=new URL(value);}catch{throw Error('Dirección del backend inválida.');}if(u.username||u.password||u.search||u.hash||u.pathname!=='/'||!(u.protocol==='https:'||(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname))))throw Error('Usa HTTPS, o HTTP localhost para pruebas, sin rutas ni credenciales en la URL.');return u.origin;}
3
+ function createLocalRemote({enabled,dataDir,env=process.env,getSnapshot,applyRoster,fetchImpl=fetch}){
4
+ const configFile=path.join(dataDir,'remote-lists-config.json'),pendingFile=path.join(dataDir,'remote-lists-pending.json');let config={},pending={},locked=false;
5
+ if(enabled){try{config=JSON.parse(fs.readFileSync(configFile,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}try{pending=JSON.parse(fs.readFileSync(pendingFile,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}if(env.REMOTE_LISTS_URL)config.url=backendURL(env.REMOTE_LISTS_URL);if(env.REMOTE_LISTS_MANAGER_TOKEN)config.token=env.REMOTE_LISTS_MANAGER_TOKEN;}
6
+ const configured=()=>!!config.url&&typeof config.token==='string'&&config.token.length>=24;
7
+ function write(file,value){fs.writeFileSync(file+'.tmp',JSON.stringify(value),{mode:0o600});fs.renameSync(file+'.tmp',file);fs.chmodSync(file,0o600);}
8
+ const view=()=>({enabled,configured:configured(),url:config.url||'',links:configured()?{blue:config.url+'/blue',red:config.url+'/red'}:{},pending:Object.fromEntries(Object.entries(pending).map(([s,p])=>[s,{name:p.list.name,count:p.list.units.length}]))});
9
+ async function remote(route,body){if(!configured())throw Error('Configura la dirección y credencial del backend.');const r=await fetchImpl(config.url+'/api/manager/'+route,{method:body?'POST':'GET',redirect:'error',signal:AbortSignal.timeout(15000),headers:{Authorization:'Bearer '+config.token,...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});let chunks=[],size=0;for await(const chunk of r.body){size+=chunk.length;if(size>500000)throw Error('Respuesta remota demasiado grande.');chunks.push(chunk);}const value=JSON.parse(Buffer.concat(chunks));if(!r.ok)throw Error(value.error||'Error del backend remoto.');return value;}
10
+ async function handle(req,res,url){
11
+ const reply=(status,value)=>{res.writeHead(status,{'Content-Type':'application/json'});res.end(JSON.stringify(value));};
12
+ if(!enabled){reply(url.pathname==='/remote-lists'&&req.method==='GET'?200:404,{enabled:false});return;}
13
+ // JSON-only same-origin writes prevent a foreign site from controlling the local proxy.
14
+ if(req.headers.origin&&req.headers.origin!=='http://'+req.headers.host){reply(403,{error:'Origen no permitido.'});return;}
15
+ if(!['127.0.0.1','localhost','[::1]'].includes((req.headers.host||'').split(':')[0])&&!(req.headers.host||'').startsWith('[::1]:')){reply(403,{error:'Host no permitido.'});return;}
16
+ try{
17
+ if(req.method==='GET'&&url.pathname==='/remote-lists')return reply(200,view());
18
+ if(req.method==='GET'&&url.pathname==='/remote-lists/status'){const status=await remote('status');return reply(200,{...view(),status:{blue:status.blue&&{...status.blue,confirmed:undefined},red:status.red&&{...status.red,confirmed:undefined},reservedTokens:status.reservedTokens,tokenBudget:status.tokenBudget}});}
19
+ if(req.method!=='POST')return reply(404,{error:'Ruta no encontrada.'});
20
+ if(locked)return reply(409,{error:'Espera a que termine la operación anterior.'});locked=true;
21
+ try{
22
+ const input=await readJSON(req,4096);
23
+ if(url.pathname==='/remote-lists/config'){
24
+ const token=input.token||config.token;if(typeof token!=='string'||token.length<24||token.length>1024)throw Error('La credencial debe tener de 24 a 1024 caracteres.');
25
+ config={url:backendURL(input.url),token};write(configFile,config);pending={};write(pendingFile,pending);return reply(200,view());
26
+ }
27
+ if(url.pathname==='/remote-lists/gate'){
28
+ if(!['blue','red'].includes(input.side)||typeof input.open!=='boolean')throw Error('Estado de subida inválido.');const status=await remote(input.side+'/gate',{open:input.open});return reply(200,{...view(),status:{blue:{...status.blue,confirmed:undefined},red:{...status.red,confirmed:undefined}}});
29
+ }
30
+ if(url.pathname==='/remote-lists/sync'){
31
+ const before=getSnapshot(),revisions=JSON.stringify(before),status=await remote('status');
32
+ if(JSON.stringify(getSnapshot())!==revisions)return reply(409,{error:'La lista local cambió durante la sincronización. Vuelve a sincronizar.'});
33
+ const changes=[];
34
+ for(const [side,localSide] of [['blue','left'],['red','right']]){
35
+ const list=status[side]?.confirmed;if(!list)continue;const player=before.players[localSide],old=before.rosters[player.rosterId];
36
+ if(old?.remoteImportId===list.id)continue;
37
+ if(typeof list.id!=='string'||list.id.length>80)throw Error('Lista remota inválida.');
38
+ const clean=cleanList(list);changes.push({side,id:player.rosterId,roster:{revision:(old?.revision||0)+1,name:clean.name,units:clean.units,backgroundUrl:old?.backgroundUrl||'',remoteImportId:list.id},metadata:{faction:clean.armyFaction,detachments:clean.detachments}});
39
+ }
40
+ for(const change of changes)applyRoster(change.id,change.roster,change.metadata);
41
+ pending={};write(pendingFile,pending);return reply(200,{...view(),synced:changes.map(c=>c.side),warnings:Object.fromEntries(['blue','red'].map(side=>[side,status[side]?.confirmed?.warnings||[]]))});
42
+ }
43
+ if(url.pathname==='/remote-lists/apply'){
44
+ const p=pending[input.side];if(!['blue','red'].includes(input.side)||!p)throw Error('No hay lista pendiente para ese lado.');
45
+ const snapshot=getSnapshot(),localSide=input.side==='blue'?'left':'right',player=snapshot.players[localSide],old=snapshot.rosters[p.rosterId];
46
+ if(player.rosterId!==p.rosterId||(old?.revision||0)!==p.revision)return reply(409,{error:'La lista local o los jugadores cambiaron. Guarda tus cambios y vuelve a sincronizar.'});
47
+ applyRoster(p.rosterId,{revision:p.revision+1,name:p.list.name,units:p.list.units,backgroundUrl:old?.backgroundUrl||'',remoteImportId:p.list.id});delete pending[input.side];write(pendingFile,pending);return reply(200,view());
48
+ }
49
+ return reply(404,{error:'Ruta no encontrada.'});
50
+ }finally{locked=false;}
51
+ }catch(e){reply(400,{error:e instanceof TypeError?'No se pudo conectar con el backend remoto.':e.message});}
52
+ }
53
+ return {handle};
54
+ }
55
+ module.exports={createLocalRemote,backendURL};
@@ -0,0 +1,7 @@
1
+ *{box-sizing:border-box}body{margin:0;background:#101824;color:#ecf1f8;font:16px system-ui,sans-serif}main{max-width:760px;margin:0 auto;padding:32px 20px}.eyebrow{color:#92b9ef;font-size:13px;letter-spacing:.15em}h1{font-size:30px}label{display:block;margin:14px 0}input,textarea{display:block;width:100%;padding:12px;margin-top:8px;color:inherit;background:#1a283b;border:1px solid #4e627a;border-radius:7px;font:inherit}button{padding:12px 16px;border:0;border-radius:6px;background:#9bbfed;color:#101824;font:inherit;margin:5px 8px 5px 0}button:disabled{opacity:.5}article{padding:14px;margin:14px 0;background:#182535;border-radius:8px}p{line-height:1.5;color:#becbdd}[hidden]{display:none!important}
2
+
3
+ html{color-scheme:dark}main{max-width:1120px}input,textarea{min-width:0;font-size:16px}textarea{resize:vertical}label{overflow-wrap:anywhere}article{min-width:0;overflow-wrap:anywhere}button{min-height:44px;cursor:pointer;max-width:100%}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #b4d4ff;outline-offset:3px}#units{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}#units article{margin:0}#warnings:not(:empty),#mode{background:#342b18;border:1px solid #827044;border-radius:6px;padding:12px;white-space:pre-wrap;overflow-wrap:anywhere}.preview-actions{display:flex;flex-wrap:wrap;margin-top:20px;gap:8px}.preview-actions button{margin:0}#confirm{background:#b4dbb8}#status{overflow-wrap:anywhere}#import-form{max-width:760px;margin:auto}#preview{scroll-margin-top:20px}@media(max-width:640px){main{padding:20px 14px}h1{font-size:26px}#units{grid-template-columns:minmax(0,1fr)}.preview-actions{flex-direction:column}.preview-actions button,#interpret{width:100%}article{padding:12px}label{margin:12px 0}}
4
+
5
+ select{width:100%;font:inherit;font-size:16px;min-height:44px;padding:10px;border-radius:8px;box-sizing:border-box}
6
+
7
+ .loading-spinner{display:inline-block;width:22px;height:22px;border:3px solid #354b67;border-top-color:#9bbfed;border-radius:50%;animation:loading-spin .8s linear infinite;vertical-align:middle;margin:8px 0}.loading-spinner:not([hidden])+#status{display:inline-block;margin-left:10px}@keyframes loading-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.loading-spinner{animation:none;border-top-color:#ecf1f8}}
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Subir lista de ejército</title><link rel="stylesheet" href="/player.css"></head><body><main><p class="eyebrow">WARHAMMER 40,000</p><h1 id="title">Subir lista</h1><p id="mode" hidden></p><span id="loading" class="loading-spinner" aria-hidden="true" hidden></span><p id="status" role="status">Comprobando si la subida está habilitada…</p><form id="import-form" hidden><label>Pega el texto exportado de tu app<textarea id="text" maxlength="6000" rows="12" required></textarea></label><button id="interpret">Subir lista</button></form></main><script src="/player.js"></script></body></html>
@@ -0,0 +1,13 @@
1
+ (()=>{
2
+ const $=id=>document.getElementById(id),side=location.pathname.slice(1);let busy=false,uploaded=false;
3
+ $('title').textContent='Lista del jugador '+(side==='blue'?'azul':'rojo');
4
+ async function request(action,body){const r=await fetch('/api/'+side+'/'+action,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(data.error||'No se pudo subir la lista.');return data;}
5
+ async function status(){try{const s=await request('status');$('mode').hidden=!s.demo;$('mode').textContent='Modo de prueba: se guardará una lista de ejemplo.';if(!busy&&!uploaded){$('status').textContent=s.open?'Pega tu lista y pulsa Subir lista.':'Subida cerrada. Pide al manager que la habilite.';$('import-form').hidden=!s.open;}}catch{if(!busy&&!uploaded)$('status').textContent='No se pudo conectar. Recarga para reintentar.';}}
6
+ $('import-form').onsubmit=async e=>{
7
+ e.preventDefault();if(busy||uploaded)return;busy=true;$('interpret').disabled=true;$('text').disabled=true;$('loading').hidden=false;$('import-form').setAttribute('aria-busy','true');$('status').textContent='Analizando y guardando tu lista…';
8
+ try{await request('import',{text:$('text').value});uploaded=true;$('import-form').hidden=true;$('status').textContent='✓ Lista subida correctamente.';}
9
+ catch(e){$('status').textContent=e.message;}
10
+ finally{busy=false;$('interpret').disabled=false;$('text').disabled=false;$('loading').hidden=true;$('import-form').setAttribute('aria-busy','false');}
11
+ };
12
+ status();setInterval(()=>{if(!busy&&!uploaded)status();},15000);
13
+ })();
@@ -0,0 +1,39 @@
1
+ const fs=require('node:fs/promises'),path=require('node:path');const {createHash}=require('node:crypto');
2
+ const TTL=7*24*60*60*1000,MAX_CONTEXT=24000;
3
+ const FACTIONS=['adepta-sororitas','adeptus-custodes','adeptus-mechanicus','astra-militarum','grey-knights','imperial-agents','imperial-knights','space-marines','chaos-daemons','chaos-knights','chaos-space-marines','death-guard','emperors-children','thousand-sons','world-eaters','aeldari','drukhari','genestealer-cults','leagues-of-votann','necrons','orks','tau-empire','tyranids'];
4
+ const normalize=s=>String(s).normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]/g,'');
5
+ function plain(html){return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi,'').replace(/<br\s*\/?>|<\/li>|<\/div>|<\/p>/gi,'\n').replace(/<[^>]*>/g,' ').replace(/&#(x[0-9a-f]+|[0-9]+);/gi,(_,n)=>{const v=n[0].toLowerCase()==='x'?parseInt(n.slice(1),16):Number(n);return v>0&&v<=0x10ffff?String.fromCodePoint(v):' ';}).replace(/&(nbsp|amp|quot|apos|lt|gt|rsquo|ndash|mdash);/g,(_,n)=>({nbsp:' ',amp:'&',quot:'"',apos:"'",lt:'<',gt:'>',rsquo:'’',ndash:'–',mdash:'—'})[n]).replace(/[ \t]+/g,' ').replace(/\n\s*\n/g,'\n').trim();}
6
+ function checkedURL(value){const u=new URL(value);if(u.protocol!=='https:'||!['wahapedia.ru','www.wahapedia.ru'].includes(u.hostname)||!/^\/wh40k11ed\/factions\/[a-z-]+\/(?:[A-Za-z0-9-]+(?:\.html)?)?$/.test(u.pathname)||u.search||u.hash||u.username||u.password)throw Error('Referencia fuera de 11.ª edición.');return u.href;}
7
+ function extractSheet(html,url){checkedURL(url);const title=plain(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]||'');if(!/11th edition/i.test(title)||/10th edition/i.test(title))throw Error('La ficha no acredita 11.ª edición.');
8
+ const text=plain(html),options=text.indexOf('WARGEAR OPTIONS'),composition=text.indexOf('UNIT COMPOSITION');if(composition<0)throw Error('Ficha sin equipo u opciones identificables.');
9
+ const section=start=>{const rest=text.slice(start);const markers=['CORE','ARMY RULES','ABILITIES','KEYWORDS','YOUR 1ST','YOUR 4TH','POINTS COST','STRATAGEMS','YOUR UNIT COSTS','SUPREME COMMANDER','LEADER','ATTACHED UNIT','WARGEAR OPTIONS','UNIT COMPOSITION'];const end=Math.min(...markers.map(m=>{const i=rest.indexOf(m,20);return i<0?rest.length:i;}));return rest.slice(0,end).trim();};
10
+ const optionText=options<0?'':section(options);const result=(optionText?optionText+'\n':'')+section(composition);if(result.length>4000||!/equipped with/i.test(result))throw Error('Ficha de equipo incompleta o demasiado extensa.');return {title,text:result,hasOptions:!!optionText&&!/WARGEAR OPTIONS\s*(?:None|None\.|–|—|N\/A)\s*$/i.test(optionText),options:optionText};
11
+ }
12
+ function extractIndex(html,url){checkedURL(url);const title=plain(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]||'');if(!/11th edition/i.test(title))throw Error('Índice de otra edición.');const prefix=new URL(url).pathname;const found=new Map();
13
+ for(const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)){let u;try{u=new URL(m[1],url);checkedURL(u.href);}catch{continue;}if(!u.pathname.startsWith(prefix)||u.pathname===prefix)continue;const slug=u.pathname.slice(prefix.length);if(slug.includes('/')||['datasheets.html','index.html','army-rules','detachments'].includes(slug))continue;const name=plain(m[2]);if(name&&!name.includes('\n')&&name.length<120){found.set(normalize(name),u.href);found.set(normalize(slug.replace(/\.html$/,'')),u.href);}}
14
+ if(!found.size)throw Error('No se encontraron fichas de 11.ª.');return Object.fromEntries(found);
15
+ }
16
+ function createReferences({cacheDir,fetchImpl=fetch,now=Date.now}){
17
+ const inflight=new Map();
18
+ async function readPage(url,kind){url=checkedURL(url);const file=path.join(cacheDir,createHash('sha256').update(url).digest('hex')+'.json');
19
+ try{const entry=JSON.parse(await fs.readFile(file,'utf8'));if(entry.edition===11&&entry.url===url&&entry.kind===kind&&Number.isFinite(entry.fetchedAt)&&now()>=entry.fetchedAt&&now()-entry.fetchedAt<TTL)return entry.value;await fs.rm(file,{force:true});}catch(e){if(e.code!=='ENOENT')await fs.rm(file,{force:true});}
20
+ if(inflight.has(url))return inflight.get(url);
21
+ const task=(async()=>{
22
+ // Never follow redirects to another host or edition; validate every hop.
23
+ let target=url,r;for(let i=0;i<4;i++){r=await fetchImpl(target,{redirect:'manual',signal:AbortSignal.timeout(10000),headers:{'User-Agent':'wargaming-overlay-list-importer/1.0'}});if([301,302,303,307,308].includes(r.status)){target=checkedURL(new URL(r.headers.get('location'),target).href);continue;}break;}
24
+ if(!r?.ok)throw Error('No se pudo descargar la referencia.');checkedURL(target);
25
+ const chunks=[];let size=0;for await(const chunk of r.body){size+=chunk.length;if(size>2500000)throw Error('Referencia demasiado grande.');chunks.push(chunk);}const html=Buffer.concat(chunks).toString('utf8');const value=kind==='index'?extractIndex(html,url):extractSheet(html,target);
26
+ await fs.mkdir(cacheDir,{recursive:true,mode:0o700});const temp=file+'.tmp';await fs.writeFile(temp,JSON.stringify({edition:11,url,kind,fetchedAt:now(),value}),{mode:0o600});await fs.rename(temp,file);return value;
27
+ })();inflight.set(url,task);try{return await task;}finally{inflight.delete(url);}
28
+ }
29
+ async function lookup(faction,units){const references=[],warnings=[],covered=new Set();if(!FACTIONS.includes(faction))return {references,warnings:['No se identificó una facción de 11.ª: no se mostrará equipo sin verificar.'],covered};
30
+ let index;try{index=await readPage('https://wahapedia.ru/wh40k11ed/factions/'+faction+'/','index');}catch{return {references,warnings:['No se pudo obtener el índice vigente de 11.ª. No se mostrará equipo sin verificar.'],covered};}
31
+ const unique=new Map();units.forEach((u,i)=>{const url=index[normalize(u.referenceName||u.name)];if(url){if(!unique.has(url))unique.set(url,[]);unique.get(url).push(i);}else warnings.push('Sin ficha de 11.ª para '+u.name+'. Revisa su equipo.');});
32
+ let used=0;const entries=[...unique.entries()];
33
+ // Bound source requests and work concurrently in small batches.
34
+ for(let start=0;start<Math.min(entries.length,16);start+=4){const batch=await Promise.all(entries.slice(start,start+4).map(async([url,indices])=>{try{return {url,indices,sheet:await readPage(url,'sheet')};}catch{return {url,indices};}}));for(const item of batch){if(!item.sheet||used+Buffer.byteLength(JSON.stringify(item))>MAX_CONTEXT){warnings.push('Referencia no disponible o límite de contexto: '+units[item.indices[0]].name+'. Revisa el equipo.');continue;}used+=Buffer.byteLength(JSON.stringify(item));references.push(item);item.indices.forEach(i=>covered.add(i));}}
35
+ if(entries.length>16)warnings.push('Se alcanzó el máximo de 16 fichas distintas. Las demás unidades no mostrarán equipo sin verificar.');return {references,warnings,covered};
36
+ }
37
+ return {lookup,readPage};
38
+ }
39
+ module.exports={createReferences,extractSheet,extractIndex,checkedURL,TTL,FACTIONS,MAX_CONTEXT};
@@ -0,0 +1,10 @@
1
+ const path=require('node:path');const {createReferences}=require('./references');const {createImporter}=require('./importer');const {createRemoteServer}=require('./backend');
2
+ try{
3
+ const env=process.env,maxOutputTokens=Number(env.REMOTE_LISTS_MAX_OUTPUT_TOKENS||4000),port=Number(env.REMOTE_LISTS_PORT||env.PORT||8080);
4
+ if(!Number.isInteger(port)||port<1||port>65535)throw Error('Puerto remoto inválido.');
5
+ const references=createReferences({cacheDir:path.join(path.resolve(env.REMOTE_LISTS_DATA_DIR||'remote-lists-data'),'references-11')});
6
+ const server=createRemoteServer({dataDir:path.resolve(env.REMOTE_LISTS_DATA_DIR||'remote-lists-data'),managerToken:env.REMOTE_LISTS_MANAGER_TOKEN,tokenBudget:Number(env.REMOTE_LISTS_TOKEN_BUDGET||500000),maxImports:Number(env.REMOTE_LISTS_MAX_IMPORTS||2),maxOutputTokens,trustProxy:env.REMOTE_LISTS_TRUST_PROXY==='true',openMinutes:Number(env.REMOTE_LISTS_OPEN_MINUTES||30),demo:env.REMOTE_LISTS_DEMO==='true',importer:env.REMOTE_LISTS_DEMO==='true'?require('./demo').demoImporter:createImporter({apiKey:env.OPENAI_API_KEY,model:env.OPENAI_MODEL||'gpt-6-luna',maxOutputTokens,references})});
7
+ server.listen(port,env.REMOTE_LISTS_HOST||'0.0.0.0',()=>console.log(`${env.REMOTE_LISTS_DEMO==='true'?'MODO DEMO (sin LLM) · ':''}Backend de listas: puerto ${port}. Enlaces /blue y /red. Subidas cerradas al iniciar.`));
8
+ server.on('error',()=>{console.error('No se pudo iniciar el backend remoto.');process.exitCode=1;});
9
+ for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>server.close(()=>process.exit()));
10
+ }catch(e){console.error(e.message);process.exitCode=1;}
package/rosters.js ADDED
@@ -0,0 +1,46 @@
1
+ const {randomUUID}=require('node:crypto');
2
+ function linkPlayers(next,previous={}){
3
+ const used=new Set();const result={...next};
4
+ for(const side of ['left','right']){
5
+ const player=next[side];if(!player||typeof player!=='object')throw Error('Faltan los dos jugadores.');
6
+ const known=['left','right'].map(s=>previous[s]).filter(Boolean);
7
+ let id=known.find(p=>p.rosterId===player.rosterId)?.rosterId;
8
+ if(!id)id=known.find(p=>(p.appName||p.name)===(player.appName||player.name))?.rosterId;
9
+ if(!id)id=previous[side]?.rosterId;
10
+ if(!id||used.has(id))id=randomUUID();
11
+ used.add(id);result[side]={...player,rosterId:id};
12
+ }
13
+ return result;
14
+ }
15
+ function validateUnits(input){
16
+ if(!Array.isArray(input)||input.length>80)throw Error('La lista admite hasta 80 unidades.');
17
+ const ids=new Set();
18
+ const text=(v,max,label,required=false)=>{if(typeof v!=='string'||v.length>max||(required&&!v.trim()))throw Error(`${label}: texto ${required?'obligatorio, ':''}máximo ${max} caracteres.`);return v.trim();};
19
+ return input.map(u=>{
20
+ if(!u||typeof u!=='object')throw Error('Unidad inválida.');
21
+ const id=u.id||randomUUID();if(typeof id!=='string'||!/^[a-zA-Z0-9_-]{1,80}$/.test(id)||ids.has(id))throw Error('Identificador de unidad inválido o repetido.');ids.add(id);
22
+ if(!Array.isArray(u.characters)||u.characters.length>8)throw Error('Cada unidad admite hasta 8 personajes.');
23
+ if(typeof u.deepStrike!=='boolean'||typeof u.destroyed!=='boolean')throw Error('En reservas y destruida deben ser true o false.');
24
+ if(u.battleshocked!==undefined&&typeof u.battleshocked!=='boolean')throw Error('Battleshocked debe ser true o false.');
25
+ if(u.category!==undefined&&!['epic_character','character','unit','vehicle','monster'].includes(u.category))throw Error('Categoría de unidad inválida.');
26
+ return {...(u.category?{category:u.category}:{}),id,name:text(u.name,100,'Unidad',true),enhancement:text(u.enhancement??'',100,'Mejora'),characters:u.characters.map(c=>text(c,80,'Personaje',true)),notes:text(u.notes??'',160,'Notas'),deepStrike:u.deepStrike,destroyed:u.destroyed,battleshocked:u.battleshocked===true};
27
+ });
28
+ }
29
+ function validateBackground(value=''){
30
+ if(typeof value!=='string'||value.length>2048)throw Error('URL de fondo inválida.');
31
+ if(!value.trim())return '';
32
+ let url;try{url=new URL(value);}catch{throw Error('Introduce una URL completa para el fondo.');}
33
+ if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw Error('El fondo debe usar HTTP o HTTPS, sin credenciales.');
34
+ return url.href;
35
+ }
36
+ function validateName(value=''){
37
+ if(typeof value!=='string'||value.length>80)throw Error('Nombre de lista: máximo 80 caracteres.');
38
+ return value.trim();
39
+ }
40
+ function updateUnitStatus(roster,id,input){
41
+ const keys=['battleshocked','destroyed','deepStrike'];
42
+ if(!input||typeof input!=='object'||Array.isArray(input)||!Object.keys(input).length||Object.keys(input).some(k=>!keys.includes(k)||typeof input[k]!=='boolean'))throw Error('Solo se pueden cambiar estados de unidad con true o false.');
43
+ if(!roster?.units?.some(u=>u.id===id))throw Error('Unidad no encontrada.');
44
+ return {...roster,revision:roster.revision+1,units:roster.units.map(u=>u.id===id?{...u,...input}:u)};
45
+ }
46
+ module.exports={linkPlayers,validateUnits,validateBackground,validateName,updateUnitStatus};
@@ -0,0 +1,2 @@
1
+ const {ensureAssets}=require('../tts/kokoro-assets');
2
+ if(process.env.KOKORO_SKIP_DOWNLOAD!=='true')ensureAssets({log:console.log}).catch(e=>{console.warn('[Kokoro] '+e.message+' Se reintentará al leer el primer mensaje; el overlay puede seguir instalándose.');});
@@ -1,7 +1,6 @@
1
1
  const {SpeechQueue}=require('../tts/queue');
2
2
  const {sanitize}=require('../tts/sanitizer');
3
3
  async function main(){
4
- if(process.platform!=='darwin')throw Error('Esta prueba de voz requiere macOS.');
5
4
  const q=new SpeechQueue();
6
5
  for(const [author,text] of [['Pedro','Hola, esto es una prueba.'],['John','Este mensaje debe sonar después del anterior.'],['Pedro','Mira esto https://example.com/prueba']])q.enqueue(sanitize({author,text}));
7
6
  const stop=()=>{q.stop();};process.once('SIGINT',stop);process.once('SIGTERM',stop);
package/server.js CHANGED
@@ -51,8 +51,17 @@ const defaultState = {
51
51
  let state = defaultState;
52
52
  try { state = { ...defaultState, ...JSON.parse(fs.readFileSync(statePath, 'utf8')) }; }
53
53
  catch { /* First run: use the defaults. */ }
54
+ const {linkPlayers,validateUnits,validateBackground,validateName,updateUnitStatus}=require('./rosters');
55
+ state=linkPlayers(state,state);
56
+ saveState();
57
+ const rostersPath=path.join(dataDir,'rosters.json');
58
+ let rosters={};
59
+ try{rosters=JSON.parse(fs.readFileSync(rostersPath,'utf8'));}catch{}
60
+ function rosterSnapshot(){return {players:{left:state.left,right:state.right},rosters};}
61
+ const rosterListeners=new Set();
62
+ function broadcastRosters(){const msg=`data: ${JSON.stringify(rosterSnapshot())}\n\n`;for(const res of rosterListeners)res.write(msg);}
54
63
  const listeners = new Set();
55
- function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); }
64
+ function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); broadcastRosters(); }
56
65
  function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
57
66
  function saveState() { const temporaryPath = `${statePath}.tmp`; fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2)); fs.renameSync(temporaryPath, statePath); }
58
67
 
@@ -69,19 +78,52 @@ const announcements = new Announcements({
69
78
  });
70
79
  const toastTimer = setInterval(() => { try { announcements.tick([...toastListeners.values()].includes('overlay')); } catch(e) { console.error('Mensajes: '+e.message); } }, 1000);
71
80
  toastTimer.unref();
72
- async function readToastBody(req) {
81
+ async function readToastBody(req,limit=8192) {
73
82
  const chunks = []; let size = 0;
74
83
  for await (const chunk of req) {
75
84
  size += chunk.length;
76
- if (size > 8192) throw new Error('El mensaje es demasiado largo.');
85
+ if (size > limit) throw new Error('El mensaje es demasiado largo.');
77
86
  chunks.push(chunk);
78
87
  }
79
88
  return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
80
89
  }
81
90
 
91
+ const remoteLists=process.env.OVERLAY_REMOTE_LISTS_ENABLED==='true'?require('./remote-lists/local').createLocalRemote({enabled:true,dataDir,getSnapshot:rosterSnapshot,applyRoster:(id,roster,metadata)=>{
92
+ const next={...rosters,[id]:roster};fs.writeFileSync(rostersPath+'.tmp',JSON.stringify(next,null,2));fs.renameSync(rostersPath+'.tmp',rostersPath);rosters=next;if(metadata){for(const side of ['left','right'])if(state[side].rosterId===id)state[side]={...state[side],...(metadata.faction?{faction:metadata.faction}:{}),...(metadata.detachments.length?{detachments:metadata.detachments}:{})};saveState();broadcast();}else broadcastRosters();
93
+ }}):null;
82
94
  const server = http.createServer(async (req, res) => {
83
95
  res.setHeader('Cache-Control','no-store');
84
96
  const url = new URL(req.url, 'http://localhost');
97
+ if(url.pathname==='/remote-lists'||url.pathname.startsWith('/remote-lists/')){if(remoteLists)return remoteLists.handle(req,res,url);return send(res,url.pathname==='/remote-lists'&&req.method==='GET'?200:404,'application/json',JSON.stringify({enabled:false}));}
98
+ if(url.pathname==='/rosters/events' && req.method==='GET'){
99
+ res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
100
+ rosterListeners.add(res);res.write(`data: ${JSON.stringify(rosterSnapshot())}\n\n`);
101
+ req.on('close',()=>rosterListeners.delete(res));return;
102
+ }
103
+ if(url.pathname==='/rosters' && req.method==='GET')return send(res,200,'application/json',JSON.stringify(rosterSnapshot()));
104
+ const statusRoute=url.pathname.match(/^\/rosters\/([a-zA-Z0-9_-]+)\/units\/([a-zA-Z0-9_-]+)\/status$/);
105
+ if(statusRoute && req.method==='PATCH'){
106
+ try{
107
+ const input=await readToastBody(req,2000),[,id,unitId]=statusRoute;
108
+ if(![state.left.rosterId,state.right.rosterId].includes(id)||!rosters[id]?.units?.some(u=>u.id===unitId))return send(res,404,'application/json',JSON.stringify({error:'Unidad no encontrada.'}));
109
+ const roster=updateUnitStatus(rosters[id],unitId,input),next={...rosters,[id]:roster};
110
+ fs.writeFileSync(rostersPath+'.tmp',JSON.stringify(next,null,2));fs.renameSync(rostersPath+'.tmp',rostersPath);rosters=next;
111
+ broadcastRosters();return send(res,200,'application/json',JSON.stringify(roster));
112
+ }catch(e){return send(res,400,'application/json',JSON.stringify({error:e.message}));}
113
+ }
114
+ if(url.pathname.startsWith('/rosters/') && req.method==='POST'){
115
+ try{
116
+ const id=url.pathname.slice('/rosters/'.length);
117
+ if(![state.left.rosterId,state.right.rosterId].includes(id))return send(res,404,'application/json',JSON.stringify({error:'Jugador no encontrado. Recarga el panel.'}));
118
+ const input=await readToastBody(req,200000);
119
+ const old=rosters[id]||{revision:0,units:[]};
120
+ if(input.revision!==old.revision)return send(res,409,'application/json',JSON.stringify({error:'La lista cambió en otro panel. Recarga antes de guardar.'}));
121
+ const roster={revision:old.revision+1,...(old.remoteImportId?{remoteImportId:old.remoteImportId}:{}),name:validateName(input.name??old.name??''),units:validateUnits(input.units),backgroundUrl:validateBackground(input.backgroundUrl??old.backgroundUrl??'')};
122
+ const next={...rosters,[id]:roster};
123
+ fs.writeFileSync(rostersPath+'.tmp',JSON.stringify(next,null,2));fs.renameSync(rostersPath+'.tmp',rostersPath);rosters=next;
124
+ broadcastRosters();return send(res,200,'application/json',JSON.stringify(roster));
125
+ }catch(e){return send(res,400,'application/json',JSON.stringify({error:e.message}));}
126
+ }
85
127
  if (url.pathname === '/toasts/events' && req.method === 'GET') {
86
128
  res.writeHead(200, {'Content-Type':'text/event-stream', 'Cache-Control':'no-cache', Connection:'keep-alive'});
87
129
  toastListeners.set(res, url.searchParams.get('client') === 'control' ? 'control' : 'overlay');
@@ -112,7 +154,7 @@ const server = http.createServer(async (req, res) => {
112
154
  if (url.pathname === '/state' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify(state));
113
155
  if (url.pathname === '/state' && req.method === 'POST') {
114
156
  let body = ''; req.on('data', c => body += c); req.on('end', () => {
115
- try { state = JSON.parse(body); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
157
+ try { state = linkPlayers(JSON.parse(body),state); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
116
158
  catch { send(res, 400, 'application/json', JSON.stringify({ error: 'Invalid data' })); }
117
159
  }); return;
118
160
  }
@@ -0,0 +1,27 @@
1
+ const fs=require('node:fs/promises'),path=require('node:path'),os=require('node:os');
2
+ const {createHash,randomUUID}=require('node:crypto');
3
+ const MODEL='onnx-community/Kokoro-82M-v1.0-ONNX';
4
+ const REVISION='1939ad2a8e416c0acfeecc08a694d14ef25f2231';
5
+ const ASSETS={
6
+ 'onnx/model_quantized.onnx':'fbae9257e1e05ffc727e951ef9b9c98418e6d79f1c9b6b13bd59f5c9028a1478',
7
+ 'voices/ef_dora.bin':'f66ec66bd295acb18372e37008533a9a3228483ccd294e7538d5d9294ac9a532',
8
+ 'voices/em_alex.bin':'27809e9eafdcbcfff90a3016c697568676531de2a2c39cee29c96c7bd6b83e95',
9
+ 'voices/em_santa.bin':'ad43b774e1ca24d05c6161297d8aeb770ac3d29bb95daf516727af5f7d543683',
10
+ 'tokenizer.json':'77a02c8e164413299b4b4c403b14f8e0e1c1b727db4d46a09d6327b861060a34'
11
+ };
12
+ function cacheDirectory(){return process.env.KOKORO_CACHE_DIR||path.join(os.homedir(),'.cache','wargaming-overlay','kokoro',REVISION);}
13
+ const hash=b=>createHash('sha256').update(b).digest('hex');
14
+ async function asset(name,{cacheDir=cacheDirectory(),fetchImpl=fetch,signal,log=()=>{}}={}){
15
+ if(!Object.hasOwn(ASSETS,name))throw Error('Recurso Kokoro desconocido.');
16
+ const dest=path.join(cacheDir,name);
17
+ try{const b=await fs.readFile(dest);if(hash(b)===ASSETS[name])return b;}catch(e){if(e.code!=='ENOENT')throw e;}
18
+ log(`[Kokoro] Descargando ${name} (solo la primera vez).`);
19
+ const response=await fetchImpl(`https://huggingface.co/${MODEL}/resolve/${REVISION}/${name}`,{signal:signal||AbortSignal.timeout(90000)});
20
+ if(!response.ok)throw Error(`Descarga Kokoro: HTTP ${response.status}.`);
21
+ const data=Buffer.from(await response.arrayBuffer());if(hash(data)!==ASSETS[name])throw Error('La descarga Kokoro no pasó la verificación de integridad.');
22
+ await fs.mkdir(path.dirname(dest),{recursive:true});const temporary=dest+'.'+randomUUID()+'.tmp';
23
+ try{await fs.writeFile(temporary,data);await fs.rename(temporary,dest);}finally{await fs.rm(temporary,{force:true});}
24
+ return data;
25
+ }
26
+ async function ensureAssets(options){const [model,tokenizer,...voices]=await Promise.all(['onnx/model_quantized.onnx','tokenizer.json','voices/ef_dora.bin','voices/em_alex.bin','voices/em_santa.bin'].map(name=>asset(name,options)));return {model,voices:Object.fromEntries(['ef_dora','em_alex','em_santa'].map((v,i)=>[v,voices[i]])),vocab:JSON.parse(tokenizer).model.vocab};}
27
+ module.exports={ensureAssets,asset,ASSETS,MODEL,REVISION,cacheDirectory};
@@ -0,0 +1,31 @@
1
+ const {ensureAssets}=require('./kokoro-assets');
2
+ let engine;
3
+ async function load(options={}){
4
+ if(!engine)engine=(async()=>{
5
+ const wasm=process.platform==='win32'||process.env.KOKORO_RUNTIME==='wasm';
6
+ const ort=require(wasm?'onnxruntime-web':'onnxruntime-node');
7
+ if(wasm)ort.env.wasm.numThreads=1;
8
+ const {model,voices,vocab}=await ensureAssets(options);
9
+ const {default:init}=await import('@echogarden/espeak-ng-emscripten');
10
+ const module=await init();const espeak=new module.eSpeakNGWorker();
11
+ const session=await ort.InferenceSession.create(model,{executionProviders:[wasm?'wasm':'cpu'],intraOpNumThreads:2});
12
+ return {ort,voices,vocab,espeak,session};
13
+ })().catch(e=>{engine=null;throw e;});
14
+ return engine;
15
+ }
16
+ async function generate(text,{voice='ef_dora',speed=1,...options}={}){
17
+ if(!['ef_dora','em_alex','em_santa'].includes(voice))throw Error('Voz española desconocida.');
18
+ if(!Number.isFinite(speed)||speed<0.5||speed>2)throw Error('kokoroSpeed debe estar entre 0.5 y 2.');
19
+ const {ort,voices,vocab,espeak,session}=await load(options);
20
+ espeak.set_voice('es');
21
+ // Preserve clause punctuation and map eSpeak IPA affricates to Kokoro's vocabulary.
22
+ const phones=String(text).normalize('NFC').split(/([:;,.!?])/).map(part=>/^[,:;.!?]$/.test(part)?part:espeak.synthesize_ipa(part.replace(/[<>\[\]]/g,'')).ipa.replaceAll('_','').trim().replace(/\n/g,' ')).join(' ').replaceAll('tʃ','ʧ').replaceAll('dʒ','ʤ').replace(/\u0361/g,'');
23
+ const tokens=Array.from(phones).filter(c=>vocab[c]!==undefined).map(c=>vocab[c]);
24
+ if(!tokens.length)throw Error('No hay texto pronunciable.');
25
+ if(tokens.length>509)throw Error('El texto excede el límite de Kokoro.');
26
+ const ids=[0,...tokens,0];const offset=tokens.length*256*4;
27
+ const bytes=voices[voice].subarray(offset,offset+1024);const style=new Float32Array(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.length));
28
+ const out=await session.run({input_ids:new ort.Tensor('int64',BigInt64Array.from(ids,BigInt),[1,ids.length]),style:new ort.Tensor('float32',style,[1,256]),speed:new ort.Tensor('float32',Float32Array.of(speed),[1])});
29
+ return {samples:out.waveform.data,sampleRate:24000};
30
+ }
31
+ module.exports={generate};
@@ -0,0 +1,13 @@
1
+ // Isolate native synthesis/playback from the overlay. Killing this process cancels both.
2
+ const {generate}=require('./kokoro-engine');
3
+ const {play}=require('./playback');
4
+ let input='';process.stdin.setEncoding('utf8');
5
+ process.stdin.on('data',chunk=>{input+=chunk;if(input.length>20000)process.exit(1);});
6
+ process.stdin.on('end',async()=>{
7
+ try{
8
+ const {text,voice,speed,cacheDir}=JSON.parse(input);const start=performance.now();
9
+ const {samples,sampleRate}=await generate(text,{voice,speed,cacheDir,log:message=>console.error(message)});
10
+ console.error(`[Kokoro] Audio generado en ${((performance.now()-start)/1000).toFixed(2)} s.`);
11
+ if(process.platform==='win32')await require('./windows-playback').playWindows(samples,sampleRate);else await play(samples,sampleRate);process.exit(0);
12
+ }catch(e){console.error('[Kokoro] '+e.message);process.exit(1);}
13
+ });