diff --git a/CHANGELOG.md b/CHANGELOG.md index c45e41e..7d472d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,13 @@ Docker/CI-Härtung und erste Tests. - Datenschutzerklärung um lokale Speicherung und Browser-Benachrichtigung ergänzt. ### CodeEscape +- **Spielablauf verdeutlicht und Schleusenfehler behoben:** dauerhafte Drei-Schritt-Führung + „Rätsel lösen → Fragmente sammeln → Türcode bilden“, nummerierte Fragmentreihenfolge, + optionales automatisches Einsetzen und klare Rückmeldungen bei Prüfung, Erfolg und Fehler. +- **Produktive Türcodes repariert:** ergänzende V11.1-Datenmigration gleicht alle neun Türcodes + mit ihren sichtbaren Fragmenten ab. Die Lösereihenfolge der Rätsel bleibt bewusst frei. +- Schleusen lassen sich serverseitig erst nach Abschluss aller Rätsel öffnen; ein zusätzlicher + Test sichert diesen Ablauf und die hilfreiche Fehlermeldung ab. - **Neues vollständiges Spiel:** drei Facility-Szenarien mit neun Räumen und 24 IT-Rätseln von AP1 bis AP2 (FIAE/FISI/Cross), Fragment-Inventar, Türcodes, Countdown, Alarmzustand, Hinweise, Reveal, Zeitbonus, Sterne, Highscore und Ergebnis-Debriefing. diff --git a/mindfluxfuture.md b/mindfluxfuture.md index 752273e..55a4e22 100644 --- a/mindfluxfuture.md +++ b/mindfluxfuture.md @@ -98,7 +98,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: ## Teil 1: Spieldesigns -### Spiel 1: CodeEscape (Key: `codeescape`, Migration V11) ✅ MVP umgesetzt +### Spiel 1: CodeEscape (Key: `codeescape`, Migration V11 + Datenfix V11.1) ✅ MVP inklusive Spielführung umgesetzt **Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** = ein Durchlauf mit 3 Räumen in fester Reihenfolge; jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment** (Ziffern/Hex-Chunk in einer Inventar-Leiste); die Fragmente ergeben zusammen den Türcode zum nächsten Raum. Countdown erzeugt Zeitdruck; ein 2-stufiges Hinweis-System tauscht Punkte gegen Hilfe. diff --git a/src/main/java/com/example/mindflux/service/CodeEscapeService.java b/src/main/java/com/example/mindflux/service/CodeEscapeService.java index a27ea76..efc4a65 100644 --- a/src/main/java/com/example/mindflux/service/CodeEscapeService.java +++ b/src/main/java/com/example/mindflux/service/CodeEscapeService.java @@ -4,14 +4,14 @@ import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import c public static final String GAME="codeescape"; private static final SetTYPES=Set.of("code","choice","order","match"),SCOPES=Set.of("AP1","AP2_FIAE","AP2_FISI","CROSS"); private static final int MAX_ITEMS=40; private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;private final ObjectMapper mapper; public CodeEscapeService(EscapeScenarioRepository s,EscapeRoomRepository r,EscapePuzzleRepository p,GameScoreService g,ObjectMapper m){scenarios=s;rooms=r;puzzles=p;scores=g;mapper=m;} - public static class RunState implements java.io.Serializable {public final Map attempts=new HashMap<>(),hints=new HashMap<>();public final Set revealed=new HashSet<>(),opened=new HashSet<>();} + public static class RunState implements java.io.Serializable {public final Map attempts=new HashMap<>(),hints=new HashMap<>();public final Set revealed=new HashSet<>(),resolved=new HashSet<>(),opened=new HashSet<>();} private RunState state(HttpSession session){Object o=session.getAttribute("codeescape.run");if(o instanceof RunState s)return s;RunState s=new RunState();session.setAttribute("codeescape.run",s);return s;} @Transactional(readOnly=true) public JsonNode catalog(){ArrayNode a=mapper.createArrayNode();for(EscapeScenario s:scenarios.findAllByActiveTrueOrderBySortOrderAscIdAsc()){if(s.getRooms().isEmpty())continue;ObjectNode n=a.addObject();n.put("slug",s.getSlug());n.put("title",s.getTitle());n.put("difficulty",s.getDifficulty());n.put("examScope",s.getExamScope());n.put("description",s.getDescription());n.put("timeLimitSeconds",s.getTimeLimitSeconds());n.put("roomCount",s.getRooms().size());}return mapper.createObjectNode().set("scenarios",a);} @Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){EscapeScenario s=scenarios.findBySlug(slug).filter(EscapeScenario::isActive).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));http.setAttribute("codeescape.run",new RunState());ObjectNode root=mapper.createObjectNode();root.put("slug",s.getSlug());root.put("title",s.getTitle());root.put("difficulty",s.getDifficulty());root.put("examScope",s.getExamScope());root.put("description",s.getDescription());root.put("timeLimitSeconds",s.getTimeLimitSeconds());ArrayNode rs=root.putArray("rooms");int max=0;for(EscapeRoom r:s.getRooms()){ObjectNode rv=rs.addObject();rv.put("roomId",r.getId());rv.put("title",r.getTitle());rv.put("intro",r.getIntro());rv.put("doorPrompt",r.getDoorPrompt());ArrayNode ps=rv.putArray("puzzles");for(EscapePuzzle p:r.getPuzzles()){ObjectNode pv=sanitize(p);if(pv!=null){ps.add(pv);max+=p.getPoints();}}}root.put("maxScore",max+s.getRooms().size()*50);return root;} private ObjectNode sanitize(EscapePuzzle p){JsonNode j=parse(p.getPayload());if(j==null||!TYPES.contains(p.getType()))return null;ObjectNode v=mapper.createObjectNode();v.put("puzzleId",p.getId());v.put("type",p.getType());v.put("prompt",p.getPrompt());v.put("points",p.getPoints());copy(j,v,"narrative");copy(j,v,"instruction");switch(p.getType()){case"code"->copy(j,v,"placeholder");case"choice"->v.set("options",shuffle(strings(j,"options")));case"order"->v.set("items",shuffle(strings(j,"items")));case"match"->{ArrayNode l=v.putArray("left"),r=v.putArray("right");Listrr=new ArrayList<>();for(JsonNode pair:array(j,"pairs"))if(pair.isArray()&&pair.size()>1){l.add(pair.get(0).asText());rr.add(pair.get(1).asText());}Collections.shuffle(rr);rr.forEach(r::add);}}return v;} - @Transactional(readOnly=true) public JsonNode check(Long id,boolean reveal,JsonNode submission,HttpSession session){EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));RunState st=state(session);int attempts=st.attempts.merge(id,1,Integer::sum);if(attempts>3&&!reveal)throw new IllegalArgumentException("Maximale Versuchszahl erreicht.");boolean correct=matches(p,submission);if(reveal)st.revealed.add(id);ObjectNode out=mapper.createObjectNode();out.put("correct",correct);out.put("resolved",correct||reveal);out.put("attempts",attempts);if(correct||reveal)out.put("fragment",p.getCodeFragment());if(reveal){JsonNode j=parse(p.getPayload());if(j!=null&&j.has("explanation"))out.put("explanation",j.get("explanation").asText());}return out;} + @Transactional(readOnly=true) public JsonNode check(Long id,boolean reveal,JsonNode submission,HttpSession session){EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));RunState st=state(session);int attempts=st.attempts.merge(id,1,Integer::sum);if(attempts>3&&!reveal)throw new IllegalArgumentException("Maximale Versuchszahl erreicht.");boolean correct=matches(p,submission);if(reveal)st.revealed.add(id);if(correct||reveal)st.resolved.add(id);ObjectNode out=mapper.createObjectNode();out.put("correct",correct);out.put("resolved",correct||reveal);out.put("attempts",attempts);if(correct||reveal)out.put("fragment",p.getCodeFragment());if(reveal){JsonNode j=parse(p.getPayload());if(j!=null&&j.has("explanation"))out.put("explanation",j.get("explanation").asText());}return out;} @Transactional(readOnly=true) public JsonNode hint(Long id,int level,HttpSession session){EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));int lv=Math.max(1,Math.min(2,level));RunState st=state(session);st.hints.merge(id,lv,Math::max);Listh=strings(parse(p.getPayload()),"hints");ObjectNode o=mapper.createObjectNode();o.put("level",lv);o.put("text",h.isEmpty()?"Prüfe die Aufgabenstellung noch einmal.":h.get(Math.min(lv-1,h.size()-1)));return o;} - @Transactional(readOnly=true) public JsonNode door(Long id,String code,HttpSession session){EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));boolean open=eq(r.getDoorCode(),code,false);if(open)state(session).opened.add(id);ObjectNode o=mapper.createObjectNode();o.put("open",open);if(open)o.put("successText",r.getSuccessText());return o;} + @Transactional(readOnly=true) public JsonNode door(Long id,String code,HttpSession session){EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));RunState st=state(session);boolean complete=r.getPuzzles().stream().allMatch(p->st.resolved.contains(p.getId()));boolean open=complete&&eq(r.getDoorCode(),code,false);if(open)st.opened.add(id);ObjectNode o=mapper.createObjectNode();o.put("open",open);o.put("complete",complete);if(open)o.put("successText",r.getSuccessText());else o.put("message",complete?"Code abgelehnt. Setze die Fragmente von links nach rechts ohne Leerzeichen zusammen.":"Löse zuerst alle Rätsel dieses Raums.");return o;} @Transactional public EscapeResult finish(String user,EscapeFinishRequest req,HttpSession session){EscapeScenario s=scenarios.findBySlug(req.getSlug()).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));if(req.getDurationSeconds()<1||req.getDurationSeconds()>s.getTimeLimitSeconds()*4)throw new IllegalArgumentException("Unplausible Spieldauer.");if(req.getItems()==null||req.getItems().size()>MAX_ITEMS)throw new IllegalArgumentException("Ungültige Rätselliste.");Setvalid=new HashSet<>();MapbyId=new HashMap<>();for(EscapeRoom r:s.getRooms())for(EscapePuzzle p:r.getPuzzles()){valid.add(p.getId());byId.put(p.getId(),p);}Setseen=new HashSet<>();RunState st=state(session);int base=0,solved=0;for(EscapeFinishRequest.Item i:req.getItems()){if(i.getPuzzleId()==null||!valid.contains(i.getPuzzleId())||!seen.add(i.getPuzzleId()))throw new IllegalArgumentException("Fremde oder doppelte Rätsel-ID.");EscapePuzzle p=byId.get(i.getPuzzleId());if(matches(p,i.getSubmission())&&!st.revealed.contains(p.getId())){solved++;int a=Math.max(1,st.attempts.getOrDefault(p.getId(),1));double af=a==1?1:a==2?.6:.3;int hl=st.hints.getOrDefault(p.getId(),0);double hf=hl==0?1:hl==1?.8:.6;base+=(int)Math.round(p.getPoints()*af*hf);}} int opened=0;for(EscapeRoom r:s.getRooms())if(st.opened.contains(r.getId())){opened++;base+=50;}int max=s.getRooms().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==s.getRooms().size();int bonus=escaped?Math.max(0,(int)Math.round(500.0*(s.getTimeLimitSeconds()-req.getDurationSeconds())/s.getTimeLimitSeconds())):0;int total=base+bonus;int stars=stars(base,max);ScoreResult saved=scores.recordScore(user,GAME,s.getSlug(),total,solved,req.getDurationSeconds());httpClear(session);return new EscapeResult(total,base,max,bonus,solved,opened,escaped,stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays());} private void httpClear(HttpSession s){s.removeAttribute("codeescape.run");} diff --git a/src/main/resources/db/migration/V11_1__repair_codeescape_door_codes.sql b/src/main/resources/db/migration/V11_1__repair_codeescape_door_codes.sql new file mode 100644 index 0000000..c3b46d9 --- /dev/null +++ b/src/main/resources/db/migration/V11_1__repair_codeescape_door_codes.sql @@ -0,0 +1,9 @@ +UPDATE escape_rooms SET door_code = '4122SYN', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Rack 01'; +UPDATE escape_rooms SET door_code = '6253UP', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Patchfeld'; +UPDATE escape_rooms SET door_code = 'DHCP67', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Kontrollraum'; +UPDATE escape_rooms SET door_code = '3TBUSVR5', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Stromschleuse'; +UPDATE escape_rooms SET door_code = 'VOLL321', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Backup-Tresor'; +UPDATE escape_rooms SET door_code = 'TYPE1NOSNAPISO', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Hypervisor-Kern'; +UPDATE escape_rooms SET door_code = 'SHA256443CIA', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Forensik-Labor'; +UPDATE escape_rooms SET door_code = 'PREPAREDACID', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Datenbank-Bunker'; +UPDATE escape_rooms SET door_code = 'ISOLATECALEAST', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Incident-Leitstand'; diff --git a/src/main/resources/static/css/codeescape-guide.css b/src/main/resources/static/css/codeescape-guide.css new file mode 100644 index 0000000..67b69b0 --- /dev/null +++ b/src/main/resources/static/css/codeescape-guide.css @@ -0,0 +1 @@ +.esc-how{display:grid;grid-template-columns:repeat(3,1fr);gap:.65rem;margin:0 0 1rem}.esc-how>div{display:flex;align-items:center;gap:.65rem;padding:.7rem;background:var(--surface);border:1px solid var(--border);border-radius:9px;color:var(--muted)}.esc-how b{display:grid;place-items:center;min-width:1.7rem;height:1.7rem;border:1px solid var(--border);border-radius:50%;color:var(--muted)}.esc-how strong,.esc-how small{display:block}.esc-how strong{color:var(--heading);font-size:.78rem}.esc-how small{margin-top:.15rem;font-size:.65rem}.esc-how .done,.esc-how .active{border-color:var(--esc-accent)}.esc-how .done b{background:var(--success);border-color:var(--success);color:#06120a}.esc-how .active{box-shadow:0 0 18px var(--esc-glow)}.esc-puzzle:not(.done) .status{color:var(--esc-accent)}.esc-puzzle:not(.done):hover{border-color:var(--esc-accent);transform:translateY(-2px)}.esc-fragment{display:flex;align-items:center;gap:.45rem}.esc-fragment small{display:grid;place-items:center;width:1.25rem;height:1.25rem;border-radius:50%;background:var(--esc-accent);color:#160d00;font-size:.6rem}.esc-inventory.complete{border-style:solid;border-color:var(--success)}.esc-ready{margin:.7rem 0 0;color:var(--success);font-size:.75rem}.esc-door{flex-wrap:wrap}.esc-door.ready{border-color:var(--esc-accent);box-shadow:0 0 22px color-mix(in srgb,var(--esc-accent) 14%,transparent)}.esc-door-title{flex-basis:100%;display:flex;justify-content:space-between;align-items:center}.esc-door-title strong{color:var(--heading);font-size:.8rem}.esc-door-feedback{flex-basis:100%;min-height:1.2rem;font-size:.75rem}.esc-door-feedback.ok{color:var(--success)}.esc-door-feedback.bad{color:var(--danger)}.esc-door-feedback.wait{color:var(--esc-accent)}.esc-input.bad{border-color:var(--danger);box-shadow:0 0 0 2px color-mix(in srgb,var(--danger) 28%,transparent)}.esc-btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}@media(max-width:800px){.esc-how{grid-template-columns:1fr}.esc-door-title{align-items:flex-start}} diff --git a/src/main/resources/static/js/codeescape.js b/src/main/resources/static/js/codeescape.js index 2247440..54fee88 100644 --- a/src/main/resources/static/js/codeescape.js +++ b/src/main/resources/static/js/codeescape.js @@ -5,13 +5,14 @@ function startTimer(){clearInterval(tickId);start=Date.now();tickId=setInterval( function loadCatalog(){api('/scenarios').then(d=>{catalog=d.scenarios;renderLobby();}).catch(error);} function renderLobby(){timerEl.textContent='--:--';view.innerHTML='
FACILITY ARCHIVE // 03 SZENARIEN

Wähle deinen Fluchtweg

Jeder Raum enthält IT-Rätsel. Sichere die Code-Fragmente, öffne die Schleusen und entkomme, bevor der Zeitbonus verfällt.

'+catalog.map(s=>`
${esc(s.examScope)} · ${esc(s.difficulty)}

${esc(s.title)}

${esc(s.description)}

${s.roomCount} Räume${Math.round(s.timeLimitSeconds/60)} Minuten
`).join('')+'
';view.querySelectorAll('[data-start]').forEach(b=>b.onclick=()=>begin(b.dataset.start));} function begin(slug){api('/session/'+encodeURIComponent(slug)).then(d=>{session=d;roomIndex=0;answers.clear();fragments.clear();startTimer();renderRoom();}).catch(error);} -function renderRoom(){const r=session.rooms[roomIndex];view.innerHTML=`
RAUM ${roomIndex+1} / ${session.rooms.length} · ${esc(session.examScope)}

${esc(r.title)}

${esc(r.intro)}

${'● '.repeat(roomIndex+1)}${'○ '.repeat(session.rooms.length-roomIndex-1)}
${r.puzzles.map((p,i)=>``).join('')}
FRAGMENT-INVENTAR
${r.puzzles.map(p=>`${fragments.has(p.puzzleId)?esc(fragments.get(p.puzzleId)):'•••'}`).join('')}
`;view.querySelectorAll('[data-puzzle]').forEach(b=>b.onclick=()=>openPuzzle(r.puzzles.find(p=>p.puzzleId==b.dataset.puzzle)));document.getElementById('doorBtn').onclick=()=>openDoor(r);document.getElementById('quitBtn').onclick=()=>finish(false);} +function renderRoom(){const r=session.rooms[roomIndex],complete=r.puzzles.every(p=>answers.has(p.puzzleId));view.innerHTML=`
RAUM ${roomIndex+1} / ${session.rooms.length} · ${esc(session.examScope)}

${esc(r.title)}

${esc(r.intro)}

${'● '.repeat(roomIndex+1)}${'○ '.repeat(session.rooms.length-roomIndex-1)}
1Rätsel lösenDie Rätsel sind frei wählbar.
2Fragmente sammelnIhre Reihenfolge ist links → rechts.
3Türcode bildenOhne Leerzeichen zusammensetzen.
${r.puzzles.map((p,i)=>``).join('')}
2 · FRAGMENTE VON LINKS NACH RECHTS
${r.puzzles.map((p,i)=>`${i+1}${fragments.has(p.puzzleId)?esc(fragments.get(p.puzzleId)):'•••'}`).join('')}
${complete?'

✓ Alle Fragmente gesichert. Bilde jetzt unten den Türcode.

':''}
3 · TÜRTERMINAL${complete?'Schleuse bereit':'Noch nicht verfügbar'}
${complete?'':''}
`;view.querySelectorAll('[data-puzzle]').forEach(b=>b.onclick=()=>openPuzzle(r.puzzles.find(p=>p.puzzleId==b.dataset.puzzle)));if(complete){document.getElementById('assembleBtn').onclick=()=>{document.getElementById('doorCode').value=r.puzzles.map(p=>fragments.get(p.puzzleId)).join('');document.getElementById('doorCode').focus();doorMessage('Fragmente eingesetzt. Prüfe den Code und öffne die Schleuse.','ok');};document.getElementById('doorCode').onkeydown=e=>{if(e.key==='Enter')openDoor(r);};}document.getElementById('doorBtn').onclick=()=>openDoor(r);document.getElementById('quitBtn').onclick=()=>finish(false);} function control(p){if(p.type==='code')return ``;if(p.type==='choice')return `
${p.options.map(x=>``).join('')}
`;if(p.type==='order')return `
${p.items.map((x,i)=>``).join('')}
`;if(p.type==='match')return `
${p.left.map(x=>``).join('')}
`;return'';} function openPuzzle(p){selected=null;document.body.insertAdjacentHTML('beforeend',`
SECURE TERMINAL · ${esc(p.type)}

${esc(p.prompt)}

${esc(p.narrative||p.instruction||'Löse das Zugriffsrätsel.')}

${p.narrative&&p.instruction?`

${esc(p.instruction)}

`:''}${control(p)}
`);const modal=document.querySelector('.esc-modal');modal.querySelectorAll('.esc-option').forEach(b=>b.onclick=()=>{modal.querySelectorAll('.esc-option').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');selected=b.dataset.value;});document.getElementById('closeBtn').onclick=()=>modal.remove();let hintLevel=0;document.getElementById('hintBtn').onclick=()=>post('/hint',{puzzleId:p.puzzleId,level:Math.min(2,++hintLevel)}).then(x=>feedback(x.text,'ok'));document.getElementById('checkBtn').onclick=()=>checkPuzzle(p,false,modal);document.getElementById('revealBtn').onclick=()=>checkPuzzle(p,true,modal);} function submission(p,modal){if(p.type==='code')return{answer:modal.querySelector('#answer').value};if(p.type==='choice')return{answer:selected||''};if(p.type==='order')return{items:[...modal.querySelectorAll('.order')].map(x=>x.value)};if(p.type==='match')return{pairs:[...modal.querySelectorAll('.pair')].map(x=>[x.dataset.left,x.value])};return{};} function checkPuzzle(p,reveal,modal){const sub=submission(p,modal);post('/check',{puzzleId:p.puzzleId,reveal,submission:sub}).then(x=>{if(x.resolved){answers.set(p.puzzleId,{puzzleId:p.puzzleId,submission:sub});fragments.set(p.puzzleId,x.fragment);feedback(reveal?'Auflösung freigegeben. Fragment gesichert.':'Zugriff bestätigt. Fragment gesichert.','ok');setTimeout(()=>{modal.remove();renderRoom();},700);}else feedback('Zugriff verweigert. Prüfe deine Eingabe.','bad');}).catch(e=>feedback(e.message,'bad'));} function feedback(t,c){const f=document.getElementById('feedback');if(f){f.textContent=t;f.className='esc-feedback '+c;}} -function openDoor(r){post('/door',{roomId:r.roomId,code:document.getElementById('doorCode').value}).then(x=>{if(!x.open){document.getElementById('doorCode').classList.add('bad');return;}if(roomIndex{if(!x.open){input.classList.add('bad');doorMessage(x.message||'Code abgelehnt. Prüfe die Reihenfolge der Fragmente.','bad');button.disabled=false;button.textContent='CODE PRÜFEN & ÖFFNEN';input.focus();return;}doorMessage('✓ '+(x.successText||'Schleuse geöffnet.'),'ok');setTimeout(()=>{if(roomIndex{doorMessage(e.message||'Das Türterminal ist nicht erreichbar.','bad');button.disabled=false;button.textContent='ERNEUT VERSUCHEN';});} function finish(escaped){clearInterval(tickId);post('/finish',{slug:session.slug,durationSeconds:seconds(),escaped,items:[...answers.values()],doors:[]}).then(renderResult).catch(error);} function renderResult(r){timerEl.textContent='ESCAPED';view.innerHTML=`
FACILITY RUN ABGESCHLOSSEN

${r.escaped?'AUSGANG ERREICHT':'RUN BEENDET'}

${r.score}

${'★'.repeat(r.stars)}${'☆'.repeat(3-r.stars)}

${r.baseScore}Basis
+${r.timeBonus}Zeitbonus
${r.solvedPuzzles}Rätsel
${r.roomsOpened}Räume
${r.highscore}Highscore
${r.plays}Runs
`;document.getElementById('again').onclick=loadCatalog;} function error(e){view.innerHTML=`

SYSTEMFEHLER

${esc(e.message||e)}

`;}loadCatalog();})(); diff --git a/src/main/resources/templates/codeescape.html b/src/main/resources/templates/codeescape.html index 272f40b..6ba40b9 100644 --- a/src/main/resources/templates/codeescape.html +++ b/src/main/resources/templates/codeescape.html @@ -1 +1 @@ -CodeEscape · Mindflux
← Dashboard
CODEESCAPEFACILITY ACCESS PROTOCOL
--:--
FACILITY WIRD INITIALISIERT …
+CodeEscape · Mindflux
← Dashboard
CODEESCAPEFACILITY ACCESS PROTOCOL
--:--
FACILITY WIRD INITIALISIERT …
diff --git a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java index 18080bb..f35d1d5 100644 --- a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java +++ b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java @@ -5,6 +5,7 @@ class CodeEscapeServiceTest { @BeforeEach void setup(){service=new CodeEscapeService(scenarios,rooms,puzzles,scores,mapper);scenario=new EscapeScenario("test","Test","leicht","AP1","desc",900,0);scenario.setId(1L);room=new EscapeRoom("Raum","Intro","Code?","41","Offen",0);room.setId(2L);puzzle=new EscapePuzzle("code","Binär",100,"41",0,"{\"solutions\":[\"41\"],\"hints\":[\"h1\",\"h2\"],\"explanation\":\"secret\"}");puzzle.setId(3L);room.addPuzzle(puzzle);scenario.addRoom(room);when(scenarios.findBySlug("test")).thenReturn(Optional.of(scenario));when(puzzles.findById(3L)).thenReturn(Optional.of(puzzle));when(rooms.findById(2L)).thenReturn(Optional.of(room));when(scores.recordScore(anyString(),anyString(),anyString(),anyInt(),anyInt(),anyInt())).thenAnswer(i->new ScoreResult(i.getArgument(3),i.getArgument(3),true,1));} @Test void sessionLeaksNoSolutionsHintsFragmentsOrDoorCodes(){JsonNode json=service.session("test",new MockHttpSession());String raw=json.toString();assertFalse(raw.contains("solutions"));assertFalse(raw.contains("hints"));assertFalse(raw.contains("secret"));assertFalse(raw.contains("\"41\""));} @Test void hintAndAttemptAreServerAuthoritative(){MockHttpSession session=new MockHttpSession();service.session("test",session);service.hint(3L,1,session);service.check(3L,false,mapper.createObjectNode().put("answer","41"),session);service.door(2L,"41",session);EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(450);req.setEscaped(true);EscapeFinishRequest.Item item=new EscapeFinishRequest.Item();item.setPuzzleId(3L);item.setSubmission(mapper.createObjectNode().put("answer","41"));req.setItems(List.of(item));EscapeResult result=service.finish("alice",req,session);assertEquals(130,result.baseScore());assertEquals(250,result.timeBonus());assertTrue(result.escaped());assertEquals(380,result.score());} + @Test void doorExplainsWhyItStaysClosedUntilPuzzlesAreResolved(){MockHttpSession session=new MockHttpSession();service.session("test",session);JsonNode early=service.door(2L,"41",session);assertFalse(early.path("open").asBoolean());assertFalse(early.path("complete").asBoolean());assertTrue(early.path("message").asText().contains("Löse zuerst"));service.check(3L,false,mapper.createObjectNode().put("answer","41"),session);JsonNode open=service.door(2L,"41",session);assertTrue(open.path("open").asBoolean());assertTrue(open.path("complete").asBoolean());} @Test void finishRejectsForeignPuzzleIds(){MockHttpSession session=new MockHttpSession();service.session("test",session);EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(10);EscapeFinishRequest.Item item=new EscapeFinishRequest.Item();item.setPuzzleId(999L);item.setSubmission(mapper.createObjectNode());req.setItems(List.of(item));assertThrows(IllegalArgumentException.class,()->service.finish("alice",req,session));} @Test void finishRejectsImplausibleDuration(){EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(0);assertThrows(IllegalArgumentException.class,()->service.finish("alice",req,new MockHttpSession()));} }