Add ServiceClash game
mindflux-ci / build (push) Successful in 22s

This commit is contained in:
AliceDarkest
2026-07-13 23:06:14 +02:00
parent b8f830dce0
commit 3f32fcd6c1
22 changed files with 233 additions and 22 deletions
@@ -2,7 +2,7 @@
function load(){try{return Object.assign({},defaults,JSON.parse(localStorage.getItem(KEY)||'{}'));}catch(e){return Object.assign({},defaults);}}var state=load();
function apply(){var h=document.documentElement;h.setAttribute('data-mf-font',String(state.font));h.setAttribute('data-mf-readable',state.readable);h.setAttribute('data-mf-contrast',state.contrast);h.setAttribute('data-mf-color',state.color?'safe':'standard');h.setAttribute('data-mf-mono',state.mono);h.setAttribute('data-mf-links',state.links);h.setAttribute('data-mf-motion',state.motion);h.setAttribute('data-mf-cursor',state.cursor);try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
apply();
function applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(!skin&&document.querySelector('.nw-app'))skin='networker';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);}
function applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(!skin&&document.querySelector('.sc-app'))skin='serviceclash';if(!skin&&document.querySelector('.nw-app'))skin='networker';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);}
function button(id,icon,label,detail){return '<button type="button" class="mf-a11y-option" data-a11y="'+id+'"><strong aria-hidden="true">'+icon+'</strong><span>'+label+'</span>'+(detail?'<small>'+detail+'</small>':'')+'</button>';}
function init(){if(document.querySelector('.mf-a11y-trigger'))return;var trigger=document.createElement('button');trigger.type='button';trigger.className='mf-a11y-trigger';trigger.setAttribute('aria-label','Barrierefreiheit öffnen');trigger.setAttribute('aria-expanded','false');trigger.setAttribute('aria-controls','mf-a11y-panel');trigger.innerHTML='<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="18" cy="5" r="3" fill="currentColor"/><path d="M13 11l5-2 5 4m-7-2-2 7-6 1m8-4 6 2 3 7M13 18a7 7 0 1 0 6 7"/></svg>';
var panel=document.createElement('section');panel.id='mf-a11y-panel';panel.className='mf-a11y-panel';panel.hidden=true;panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','false');panel.setAttribute('aria-labelledby','mf-a11y-title');panel.innerHTML='<div class="mf-a11y-head"><button type="button" class="mf-a11y-close" aria-label="Barrierefreiheit schließen">×</button><h2 id="mf-a11y-title">Barrierefreiheit</h2></div><div class="mf-a11y-grid">'+button('font','T⁺','Text vergrößern','100 %')+button('contrast','◐','Hochkontrast')+button('color','◉','Farbenblind-Modus')+button('mono','◒','Monochrom')+button('readable','Aa','Lesemodus (Dyslexie)','Verdana + Abstände')+button('links','🔗','Links unterstreichen')+button('motion','Ⅱ','Animationen stoppen')+button('cursor','➤','Großer Cursor')+button('reset','↺','Alles zurücksetzen')+'</div>';
@@ -0,0 +1,10 @@
(()=>{'use strict';const view=document.getElementById('scView');let catalog=[],session=null,index=0,started=0,states={};const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));const api=(url,opt={})=>fetch('/api/serviceclash'+url,{headers:{'Content-Type':'application/json'},...opt}).then(async r=>{const data=await r.json();if(!r.ok)throw new Error(data.message||'Serverfehler');return data});const post=(url,data)=>api(url,{method:'POST',body:JSON.stringify(data)});const seconds=()=>Math.max(1,Math.round((Date.now()-started)/1000));const current=()=>session.tickets[index];
function renderCatalog(){view.innerHTML=`<div class="sc-eyebrow">Service Desk Simulation</div><h1 class="sc-title">Welche Schicht übernimmst du?</h1><p class="sc-intro">ServiceClash erklärt die Triage direkt im Spiel: Ordne jedes Ticket zuerst als <b>Störung</b> oder <b>Anfrage</b> ein, schätze seine Dringlichkeit ein und wähle dann die beste erste Maßnahme. Du darfst Fehler machen – bei Bedarf zeigt dir „Aufdecken“ die Begründung.</p><div class="sc-shifts">${catalog.map(s=>`<article class="sc-card"><div class="sc-eyebrow">${esc(s.difficulty)} · ${esc(s.examScope)}</div><h2>${esc(s.title)}</h2><p>${esc(s.description)}</p><div class="sc-meta"><span>${s.ticketCount} Tickets</span><span>${s.waves} Wellen</span></div><button class="sc-btn primary" data-shift="${esc(s.slug)}">SCHICHT BRIEFEN</button></article>`).join('')}</div>`;view.querySelectorAll('[data-shift]').forEach(b=>b.onclick=()=>brief(b.dataset.shift));}
async function brief(slug){try{session=await api('/session/'+encodeURIComponent(slug));index=0;states={};view.innerHTML=`<article class="sc-panel sc-brief"><div class="sc-eyebrow">${esc(session.difficulty)} · ${session.tickets.length} Tickets</div><h1>${esc(session.title)}</h1><p class="sc-intro">${esc(session.description)}</p><p class="sc-tip"><b>So spielst du:</b> Eine <b>Störung</b> bedeutet, dass etwas bereits nicht funktioniert. Eine <b>Anfrage</b> wünscht eine geplante Leistung. Priorität <b>hoch</b> wählst du bei größerer Auswirkung, Sicherheitsrisiko oder Zeitkritik. Die Punkte berücksichtigen alle drei Entscheidungen.</p><button id="scStart" class="sc-btn primary">LEITSTAND ÖFFNEN →</button></article>`;document.getElementById('scStart').onclick=()=>{started=Date.now();renderRun()}}catch(error){fail(error)}}
function ticketState(ticket){return states[ticket.ticketId]||{step:'classify',done:[]}}function renderRun(){const ticket=current(),state=ticketState(ticket),done=Object.values(states).filter(s=>s.done?.length===3).length;const wave=Math.max(...session.tickets.map(t=>t.wave));if(!ticket){finish();return}const choices=choiceMarkup(ticket,state);view.innerHTML=`<div class="sc-command"><aside class="sc-panel sc-queue"><div class="sc-eyebrow">${esc(session.title)}</div><div class="sc-stat">Gelöst ${done}/${session.tickets.length}</div><div class="sc-meter"><i style="width:${done/session.tickets.length*100}%"></i></div><div class="sc-ticket-list">${session.tickets.map((t,i)=>`<button class="${i===index?'active':''} ${ticketState(t).done?.length===3?'done':''}" data-ticket="${i}">W${t.wave} · ${esc(t.title)}</button>`).join('')}</div></aside><article class="sc-panel sc-ticket"><div class="sc-ticket-head"><span>WELLE ${ticket.wave}/${wave} · ${esc(ticket.requester)}</span><span>${ticket.points} PTS</span></div><h1>${esc(ticket.title)}</h1><p>${esc(ticket.text)}</p><div class="sc-steps"><span class="sc-step ${state.step==='classify'?'active':''} ${state.done.includes('classify')?'done':''}">1 ART</span><span class="sc-step ${state.step==='priority'?'active':''} ${state.done.includes('priority')?'done':''}">2 PRIORITÄT</span><span class="sc-step ${state.step==='solve'?'active':''} ${state.done.includes('solve')?'done':''}">3 MASSNAHME</span></div>${choices}${state.feedback?`<div class="sc-feedback ${state.bad?'bad':''}">${state.feedback}</div>`:''}${state.done.length===3?`<div class="sc-debrief"><b>Debrief:</b> ${esc(state.explanation||'Ticket dokumentiert.')}<br><button id="scNext" class="sc-btn primary">${index+1===session.tickets.length?'SCHICHT AUSWERTEN':'NÄCHSTES TICKET →'}</button></div>`:''}</article></div>`;view.querySelectorAll('[data-ticket]').forEach(b=>b.onclick=()=>{const target=Number(b.dataset.ticket);if(target<=index||ticketState(session.tickets[target]).done?.length===3){index=target;renderRun()}});view.querySelectorAll('[data-value]').forEach(b=>b.onclick=()=>check(state.step,b.dataset.value));const reveal=document.getElementById('scReveal');if(reveal)reveal.onclick=()=>check(state.step,'',true);const next=document.getElementById('scNext');if(next)next.onclick=()=>{index++;renderRun()}}
function choiceMarkup(ticket,state){if(state.done.length===3)return '';if(state.step==='classify')return `<div class="sc-choices"><button class="sc-choice" data-value="incident"><b>Störung</b><br><small>Etwas funktioniert bereits nicht.</small></button><button class="sc-choice" data-value="request"><b>Anfrage</b><br><small>Eine Leistung soll geplant erbracht werden.</small></button></div>${revealButton()}`;if(state.step==='priority')return `<div class="sc-choices"><button class="sc-choice" data-value="high"><b>Hoch</b><br><small>Sicherheits-, Geschäfts- oder Zeitrisiko.</small></button><button class="sc-choice" data-value="medium"><b>Mittel</b><br><small>Spürbar, aber mit begrenzter Auswirkung.</small></button><button class="sc-choice" data-value="low"><b>Niedrig</b><br><small>Planbar, lokal oder ohne akute Auswirkung.</small></button></div>${revealButton()}`;return `<div class="sc-choices">${ticket.options.map(o=>`<button class="sc-choice" data-value="${esc(o.id)}">${esc(o.text)}</button>`).join('')}</div>${revealButton()}`}
const revealButton=()=>`<button id="scReveal" class="sc-reveal">Antwort aufdecken (keine Punkte für diesen Schritt)</button>`;
async function check(step,value,reveal=false){const ticket=current();try{const result=await post('/check',{ticketId:ticket.ticketId,step,value,reveal});const state=ticketState(ticket);states[ticket.ticketId]=state;if(result.resolved){state.done=[...state.done,step];state.step=step==='classify'?'priority':step==='priority'?'solve':'done'}state.bad=!result.correct;state.feedback=result.correct?'Richtig eingeordnet.':`${reveal?'Aufgedeckt':'Noch nicht passend.'} ${result.answer?`Richtig wäre: <b>${esc(label(result.answer,ticket))}</b>.`:''}`;if(result.ticketDone){state.explanation=result.explanation;state.feedback='Ticket abgeschlossen. Prüfe kurz das Debrief und entscheide dann selbst, wann du weitergehst.'}renderRun()}catch(error){fail(error)}}
function label(value,ticket){if(value==='incident')return'Störung';if(value==='request')return'Anfrage';if(value==='high')return'hoch';if(value==='medium')return'mittel';if(value==='low')return'niedrig';return ticket.options.find(o=>o.id===value)?.text||value}
async function finish(){try{const result=await post('/finish',{slug:session.slug,durationSeconds:seconds()});view.innerHTML=`<article class="sc-panel sc-result"><div class="sc-eyebrow">Schicht abgeschlossen</div><h1>${result.flawless?'Leitstand souverän geführt':'Auswertung bereit'}</h1><div class="sc-score">${result.score} <small>/ ${result.maxScore} PTS</small></div><p>${'★'.repeat(result.stars)}${'☆'.repeat(3-result.stars)}</p><div class="sc-result-grid"><div><b>${result.resolved}/${result.total}</b> Tickets gelöst</div><div><b>${result.health}%</b> Service-Gesundheit</div><div><b>${result.highscore}</b> Highscore</div></div><p>${result.newHighscore?'Neuer Highscore gespeichert. ':''}${result.flawless?'Fehlerfreie Schicht – stark.':'Wiederhole eine Schicht: Tickets und Reihenfolge bleiben nachvollziehbar, deine Entscheidungen werden neu bewertet.'}</p><button id="scAgain" class="sc-btn primary">SCHICHTEN ANSEHEN</button></article>`;document.getElementById('scAgain').onclick=load}catch(error){fail(error)}}
function fail(error){view.innerHTML=`<article class="sc-panel sc-brief"><h1>Leitstand nicht erreichbar</h1><p>${esc(error.message||'Unbekannter Fehler')}</p><button class="sc-btn primary" onclick="location.reload()">NEU LADEN</button></article>`}async function load(){try{catalog=(await api('/catalog')).shifts||[];renderCatalog()}catch(error){fail(error)}}load();})();
+2 -2
View File
@@ -32,13 +32,13 @@
document.head.appendChild(accessibilityCss);
var accessibilityPlacementCss = document.createElement('link');
accessibilityPlacementCss.rel = 'stylesheet';
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=7';
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=8';
accessibilityPlacementCss.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityPlacementCss);
}
if (!document.querySelector('script[data-accessibility]')) {
var accessibilityScript = document.createElement('script');
accessibilityScript.src = '/js/accessibility.js?v=6';
accessibilityScript.src = '/js/accessibility.js?v=7';
accessibilityScript.defer = true;
accessibilityScript.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityScript);