const app=document.querySelector('#app'), ribbon=document.querySelector('#private-ribbon');
const modal=document.querySelector('#modal'), toastNode=document.querySelector('#toast');
const connection=document.querySelector('#connection'), sessionLink=document.querySelector('#session-link');
const levels=['Schwach','Mittel','Stark'];
const phases=['Störung und Tarnung','Ermittlung','Schutz','Schutzabbau','Ratseffekte'];
const stages={eyes:'Augen schließen',recognize:'Erkennungsrunde',turn:'Rollenaktion',team:'Teamangriff',follow:'Nacht-Rückruf',dawn:'Morgenbericht',discussion:'Diskussion',nomination:'Offene Nominierung',defense:'Verteidigung',ballot:'Geheime Stichwahl',result:'Ergebnis',victory:'Partie beendet'};
const paths=new Set(['start','beitreten','charaktere','regeln','spiel','leitung','admin']);
const invitationCode=()=>(new URLSearchParams(location.search).get('code')??'').trim().toUpperCase();
const roomPattern=/^[A-Z2-9]{9}$/;
let catalog=null, session=null, filter='all', admin=null, selectedRole=null, adminDraft=null;
let savedCode=roomStorage('getItem')??'', refreshing=false, busy=false, toastTimer, waitTimeout, seenNotice=null;
if(!roomPattern.test(savedCode))savedCode='';
const icons={
arrow:'',
people:'',
shield:'',
moon:'',
phone:'',
vote:'',
bolt:'',
};
const icon=k=>'';
const esc=v=>String(v??'').replace(/[&<>"']/g,k=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[k]));
const safeImage=path=>/^\/art\/[a-z0-9-]+\.webp$|^\/media\/[0-9a-f-]{36}\.(webp|jpeg|png)$/.test(path??'')?path:'/art/aegis.webp';
const faction=f=>f==='hero'?'Superheld':'Superbösewicht';
const factionPlural=f=>f==='hero'?'Superhelden':'Superbösewichte';
const tag=(label,style='neutral')=>''+esc(label)+'';
const roleTag=r=>tag(faction(r.faction),r.faction==='villain'?'villain':'');
const btn=(label,action,style='',extra='')=>'';
const link=(label,href,style='')=>''+label+'';
const limit=(a,used=0)=>a.limit===0?'Jede Nacht':Math.max(0,a.limit-used)+' von '+a.limit+' übrig';
const page=()=>{const p=location.hash.slice(1);return paths.has(p)?p:(invitationCode()||/^\/beitreten\/?$/.test(location.pathname)?'beitreten':'start');};
const targetName=(s,id)=>s.players.find(p=>p.id===id)?.name??(id==='pardon'?'Freispruch':'–');
const playerRole=(s,p)=>s.config.roles.find(r=>r.id===p?.role);
const activated=s=>s.players.filter(p=>p.alive&&p.role);
const linkForCode=code=>new URL('/beitreten?code='+encodeURIComponent(code),location.origin).href;
const roomUrl=code=>'/api/rooms/'+code;
const roomPage=()=>session?.kind==='host'?'leitung':'spiel';
const hostAccess=()=>session?.kind==='host'?session.room:null;
const playerAccess=()=>session?.kind==='player'?session.room:null;
function roomStorage(method,...args){
try{return window.localStorage[method]('neonwacht-code',...args);}
catch{return null;}
}
function remember(code){savedCode=code;roomStorage('setItem',code);}
function go(hash){if(location.hash!=='#'+hash)location.hash=hash;else render();}
function showToast(text,error=false){
clearTimeout(toastTimer);
toastNode.textContent=text;
toastNode.className=error?'show error':'show';
toastTimer=setTimeout(()=>toastNode.className='',5500);
}
function showConnection(text=''){
connection.hidden=!text;
connection.textContent=text;
}
async function request(url,method='GET',payload){
const opts={method,credentials:'same-origin',cache:'no-store'};
if(payload!==undefined){
const raw=payload instanceof Blob;
opts.headers={'Content-Type':raw?'application/octet-stream':'application/json'};
opts.body=raw?payload:JSON.stringify(payload);
}
const res=await fetch(url,opts);
const data=await res.json().catch(()=>({error:'Der Spielserver antwortet gerade nicht.'}));
if(!res.ok){const err=new Error(data.error??'Anfrage fehlgeschlagen.');err.status=res.status;throw err;}
return data;
}
function accept(next,{rerender=true}={}){
const changed=next.revision!==session?.revision||next.kind!==session?.kind||next.room.code!==session?.room.code;
session=next;remember(next.room.code);
const notice=playerAccess()?.me.notice;
if(notice?.id&¬ice.id!==seenNotice){
seenNotice=notice.id;
if(notice.kind==='revived')showToast('Du wurdest wiederbelebt. Du kannst wieder mitspielen.');
else showToast('Du bist ausgeschieden und setzt aus.');
}
if(changed&&rerender)render();
}
async function refresh({force=false}={}){
if(!savedCode||refreshing||busy)return;
if(!force&&document.hidden)return;
if(!force&&app.contains(document.activeElement)&&document.activeElement.matches('input,textarea,select'))return;
refreshing=true;
try{
const code=savedCode, next=await request(roomUrl(code));
if(!busy&&savedCode===code&&(!session||session.room.code!==code||next.revision>=session.revision))accept(next);
showConnection();
}catch(e){
if(e.status===401||e.status===404){
session=null;
roomStorage('removeItem');
savedCode='';
render();
}else showConnection('Verbindung unterbrochen. Sobald sie wieder da ist, wird der Spielstand geladen.');
}finally{refreshing=false;}
}
async function loadCatalog(){
try{catalog=await request('/api/catalog');render();}
catch{if(!catalog)showConnection('Der Charakterkatalog ist gerade offline.');}
}
async function run(task){
if(busy)return;
busy=true;
app.setAttribute('aria-busy','true');
try{await task();showConnection();}
catch(e){
showToast(e.message||'Die Anfrage konnte nicht gesendet werden.',true);
if(e.status===409)waitTimeout=setTimeout(()=>refresh({force:true}),100);
}finally{busy=false;app.removeAttribute('aria-busy');}
}
function showRole(id){
const r=catalog?.roles.find(x=>x.id===id)??playerAccess()?.me.role;
if(!r)return;
modal.innerHTML='
'+roleTag(r)+'
'+esc(r.name)+'
'+esc(r.description)+'
'+r.abilities.map((a,i)=>'
'+levels[i]+' '+esc(a.limit===0?'Jede Nacht':a.limit+'× pro Spiel')+'
'+esc(a.text)+'
').join('')+'
';
modal.showModal();
}
function header(title,detail='',label='',right=''){
return '
'+(label?'
'+esc(label)+'
':'')+'
'+esc(title)+'
'+(detail?'
'+esc(detail)+'
':'')+'
'+right+'
';
}
function card(r){
return '';
}
function home(){
const featured=['aegis','funke','maskerade','schattenkoenig'].map(id=>catalog?.roles.find(r=>r.id===id)).filter(Boolean);
const image=safeImage(catalog?.heroImage??'/art/meridian.webp');
const features=[
['moon','Nacht','Rollen wachen in fester Reihenfolge auf. Die Spielleitung prüft Ziele und löst Kräfte aus.'],
['vote','Stadtrat','Nominiert offen, verteidigt euch und entscheidet geheim, wer ausscheidet.'],
['phone','Dein Handy','Tritt mit deinem Namen bei. Deine eigene Rolle bleibt auf deinem Bildschirm verfügbar.'],
];
return '
Deduktionsspiel am Tisch
Maskenrat
Kampf um Meridian
Eine Stadt voller Superkräfte. Zwei geheime Fraktionen. Wer hat im Stadtrat deine Stimme verdient?
'+icon('people')+(catalog?.minPlayers??7)+'–'+(catalog?Math.min(catalog.maxPlayers,catalog.roles.length):20)+' Spielende'+icon('shield')+(catalog?.roles.length??20)+' Rollen'+icon('phone')+' Beitritt ohne Konto
'+(session?link('Zur laufenden Partie '+icon('arrow'),'#'+roomPage(),'primary'):btn('Partie erstellen '+icon('arrow'),'create','primary'))+link('Mit Spielcode beitreten','/beitreten','ghost')+'
';
}
function gallery(){
const roles=catalog?.roles??[];
const items=roles.filter(r=>filter==='all'||r.faction===filter);
const filters=[['all','Alle'],['hero','Superhelden'],['villain','Superbösewichte']];
return ''+header('Charaktere','Porträts, Hintergrund und die drei Kräfte jeder Rolle.')+
'
'+filters.map(([id,title])=>'').join('')+'
'+items.map(card).join('')+'
';
}
function recommended(n){
const v=[2,2,2,3,3,3,4,4,4,5,5,5,6,6][n-7]??Math.max(1,Math.floor(n*.3));
return [n-v,v];
}
function rulePage(){
if(!catalog)return '
');
}
return ''+header('Spielregeln','Eine Runde beginnt nachts und endet mit dem Stadtrat.','',link('Ursprüngliches Regelheft (PDF)','/regelheft.pdf','ghost'))+'
Pro Nacht ist höchstens eine persönliche Kraft möglich. Die Standardrollen haben eine schwache Kraft pro Nacht, zwei mittlere Einsätze und einen starken Einsatz pro Partie.
';
}
function rosterPanel(s){
const taken=new Set(s.players.map(p=>p.role).filter(Boolean));
return '