SystemEscape mit zufälligen Runs und Raumpool erweitern
mindflux-ci / build (push) Successful in 22s

This commit is contained in:
AliceDarkest
2026-07-12 22:48:04 +02:00
parent 4935d00b4e
commit 17702554f8
6 changed files with 45 additions and 12 deletions
+6
View File
@@ -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`.
+3 -2
View File
@@ -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
@@ -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){}
}
@@ -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 Set<String>TYPES=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<Long,Integer> attempts=new HashMap<>(),hints=new HashMap<>();public final Set<Long> 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 List<Long>roomIds=new ArrayList<>();public final Set<Long>puzzleIds=new HashSet<>();public final Map<Long,Integer> attempts=new HashMap<>(),hints=new HashMap<>();public final Set<Long> 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."));List<EscapeRoom>selected=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");List<String>rr=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);List<String>h=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.");Set<Long>valid=new HashSet<>();Map<Long,EscapePuzzle>byId=new HashMap<>();for(EscapeRoom r:s.getRooms())for(EscapePuzzle p:r.getPuzzles()){valid.add(p.getId());byId.put(p.getId(),p);}Set<Long>seen=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);List<String>h=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.");Map<Long,EscapePuzzle>byId=new HashMap<>();Map<Long,EscapeRoom>selectedRooms=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);}Set<Long>seen=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){Map<String,String>w=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(List<String>a,List<String>b){if(a.size()!=b.size())return false;for(int i=0;i<a.size();i++)if(!eq(a.get(i),b.get(i),false))return false;return true;}private static boolean eq(String a,String b,boolean cs){return cs?norm(a).equals(norm(b)):norm(a).equalsIgnoreCase(norm(b));}private static String norm(String s){return s==null?"":s.trim().replaceAll("\\s+"," ");}private int stars(int s,int m){if(s<=0||m<=0)return 0;double r=(double)s/m;return r>=.8?3:r>=.5?2:1;}private JsonNode parse(String j){try{return mapper.readTree(j);}catch(Exception e){return null;}}private Iterable<JsonNode> array(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a:mapper.createArrayNode();}private List<String> strings(JsonNode n,String f){List<String>o=new ArrayList<>();for(JsonNode x:array(n,f))o.add(x.asText());return o;}private ArrayNode shuffle(List<String>x){List<String>c=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());}
+1 -1
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='<div class="esc-hero"><span class="esc-eyebrow">FACILITY ARCHIVE // 03 SZENARIEN</span><h1>Wähle deinen Fluchtweg</h1><p>Jeder Raum enthält IT-Rätsel. Sichere die Code-Fragmente, öffne die Schleusen und entkomme, bevor der Zeitbonus verfällt.</p></div><div class="esc-grid">'+catalog.map(s=>`<article class="esc-card"><span class="esc-eyebrow">${esc(s.examScope)} · ${esc(s.difficulty)}</span><h2>${esc(s.title)}</h2><p>${esc(s.description)}</p><div class="esc-meta"><span class="esc-chip">${s.roomCount} Räume</span><span class="esc-chip">${Math.round(s.timeLimitSeconds/60)} Minuten</span></div><button class="esc-btn" data-start="${esc(s.slug)}">FACILITY BETRETEN</button></article>`).join('')+'</div>';view.querySelectorAll('[data-start]').forEach(b=>b.onclick=()=>begin(b.dataset.start));}
function renderLobby(){timerEl.textContent='--:--';view.innerHTML='<div class="esc-hero"><span class="esc-eyebrow">FACILITY ARCHIVE // VARIABLE RUNS</span><h1>Wähle deinen Fluchtweg</h1><p>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.</p></div><div class="esc-grid">'+catalog.map(s=>`<article class="esc-card"><span class="esc-eyebrow">${esc(s.examScope)} · ${esc(s.difficulty)}</span><h2>${esc(s.title)}</h2><p>${esc(s.description)}</p><div class="esc-meta"><span class="esc-chip">${s.roomCount} Räume pro Run</span><span class="esc-chip">${s.poolSize} im Pool</span><span class="esc-chip">${Math.round(s.timeLimitSeconds/60)} Minuten</span></div><button class="esc-btn" data-start="${esc(s.slug)}">NEUEN RUN GENERIEREN</button></article>`).join('')+'</div>';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=`<div class="esc-room-head"><div><span class="esc-eyebrow">RAUM ${roomIndex+1} / ${session.rooms.length} · ${esc(session.examScope)}</span><h1>${esc(r.title)}</h1><p>${esc(r.intro)}</p></div><div class="esc-progress">${'● '.repeat(roomIndex+1)}${'○ '.repeat(session.rooms.length-roomIndex-1)}</div></div><div class="esc-how"><div class="${complete?'done':''}"><b>1</b><span><strong>Rätsel lösen</strong><small>Die Rätsel sind frei wählbar.</small></span></div><div class="${complete?'done':''}"><b>2</b><span><strong>Fragmente sammeln</strong><small>Ihre Reihenfolge ist links → rechts.</small></span></div><div class="${complete?'active':''}"><b>3</b><span><strong>Türcode bilden</strong><small>Ohne Leerzeichen zusammensetzen.</small></span></div></div><div class="esc-puzzles">${r.puzzles.map((p,i)=>`<button class="esc-puzzle ${answers.has(p.puzzleId)?'done':''}" data-puzzle="${p.puzzleId}"><span class="esc-eyebrow">RÄTSEL ${i+1} · ${esc(p.type)}</span><h3>${esc(p.prompt)}</h3><div class="status">${answers.has(p.puzzleId)?'✓ FRAGMENT GESICHERT':'ANKLICKEN UND LÖSEN →'}</div></button>`).join('')}</div><div class="esc-inventory ${complete?'complete':''}"><span class="esc-eyebrow">2 · FRAGMENTE VON LINKS NACH RECHTS</span><div class="esc-fragments">${r.puzzles.map((p,i)=>`<span class="esc-fragment"><small>${i+1}</small>${fragments.has(p.puzzleId)?esc(fragments.get(p.puzzleId)):'•••'}</span>`).join('')}</div>${complete?'<p class="esc-ready">✓ Alle Fragmente gesichert. Bilde jetzt unten den Türcode.</p>':''}</div><div class="esc-door ${complete?'ready':''}"><div class="esc-door-title"><span class="esc-eyebrow">3 · TÜRTERMINAL</span><strong>${complete?'Schleuse bereit':'Noch nicht verfügbar'}</strong></div><label>${complete?esc(r.doorPrompt):'Löse zuerst alle Rätsel, um das Terminal freizuschalten.'}<input class="esc-input" id="doorCode" autocomplete="off" spellcheck="false" ${complete?'':'disabled'}></label>${complete?'<button class="esc-btn" id="assembleBtn">FRAGMENTE EINSETZEN</button>':''}<button class="esc-btn primary" id="doorBtn" ${complete?'':'disabled'}>CODE PRÜFEN &amp; ÖFFNEN</button><button class="esc-btn danger" id="quitBtn">AUFGEBEN</button><div class="esc-door-feedback" id="doorFeedback" role="status" aria-live="polite"></div></div>`;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 `<input class="esc-input" id="answer" placeholder="${esc(p.placeholder||'Antwort')}">`;if(p.type==='choice')return `<div class="esc-options">${p.options.map(x=>`<button class="esc-option" data-value="${esc(x)}">${esc(x)}</button>`).join('')}</div>`;if(p.type==='order')return `<div class="esc-options">${p.items.map((x,i)=>`<label>${i+1}.<select class="esc-select order">${p.items.map(y=>`<option>${esc(y)}</option>`).join('')}</select></label>`).join('')}</div>`;if(p.type==='match')return `<div class="esc-pairs">${p.left.map(x=>`<label>${esc(x)}<select class="esc-select pair" data-left="${esc(x)}">${p.right.map(y=>`<option>${esc(y)}</option>`).join('')}</select></label>`).join('')}</div>`;return'';}
@@ -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));}