From 17702554f8b83bc6e2d56b0adc95c8a5d5ab949e Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Sun, 12 Jul 2026 22:48:04 +0200 Subject: [PATCH] =?UTF-8?q?SystemEscape=20mit=20zuf=C3=A4lligen=20Runs=20u?= =?UTF-8?q?nd=20Raumpool=20erweitern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++++ mindfluxfuture.md | 5 ++-- .../config/SystemEscapePoolSeeder.java | 25 +++++++++++++++++++ .../mindflux/service/CodeEscapeService.java | 18 ++++++------- src/main/resources/static/js/codeescape.js | 2 +- .../service/CodeEscapeServiceTest.java | 1 + 6 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/example/mindflux/config/SystemEscapePoolSeeder.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e8654..77239be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,12 @@ Docker/CI-Härtung und erste Tests. - Datenschutzerklärung um lokale Speicherung und Browser-Benachrichtigung ergänzt. ### CodeEscape +- **Variable SystemEscape-Runs:** Jedes Szenario besitzt nun acht Räume; ein Durchlauf wählt + serverseitig vier davon in zufälliger Reihenfolge. Der Pool wächst damit auf 24 Räume und + 69 Rätsel, mit 1.680 möglichen geordneten Raumkombinationen je Szenario. +- Die konkrete Raum- und Rätselauswahl wird im Server-Run festgeschrieben. Checks, Hinweise, + Türen, Finish, Maximalpunktzahl und Sterne akzeptieren ausschließlich IDs dieses Runs. +- Lobby erklärt Poolgröße und Run-Länge; ein neuer Start erzeugt ausdrücklich eine neue Route. - **Sichtbarer Name zu SystemEscape geändert:** Der neue Name bildet Netzwerk-, Security-, Hardware-, Backup- und Entwicklungsthemen besser ab. Interner Key, Route und Datenmodell bleiben für bestehende Scores und Migrationen kompatibel bei `codeescape`. diff --git a/mindfluxfuture.md b/mindfluxfuture.md index 7af944e..292411d 100644 --- a/mindfluxfuture.md +++ b/mindfluxfuture.md @@ -262,8 +262,9 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention W8, V11–V14-Migrationsreservierung, `exam_scope`-Konvention, S3-Sicherheitsvertrag und Spiel-Akzent-Tokens in `theme.css` sind umgesetzt. 1. **FluxTimer ✅** – risikoarmer, rein lokaler Ausbau; validiert den globalen UI-Bootstrap und belegt die Glocke vor FluxNet. ≈ 1,5–2 PT -2. **SystemEscape (V11) ✅** – serverautoritatives MVP mit 3 Szenarien, 9 Räumen, 24 Rätseln, - Admin-CRUD, `exam_scope`, Session-Tracking und Anti-Cheat-Negativtests umgesetzt. +2. **SystemEscape (V11) ✅** – serverautoritatives Spiel mit 3 Szenarien, 24 Räumen, 69 Rätseln + und vier zufälligen Räumen pro Run; Admin-CRUD, `exam_scope`, festgeschriebene Run-Auswahl, + dynamische Wertung und Anti-Cheat-Negativtests umgesetzt. 3. **BugHunter (V12)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 2. ≈ 4,5 PT 4. **NetWorker (V13)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT 5. **ServiceClash (V14)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT diff --git a/src/main/java/com/example/mindflux/config/SystemEscapePoolSeeder.java b/src/main/java/com/example/mindflux/config/SystemEscapePoolSeeder.java new file mode 100644 index 0000000..4676c97 --- /dev/null +++ b/src/main/java/com/example/mindflux/config/SystemEscapePoolSeeder.java @@ -0,0 +1,25 @@ +package com.example.mindflux.config; +import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.ObjectMapper;import com.fasterxml.jackson.databind.node.ObjectNode;import org.springframework.boot.context.event.ApplicationReadyEvent;import org.springframework.context.event.EventListener;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional;import java.util.*; +@Component public class SystemEscapePoolSeeder{ + private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public SystemEscapePoolSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;} + @EventListener(ApplicationReadyEvent.class) @Transactional public void expand()throws Exception{ + add("verlassener-serverraum","DNS-Kammer","Ein Resolver hält die nächste Schleuse gesperrt.",q("DNS-Record für IPv4-Adresse?","A","A"),q("DNS-Record für Mailserver?","MX","MX"),q("DNS-Standardport?","53","53")); + add("verlassener-serverraum","VLAN-Brücke","Getrennte Segmente müssen korrekt verbunden werden.",q("VLAN-Tagging-Standard IEEE 802.?","1Q","1Q"),q("Gerät zwischen IP-Netzen?","Router","RTR"),q("VLAN ohne Tag am Trunk?","Native VLAN","NAT")); + add("verlassener-serverraum","Routing-Schacht","Das Gateway erwartet drei Routing-Grundlagen.",q("Route für alle unbekannten Ziele?","Default Route","DEF"),q("IPv4-Loopback-Adresse?","127.0.0.1","127"),q("Befehl zum Weg eines Pakets?","traceroute","TRC")); + add("verlassener-serverraum","Firewall-Gang","Nur korrekt gefilterter Verkehr öffnet den Gang.",q("Prinzip: alles verbieten, nur Nötiges erlauben?","Default Deny","DENY"),q("Firewall mit Verbindungszustand?","Stateful","STATE"),q("HTTPS-Protokoll über TCP-Port?","443","443")); + add("verlassener-serverraum","Monitoring-Zentrale","Metriken und Ereignisse liefern die Fragmente.",q("Protokoll für Geräteüberwachung?","SNMP","SNMP"),q("Zentrales Systemprotokoll?","Syslog","LOG"),q("ICMP-Werkzeug für Erreichbarkeit?","ping","PING")); + add("notfall-im-rechenzentrum","Identity-Vault","Der Identitätsdienst verlangt sichere Grundlagen.",q("Zweiter Faktor neben Passwort?","MFA","MFA"),q("Zentraler Verzeichnisdienst von Microsoft?","Active Directory","AD"),q("Minimal notwendige Rechte?","Least Privilege","LP")); + add("notfall-im-rechenzentrum","Container-Dock","Isolierte Workloads blockieren den Ausgang.",q("Container teilen den Kernel des?","Hosts","HOST"),q("Deklarative Container-Zusammenstellung?","Compose","COMP"),q("Persistenter Containerspeicher?","Volume","VOL")); + add("notfall-im-rechenzentrum","Patch-Leitstand","Ein ungepatchtes System hält die Tür verriegelt.",q("Bewertung bekannter Schwachstellen?","CVSS","CVSS"),q("Kennung einer veröffentlichten Schwachstelle?","CVE","CVE"),q("Testumgebung vor Produktion?","Staging","STG")); + add("notfall-im-rechenzentrum","Cluster-Korridor","Hochverfügbarkeit muss wiederhergestellt werden.",q("Automatischer Rollenwechsel bei Ausfall?","Failover","FAIL"),q("Verteilung von Anfragen?","Load Balancing","LB"),q("Prüfung der Dienstverfügbarkeit?","Health Check","HC")); + add("notfall-im-rechenzentrum","Storage-Array","Das Speichernetz fordert drei Begriffe.",q("Blockzugriff über IP?","iSCSI","ISCSI"),q("Dateifreigabe für Linux/Unix?","NFS","NFS"),q("Redundanz ist kein Ersatz für ein?","Backup","BAK")); + add("blackout-protokoll","PKI-Tresor","Die Zertifikatskette muss rekonstruiert werden.",q("Widerrufsliste für Zertifikate?","CRL","CRL"),q("Online-Prüfung eines Zertifikatsstatus?","OCSP","OCSP"),q("Oberste Vertrauensinstanz?","Root CA","ROOT")); + add("blackout-protokoll","API-Gateway","Die Schnittstelle akzeptiert nur sichere Antworten.",q("HTTP-Methode zum Lesen?","GET","GET"),q("Statuscode für nicht autorisiert?","401","401"),q("Datenformat mit Objekten und Arrays?","JSON","JSON")); + add("blackout-protokoll","Git-Archiv","Die Versionshistorie enthält den nächsten Schlüssel.",q("Lokale Änderungen vorm Commit vormerken?","git add","ADD"),q("Neuen Entwicklungszweig anlegen?","git branch","BR"),q("Historie eines Repositories anzeigen?","git log","LOG")); + add("blackout-protokoll","Datenbank-Kern","Transaktionen und Indizes sichern den Weg.",q("SQL-Befehl zum Lesen?","SELECT","SEL"),q("Index mit eindeutigen Werten?","UNIQUE","UNQ"),q("Transaktion zurücknehmen?","ROLLBACK","RB")); + add("blackout-protokoll","Zero-Trust-Schleuse","Jeder Zugriff muss erneut bewiesen werden.",q("Zero-Trust-Grundsatz: never trust, always?","verify","VERIFY"),q("Netze in kleine Zonen teilen?","Microsegmentation","MICRO"),q("Zugriffsentscheidung anhand von Rollen?","RBAC","RBAC")); + } + private Q q(String p,String a,String f){return new Q(p,a,f);} + private void add(String slug,String title,String intro,Q...qs)throws Exception{EscapeScenario s=repo.findBySlug(slug).orElse(null);if(s==null||s.getRooms().stream().anyMatch(r->r.getTitle().equals(title)))return;String code=Arrays.stream(qs).map(Q::fragment).reduce("",String::concat);EscapeRoom r=new EscapeRoom(title,intro,"Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.",code,"Die Schleuse entriegelt und gibt den nächsten Abschnitt frei.",s.getRooms().size());int i=0;for(Q q:qs){ObjectNode p=mapper.createObjectNode();p.put("instruction",q.prompt());p.putArray("solutions").add(q.answer());p.putArray("hints").add("Ordne den gesuchten Begriff dem IT-Kontext zu.").add("Der gesuchte Wert lautet: "+q.answer()+".");p.put("explanation",q.answer()+" ist die korrekte Lösung.");r.addPuzzle(new EscapePuzzle("code",q.prompt(),120,q.fragment(),i++,mapper.writeValueAsString(p)));}s.addRoom(r);repo.save(s);} + private record Q(String prompt,String answer,String fragment){} +} diff --git a/src/main/java/com/example/mindflux/service/CodeEscapeService.java b/src/main/java/com/example/mindflux/service/CodeEscapeService.java index efc4a65..bb206bf 100644 --- a/src/main/java/com/example/mindflux/service/CodeEscapeService.java +++ b/src/main/java/com/example/mindflux/service/CodeEscapeService.java @@ -4,16 +4,16 @@ 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<>(),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;} + public static class RunState implements java.io.Serializable {public Long scenarioId;public String slug;public final ListroomIds=new ArrayList<>();public final SetpuzzleIds=new HashSet<>();public final Map attempts=new HashMap<>(),hints=new HashMap<>();public final Set revealed=new HashSet<>(),resolved=new HashSet<>(),opened=new LinkedHashSet<>();} + private RunState state(HttpSession session){Object o=session.getAttribute("codeescape.run");if(o instanceof RunState s&&s.scenarioId!=null)return s;throw new IllegalArgumentException("Kein aktiver SystemEscape-Run.");} + @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",Math.min(4,s.getRooms().size()));n.put("poolSize",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."));Listselected=new ArrayList<>(s.getRooms());Collections.shuffle(selected);selected=selected.subList(0,Math.min(4,selected.size()));RunState st=new RunState();st.scenarioId=s.getId();st.slug=s.getSlug();selected.forEach(r->{st.roomIds.add(r.getId());r.getPuzzles().forEach(p->st.puzzleIds.add(p.getId()));});http.setAttribute("codeescape.run",st);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());root.put("poolSize",s.getRooms().size());ArrayNode rs=root.putArray("rooms");int max=0;for(EscapeRoom r:selected){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+selected.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);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."));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());} + @Transactional(readOnly=true) public JsonNode check(Long id,boolean reveal,JsonNode submission,HttpSession session){RunState st=state(session);if(!st.puzzleIds.contains(id))throw new IllegalArgumentException("Rätsel gehört nicht zu diesem Run.");EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));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){RunState st=state(session);if(!st.puzzleIds.contains(id))throw new IllegalArgumentException("Rätsel gehört nicht zu diesem Run.");EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));int lv=Math.max(1,Math.min(2,level));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){RunState st=state(session);int next=st.opened.size();if(next>=st.roomIds.size()||!Objects.equals(st.roomIds.get(next),id))throw new IllegalArgumentException("Raum gehört nicht zum aktuellen Abschnitt dieses Runs.");EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));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){RunState st=state(session);EscapeScenario s=scenarios.findBySlug(req.getSlug()).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));if(!Objects.equals(st.scenarioId,s.getId())||!Objects.equals(st.slug,req.getSlug()))throw new IllegalArgumentException("Szenario gehört nicht zu diesem Run.");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.");MapbyId=new HashMap<>();MapselectedRooms=new HashMap<>();for(EscapeRoom r:s.getRooms())if(st.roomIds.contains(r.getId())){selectedRooms.put(r.getId(),r);for(EscapePuzzle p:r.getPuzzles())byId.put(p.getId(),p);}Setseen=new HashSet<>();int base=0,solved=0;for(EscapeFinishRequest.Item i:req.getItems()){if(i.getPuzzleId()==null||!st.puzzleIds.contains(i.getPuzzleId())||!seen.add(i.getPuzzleId()))throw new IllegalArgumentException("Fremde oder doppelte Rätsel-ID.");EscapePuzzle p=byId.get(i.getPuzzleId());if(p!=null&&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=st.opened.size();base+=opened*50;int max=selectedRooms.values().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==st.roomIds.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");} private boolean matches(EscapePuzzle p,JsonNode sub){JsonNode j=parse(p.getPayload());if(j==null||sub==null)return false;return switch(p.getType()){case"code"->strings(j,"solutions").stream().anyMatch(x->eq(x,sub.path("answer").asText(""),j.path("caseSensitive").asBoolean(false)));case"choice"->eq(j.path("correct").asText(),sub.path("answer").asText(),false);case"order"->listEq(strings(j,"solution"),strings(sub,"items"));case"match"->pairEq(j,sub);default->false;};} private boolean pairEq(JsonNode j,JsonNode s){Mapw=new HashMap<>();for(JsonNode p:array(j,"pairs"))if(p.size()>1)w.put(norm(p.get(0).asText()),norm(p.get(1).asText()));for(JsonNode p:array(s,"pairs"))if(p.size()>1&&!Objects.equals(w.get(norm(p.get(0).asText())),norm(p.get(1).asText())))return false;return arraySize(s,"pairs")==w.size();}private int arraySize(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a.size():0;}private boolean listEq(Lista,Listb){if(a.size()!=b.size())return false;for(int i=0;i=.8?3:r>=.5?2:1;}private JsonNode parse(String j){try{return mapper.readTree(j);}catch(Exception e){return null;}}private Iterable array(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a:mapper.createArrayNode();}private List strings(JsonNode n,String f){Listo=new ArrayList<>();for(JsonNode x:array(n,f))o.add(x.asText());return o;}private ArrayNode shuffle(Listx){Listc=new ArrayList<>(x);Collections.shuffle(c);ArrayNode a=mapper.createArrayNode();c.forEach(a::add);return a;}private void copy(JsonNode s,ObjectNode t,String f){if(s.hasNonNull(f))t.put(f,s.get(f).asText());} diff --git a/src/main/resources/static/js/codeescape.js b/src/main/resources/static/js/codeescape.js index 54fee88..1bbea7f 100644 --- a/src/main/resources/static/js/codeescape.js +++ b/src/main/resources/static/js/codeescape.js @@ -3,7 +3,7 @@ const api=(url,opt)=>fetch('/api/codeescape'+url,{headers:{'Content-Type':'appli function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function post(url,data){return api(url,{method:'POST',body:JSON.stringify(data)});}function seconds(){return Math.max(1,Math.round((Date.now()-start)/1000));} function startTimer(){clearInterval(tickId);start=Date.now();tickId=setInterval(()=>{let left=session.timeLimitSeconds-seconds();timerEl.textContent=(left<0?'+':'')+fmt(Math.abs(left));timerEl.classList.toggle('alarm',left<=0);},1000);}function fmt(s){return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');} 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 renderLobby(){timerEl.textContent='--:--';view.innerHTML='
FACILITY ARCHIVE // VARIABLE RUNS

Wähle deinen Fluchtweg

Jeder Run stellt vier Räume neu aus einem größeren Pool zusammen. Löse IT-Rätsel, sichere Fragmente und entkomme – der nächste Durchlauf wird anders.

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

${esc(s.title)}

${esc(s.description)}

${s.roomCount} Räume pro Run${s.poolSize} im Pool${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],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'';} diff --git a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java index f35d1d5..1b9481f 100644 --- a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java +++ b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java @@ -4,6 +4,7 @@ class CodeEscapeServiceTest { EscapeScenarioRepository scenarios=mock(EscapeScenarioRepository.class);EscapeRoomRepository rooms=mock(EscapeRoomRepository.class);EscapePuzzleRepository puzzles=mock(EscapePuzzleRepository.class);GameScoreService scores=mock(GameScoreService.class);ObjectMapper mapper=new ObjectMapper();CodeEscapeService service;EscapeScenario scenario;EscapeRoom room;EscapePuzzle puzzle; @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 sessionSelectsFourRoomsAndPinsThemToTheRun(){for(long i=4;i<=7;i++){EscapeRoom extra=new EscapeRoom("R"+i,"I","P","X","S",(int)i);extra.setId(i);EscapePuzzle p=new EscapePuzzle("code","P",10,"X",0,"{\"solutions\":[\"X\"]}");p.setId(i+10);extra.addPuzzle(p);scenario.addRoom(extra);}MockHttpSession http=new MockHttpSession();JsonNode json=service.session("test",http);assertEquals(4,json.path("rooms").size());CodeEscapeService.RunState state=(CodeEscapeService.RunState)http.getAttribute("codeescape.run");assertEquals(4,state.roomIds.size());assertEquals(4,state.puzzleIds.size());} @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));}