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.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')+'
'+ '

Das Charakterverzeichnis

Wer steckt hinter der Maske?

Alle Charaktere '+icon('arrow')+'
'+featured.map(card).join('')+'
'+ '

So läuft eine Runde

Spielregeln '+icon('arrow')+'
'+features.map(([symbol,title,text])=>'
'+icon(symbol)+'

'+title+'

'+text+'

').join('')+'
'; } function joinPage(){ const queryCode=invitationCode(); const sameHost=hostAccess()?.code===queryCode; const hostHint=sameHost?'

Dieser Browser leitet bereits diese Partie. Teste den Beitritt auf einem anderen Gerät oder in einem privaten Browserfenster.

':''; const resumed=session?'

Du hast eine aktive Sitzung. '+link('Zur Partie','#'+roomPage(),'compact')+'

':''; return '
'+header('Am Spieltisch','Die Spielleitung erstellt eine Partie. Alle anderen treten auf ihrem eigenen Gerät bei.')+hostHint+resumed+'

Partie beitreten

Den Code erhältst du von deiner Spielleitung.

Spielleitung übernehmen

Erstelle eine Lobby, teile den Spielcode und vergebe die Rollen.

'+btn('Partie erstellen '+icon('arrow'),'create')+'
'; } 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 '
Spielregeln werden geladen …
'; const roles=catalog.roles; const order=roles.slice().sort((a,b)=>a.phase-b.phase||a.order-b.order||a.name.localeCompare(b.name,'de')); const counts=[]; for(let n=catalog.minPlayers;n<=Math.min(catalog.maxPlayers,roles.length);n++){ const [h,v]=recommended(n); counts.push(''+n+''+h+''+v+''); } return '
'+header('Spielregeln','Eine Runde beginnt nachts und endet mit dem Stadtrat.','',link('Ursprüngliches Regelheft (PDF)','/regelheft.pdf','ghost'))+'

Spielziel

'+esc(catalog.rules.overview)+'

Nachtphase

'+esc(catalog.rules.night)+'

    '+phases.map((p,i)=>'
  1. '+p+'
    '+order.filter(r=>r.phase===i+1).map(r=>esc(r.name)).join(' → ')+'
  2. ').join('')+'
  3. Teamangriff
  4. Rückruf und Morgenbericht

Tagphase

'+esc(catalog.rules.day)+'

Besetzungsempfehlung

'+counts.join('')+'
SpielendeHeldenBösewichte

Kraftstufen

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 ''; } function nightPlan(s){ const current=activated(s).find(p=>p.id===s.turn); return '
Vollständiger Nachtplan
    '+s.order.map(id=>{ const r=s.config.roles.find(x=>x.id===id); const p=s.players.find(x=>x.role===id); const cls=p?.id===current?.id&¤t?'current':p?.alive?'':'unused'; return '
  1. '+r.phase+''+esc(r.name)+''+(!p?'Unbesetzt':!p.alive?'Ausgeschieden':esc(p.name))+'
  2. '; }).join('')+'
  3. 6Teamangriff
  4. 7Rückruf und Morgenbericht
'; } function dayPlan(s){ return '
Vollständiger Tagesplan
    '+['discussion','nomination','defense','ballot','result'].map((id,i)=>'
  1. '+(i+1)+''+stages[id]+'
  2. ').join('')+'
'; } function logPanel(s){ const list=(items)=>items.length?'
'+items.map(l=>'').join('')+'
WerKraft / ZieleErgebnis
'+esc(l.actorName)+'
'+esc(l.role)+'
'+esc(levels[l.level]??'Passen')+(l.fromRequest?' · Smartphone':'')+(l.targetNames?.length?'
'+esc(l.targetNames.join(', ')):'')+'
'+esc(l.message)+'
':'

In dieser Nacht wurde noch keine Kraft aufgelöst.

'; return '

Aktionsprotokoll

Runde '+s.round+' · vertraulich

'+list(s.night.log)+s.history.slice().reverse().map(h=>'
Runde '+h.round+''+list(h.log)+'
').join('')+'
'; } function targetsFor(s,r,a,p){ const spec=catalog?.effects[a.effect]; if(!spec)return {spec:null,options:[]}; let pool=activated(s); if(spec.neighbor){ const pos=pool.findIndex(x=>x.id===p.id); pool=pool.length<2?[]:[...new Set([pool[(pos+1)%pool.length],pool[(pos+pool.length-1)%pool.length]])]; } return {spec,options:pool.filter(x=>x.id!==p.id||spec.self)}; } function pickTargets(spec,options){ if(!spec||spec.max===0)return 'Diese Kraft braucht kein ausgewähltes Ziel.'; return Array.from({length:spec.max},(_,i)=>'').join(''); } function hostTurn(s){ const p=s.players.find(x=>x.id===s.turn); const r=playerRole(s,p); if(!p||!r)return '

Diese Rolle ist nicht mehr aktiv.

'; if(s.night.resolved){ const l=s.night.log.at(-1); return '

„'+esc(r.name)+', schlaf ein.“

'+esc(l?.message)+'

'+btn('Nächster Aufruf '+icon('arrow'),'advance','primary'); } const pending=s.night.pending; const note=pending?'
Auswahl vom Smartphone
'+esc(p.name)+' · '+esc(levels[pending.level]??'Passen')+' '+esc(pending.targets.map(id=>targetName(s,id)).join(', '))+'
'+btn(pending.level===-1?'Passen bestätigen':'Auswahl anwenden','apply-request','primary'):'

Warte auf die Smartphone-Auswahl oder trage die Entscheidung vom Tisch ein.

'; const choices=r.abilities.map((a,i)=>{ const {spec,options}=targetsFor(s,r,a,p); const blocked=s.night.block[p.id]===2||(s.night.block[p.id]===1&&i===0); const disabled=blocked||(a.limit!==0&&p.used[i]>=a.limit)||!spec||options.length'+levels[i]+' '+esc(blocked?'Blockiert':limit(a,p.used[i]))+'

'+esc(a.text)+'

'+(disabled?'':'
'+pickTargets(spec,options)+'
')+''; }).join(''); return '

„'+esc(r.name)+', wach auf.“

'+esc(p.name)+' · '+esc(phases[r.phase-1])+'

'+note+choices+btn('Rolle passt','pass','compact'); } function votingForm(s,action,options,explanation,button){ return '

'+esc(explanation)+'

'+options.map(id=>'').join('')+'
'; } function hostStep(s){ const active=activated(s); const heroes=active.filter(p=>playerRole(s,p).faction==='hero'); const nominees=s.day.finalists.filter(id=>active.some(p=>p.id===id)); const timings=s.config.gameplay??{discussionSeconds:180,defenseSeconds:30}; const next=(label)=>btn(label+' '+icon('arrow'),'advance','primary'); switch(s.stage){ case 'eyes':return '

„Meridian, schließt die Augen.“

Warte, bis alle bereit sind.

'+next('Augen sind geschlossen'); case 'recognize':return '

„Superbösewichte, wacht auf und erkennt euch. Schlaft wieder ein.“

'+next('Einzelaufrufe starten'); case 'turn':return hostTurn(s); case 'team':return '

„Superbösewichte, wacht auf.“

'+votingForm(s,'team',heroes.map(p=>p.id),'Alle aktiven Superbösewichte zeigen auf ein Ziel oder enthalten sich. Trage die Stimmen ein und lass das Team einschlafen.','Teamangriff auswerten'); case 'follow':return '

Rückruf für aktive Rollen

'+esc(s.night.follow.filter(f=>active.some(p=>p.id===f.actor)).map(f=>playerRole(s,s.players.find(p=>p.id===f.actor))?.name).join(', '))+' erneut aufrufen. Die Rückmeldung wird nach der Auswertung angezeigt.

'+next('Rückruf und Schutz auswerten'); case 'dawn':return '

„Meridian, wach auf.“

'+esc(s.night.result?.message)+'

'+(s.night.replies??[]).map(f=>'

'+esc(targetName(s,f.actor))+' · '+esc(f.message)+'

').join('')+'

Sage öffentlich nur, wer ausgeschieden ist und zu welcher Fraktion die Person gehört. Andernfalls sind alle noch im Spiel.

'+next('Morgenbericht bestätigen'); case 'discussion':return '

Der Stadtrat diskutiert.

Gebt dem Tisch '+timings.discussionSeconds+' Sekunden. Nur aktive Personen diskutieren und stimmen ab.

'+next('Nominierung öffnen'); case 'nomination':return votingForm(s,'nominate',active.map(p=>p.id),'Alle Aktiven zeigen gleichzeitig auf eine andere Person oder enthalten sich. Trage die echten Stimmen ein.','Nominierungen auswerten'); case 'defense':return '

'+esc(nominees.map(id=>targetName(s,id)).join(' und '))+'

Jede nominierte Person hat '+timings.defenseSeconds+' Sekunden zur Verteidigung.'+(nominees.length===1?' Die Alternative lautet Freispruch.':'')+'

'+next('Geheime Stichwahl öffnen'); case 'ballot':return votingForm(s,'ballot',nominees.length===1?[...nominees,'pardon']:nominees,'Sammelt die geheimen Zettel und tragt die echten Stimmen ein. Leere oder ungültige Zettel zählen als Enthaltung.','Stichwahl auswerten'); case 'result':return '

'+esc(s.day.outcome?.message)+'

'+next('Nächste Nacht starten'); case 'victory':return '

'+factionPlural(s.winner)+' gewinnen.

'+esc(s.day.outcome?.message??s.night.result?.message??'')+'

'+btn('Revanche vorbereiten','reset','primary'); default:return ''; } } function hostPage(){ const s=hostAccess(); if(!s)return '
'+header('Spielleitung','Erstelle eine Lobby oder öffne deine vorhandene Partie.')+btn('Partie erstellen','create','primary')+'
'; const h=s.players.filter(p=>playerRole(s,p)?.faction==='hero').length; const v=s.players.filter(p=>playerRole(s,p)?.faction==='villain').length; const [eh,ev]=recommended(s.players.length); const lobby=s.phase==='lobby'; const lobbyInfo='

Rollen verteilen

Neben jedem Namen kannst du eine noch freie Rolle auswählen. Die Zuteilung erscheint sofort privat auf dem jeweiligen Gerät. Ordne die Plätze mit den Pfeilen wie am Tisch im Uhrzeigersinn an.

'+tag(h+' Helden')+' '+tag(v+' Bösewichte','villain')+' '+(s.players.length>=s.config.minPlayers?tag('Empfohlen '+eh+' / '+ev):'')+'

'+btn('Erste Nacht starten '+icon('arrow'),'start-game','primary')+nightPlan(s); return '
'+header('Spielleitung',lobby?'Lobby und Rollenvergabe':'Runde '+s.round+' · '+(s.phase==='night'?'Nacht':s.phase==='day'?'Stadtrat':'Spielende'),'Private Ansicht','
'+btn('Einladungslink kopieren','copy','compact ghost')+''+esc(s.code)+'
')+'
'+(lobby?lobbyInfo:'
'+esc(stages[s.stage])+'
'+hostStep(s)+(s.phase==='night'?nightPlan(s):dayPlan(s)))+'
'+(!lobby?logPanel(s):'')+'

Partie zurücksetzen

'+btn('Neue Partie, Rollen behalten','reset','compact')+btn('Rollen auf null setzen','clear','compact danger')+'
'+rosterPanel(s)+'
'; } function playerPage(){ const s=playerAccess(); if(!s)return joinPage(); const {me}=s; let body=''; if(!me.role)body='

Du bist angemeldet.

Warte auf deine Rollenzuteilung durch die Spielleitung.

'; else{ const r=me.role; const status=!me.alive?'

Du bist ausgeschieden. Du setzt aus, bis die Spielleitung dich wiederbelebt.

':me.notice?.kind==='revived'?'

Du wurdest wiederbelebt. Du kannst wieder mitspielen.

':''; const turn=s.selected?'Deine Wahl ist abgeschlossen.':s.turn?'Du bist an der Reihe. Wähle eine Kraft oder passe.':s.phase==='lobby'?'Warte, bis die Spielleitung die erste Nacht startet.':s.phase==='day'?'Der Stadtrat läuft am Tisch. Folge den Ansagen der Spielleitung.':s.phase==='ended'?factionPlural(s.winner)+' gewinnen.':'Warte, bis deine Rolle aufgerufen wird.'; const powers=r.abilities.map((a,i)=>{ const spec=catalog?.effects[a.effect]; const can=me.alive&&s.turn&&!s.selected&&s.allowed[i]&&spec; const pool=s.targets.filter(p=>p.id!==me.id||spec?.self); return '
'+levels[i]+' '+esc(s.turn&&(s.blocked===2||(s.blocked===1&&i===0))?'Blockiert':limit(a,me.used[i]))+'

'+esc(a.text)+'

'+(can?'
'+pickTargets(spec,pool)+'
':'')+'
'; }).join(''); body='
'+roleTag(r)+'

'+esc(r.name)+'

Charakterverzeichnis '+icon('arrow')+'
'+status+'

'+esc(turn)+'

'+(s.feedback?'

Private Rückmeldung: '+esc(s.feedback)+'

':'')+'

Deine Kräfte

'+powers+(me.alive&&s.turn&&!s.selected?btn('Diese Nacht passen','player-pass','compact'):'')+'
'; } return '
'+header('Deine Partie',me.name+' · Platz '+me.seat+' · '+s.code+(s.round?' · Runde '+s.round:''),'Private Ansicht')+body+'
'; } async function loadAdmin(){ try{admin=await request('/api/admin');adminDraft=admin.allowed?structuredClone(admin.config):null;selectedRole=adminDraft?.roles[0]?.id??null;render();} catch(e){showToast(e.message,true);} } const numeric=(label,value,attrs)=>''; const adminField=(label,value,attrs,mode='input')=>''; function adminPage(){ const open='
'+header('Verwaltung','Charaktere, Bilder und Spielregeln bearbeiten.'); if(!admin)return open+'

Admin-Zugriff wird geprüft …

'; if(!admin.allowed)return open+'

Admin-Anmeldung

'+(admin.signIn?link('Mit ChatGPT anmelden','/signin-with-chatgpt?return_to=%2F%23admin','primary'):'')+(admin.password?'
':'')+(!admin.signIn&&!admin.password?'

Auf diesem Server ist kein Admin-Zugang konfiguriert.

':'

Nur freigeschaltete Administratoren können Änderungen speichern.

')+'
'; const d=adminDraft,r=d.roles.find(x=>x.id===selectedRole); const editor=r?'

'+esc(r.name)+'

'+adminField('Rollenname',r.name,'data-role-field="name" maxlength="36"')+'
'+ adminField('Thema',r.theme,'data-role-field="theme" maxlength="55"')+adminField('Charakterbeschreibung',r.description,'data-role-field="description" maxlength="800"','textarea')+'
'+numeric('Nachtphase',r.phase,'min="1" max="5" data-role-field="phase"')+numeric('Reihenfolge innerhalb der Phase',r.order,'min="1" max="999" data-role-field="order"')+'
Aktuelles Rollenporträt'+ r.abilities.map((a,i)=>'
'+levels[i]+''+adminField('Beschreibung',a.text,'data-ability="'+i+'" data-key="text" maxlength="550"','textarea')+'
'+numeric('Einsätze pro Partie (0 = jede Nacht)',a.limit,'min="0" max="99" data-ability="'+i+'" data-key="limit"')+'
').join('')+ btn('Rolle entfernen','remove-role','compact danger')+'
':'
Wähle eine Rolle zum Bearbeiten.
'; return open+'

Gespeicherte Änderungen erscheinen auf der Homepage und gelten für neu angelegte Partien. Laufende Räume behalten ihr Regelwerk.

'+editor+'

Startbild und Spielregeln

Aktuelles Startbild
'+numeric('Mindestanzahl',d.minPlayers,'min="3" max="30" data-config="minPlayers"')+numeric('Höchstanzahl',d.maxPlayers,'min="3" max="30" data-config="maxPlayers"')+'

Automatische Spielregeln

'+numeric('Deckungen für Abwehr',d.gameplay.coverToSave,'min="1" max="6" data-gameplay="coverToSave"')+numeric('Maximaler Ratseinfluss',d.gameplay.ratCap,'min="0" max="6" data-gameplay="ratCap"')+numeric('Diskussion in Sekunden',d.gameplay.discussionSeconds,'min="0" max="3600" data-gameplay="discussionSeconds"')+numeric('Verteidigung in Sekunden',d.gameplay.defenseSeconds,'min="0" max="600" data-gameplay="defenseSeconds"')+'
'+[['overview','Spielziel'],['night','Nachtregeln'],['day','Tagesregeln']].map(([k,label])=>adminField(label,d.rules[k],'data-rule="'+k+'" maxlength="5500"','textarea')).join('')+btn('Änderungen speichern','save-admin','primary')+'
'; } function render(){ const current=page(), r=playerAccess(); if(r){ const tagText=r.me.role?''+esc(r.me.role.name)+' '+tag(r.me.alive?'Aktiv':'Ausgeschieden',r.me.alive?'':'out'):'Rolle wird zugeteilt'; ribbon.innerHTML='
Privat · '+tagText+'Meine Partie ↗
'; }else ribbon.innerHTML=''; sessionLink.href=session?'#'+roomPage():'/beitreten'; sessionLink.innerHTML=(session?'Meine Partie':'Spielen')+' '; for(const a of document.querySelectorAll('.site-header nav a')){ if(a.getAttribute('href')==='#'+current)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current'); } const renderers={start:home,beitreten:joinPage,charaktere:gallery,regeln:rulePage,leitung:hostPage,spiel:playerPage,admin:adminPage}; app.innerHTML=renderers[current](); } async function host(action,extra={}){ const s=hostAccess(); if(!s)return; accept(await request(roomUrl(s.code)+'/action','POST',{action,revision:session.revision,...extra})); } async function choose(level,targets){ const s=playerAccess(); if(!s)return; accept(await request(roomUrl(s.code)+'/action','POST',{action:'choose',level,targets,gameId:s.gameId,round:s.round})); showToast('Deine Wahl wurde der Spielleitung angezeigt.'); } async function create(){ const next=await request('/api/rooms','POST',{}); accept(next,{rerender:false}); history.replaceState(null,'','/#leitung'); render(); scrollTo(0,0); } function confirmed(message){return window.confirm(message);} document.addEventListener('click',event=>{ const b=event.target.closest('[data-action]'); if(!b||b.disabled)return; const {action,id,alive}=b.dataset; if(action==='role'){showRole(id);return;} if(action==='close-dialog'){modal.close();return;} if(action==='filter'){filter=id;render();return;} if(action==='create'){run(create);return;} if(action==='copy'){run(async()=>{ const invite=linkForCode(hostAccess().code); try{ await navigator.clipboard.writeText(invite); showToast('Einladungslink kopiert.'); }catch{ modal.innerHTML='
'; modal.showModal();modal.querySelector('input').select(); } });return;} if(action==='seat'){run(()=>host('seat',{player:id,direction:Number(b.dataset.direction)}));return;} if(action==='status'){run(()=>host('status',{player:id,alive:alive==='true'}));return;} if(action==='start-game'){run(()=>host('start'));return;} if(action==='advance'){run(()=>host('advance'));return;} if(action==='apply-request'){run(()=>host('apply',{useRequest:true}));return;} if(action==='pass'){run(()=>host('apply',{level:-1,targets:[]}));return;} if(action==='player-pass'){run(()=>choose(-1,[]));return;} if(action==='reset'||action==='clear'){ if(confirmed(action==='clear'?'Partie zurücksetzen und alle Rollen entfernen?':'Eine neue Partie mit denselben Rollen vorbereiten?'))run(()=>host('reset',{clearRoles:action==='clear'})); return; } if(action==='edit-role'){selectedRole=id;render();return;} if(action==='add-role'&&adminDraft){ const role={id:'rolle-'+crypto.randomUUID().slice(0,8),name:'Neue Rolle '+(adminDraft.roles.length+1),theme:'',description:'Beschreibe diese Rolle.',faction:'hero',phase:3,order:100,image:'/art/aegis.webp',abilities:levels.map((x,i)=>({text:x+'e Kraft beschreiben.',effect:'manual',limit:[0,2,1][i]}))}; adminDraft.roles.push(role);selectedRole=role.id;render();return; } if(action==='remove-role'&&adminDraft&&confirmed('Diese Rolle aus dem Katalog entfernen?')){ adminDraft.roles=adminDraft.roles.filter(x=>x.id!==selectedRole);selectedRole=adminDraft.roles[0]?.id??null;render();return; } if(action==='save-admin')run(async()=>{ admin=await request('/api/admin/catalog','PUT',{revision:admin.revision,config:adminDraft}); adminDraft=structuredClone(admin.config); await loadCatalog(); showToast('Katalog und Regeln gespeichert.'); }); }); document.addEventListener('submit',e=>{ const f=e.target; if(!f.matches('form[data-form]'))return; e.preventDefault(); const data=new FormData(f); switch(f.dataset.form){ case 'join': run(async()=>{ const code=String(data.get('code')).trim().toUpperCase(); const next=await request(roomUrl(code)+'/join','POST',{name:data.get('name')}); if(next.kind==='host')throw new Error('Dieser Browser leitet bereits diese Partie. Öffne den Link in einem privaten Browserfenster oder auf einem anderen Gerät.'); accept(next,{rerender:false}); history.replaceState(null,'','/#'+roomPage()); render();scrollTo(0,0); });break; case 'host-power':run(()=>host('apply',{level:Number(f.dataset.level),targets:data.getAll('target').filter(Boolean)}));break; case 'player-power':run(()=>choose(Number(f.dataset.level),data.getAll('target').filter(Boolean)));break; case 'votes':run(()=>host(f.dataset.vote,{votes:Object.fromEntries([...data].map(([k,v])=>[k.slice(5),Number(v)]))}));break; case 'admin-login':run(async()=>{await request('/api/admin/login','POST',{password:data.get('password')});await loadAdmin();});break; } }); function saveDraft(e){ const el=e.target; if(!adminDraft||page()!=='admin'||!el.dataset)return; const r=adminDraft.roles.find(x=>x.id===selectedRole); if(el.dataset.roleField&&r)r[el.dataset.roleField]=el.type==='number'?Number(el.value):el.value; if(el.dataset.ability&&r)r.abilities[Number(el.dataset.ability)][el.dataset.key]=el.type==='number'?Number(el.value):el.value; if(el.dataset.config)adminDraft[el.dataset.config]=Number(el.value); if(el.dataset.rule)adminDraft.rules[el.dataset.rule]=el.value; if(el.dataset.gameplay)adminDraft.gameplay[el.dataset.gameplay]=el.type==='checkbox'?el.checked:Number(el.value); } document.addEventListener('input',saveDraft); document.addEventListener('change',e=>{ const el=e.target; saveDraft(e); if(el.dataset.assign)run(async()=>{ try{await host('assign',{player:el.dataset.assign,role:el.value||null});} catch(error){render();throw error;} }); if(el.dataset.upload&&el.files?.[0]){ const id=selectedRole; run(async()=>{ const file=el.files[0]; if(file.size>3145728)throw new Error('Das Bild darf höchstens 3 MB groß sein.'); const result=await request('/api/admin/image','POST',file); if(el.dataset.upload==='hero')adminDraft.heroImage=result.path; else{const role=adminDraft.roles.find(x=>x.id===id);if(role)role.image=result.path;} render();showToast('Bild hochgeladen. Speichere die Änderungen, um es zu übernehmen.'); }); } }); window.addEventListener('hashchange',()=>{ render(); if(page()==='admin'&&!admin)loadAdmin(); scrollTo(0,0); }); window.addEventListener('offline',()=>showConnection('Offline. Dein Spielstand wird beim Wiederverbinden aktualisiert.')); window.addEventListener('online',()=>{showConnection();if(!catalog)loadCatalog();refresh({force:true});}); window.addEventListener('pageshow',()=>refresh({force:true})); document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh({force:true});}); setInterval(refresh,1800); render();loadCatalog();refresh({force:true}); if(page()==='admin')loadAdmin(); if('serviceWorker'in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{});