From edb5b36408efc8570d1189076554370d54914a10 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Sun, 12 Jul 2026 22:24:47 +0200 Subject: [PATCH] CodeEscape als serverautoritativen Spielmodus umsetzen --- CHANGELOG.md | 16 ++++++++ mindfluxfuture.md | 5 ++- .../mindflux/config/CodeEscapeSeeder.java | 2 + .../mindflux/config/SecurityConfig.java | 2 +- .../controller/AdminCodeEscapeController.java | 2 + .../controller/CodeEscapeApiController.java | 2 + .../controller/CodeEscapeController.java | 1 + .../mindflux/dto/EscapeCheckRequest.java | 1 + .../mindflux/dto/EscapeDoorRequest.java | 1 + .../mindflux/dto/EscapeFinishRequest.java | 1 + .../mindflux/dto/EscapeHintRequest.java | 1 + .../example/mindflux/dto/EscapeResult.java | 1 + .../example/mindflux/model/EscapePuzzle.java | 7 ++++ .../example/mindflux/model/EscapeRoom.java | 9 +++++ .../mindflux/model/EscapeScenario.java | 13 +++++++ .../repository/EscapePuzzleRepository.java | 1 + .../repository/EscapeRoomRepository.java | 1 + .../repository/EscapeScenarioRepository.java | 2 + .../mindflux/service/CodeEscapeService.java | 26 +++++++++++++ src/main/resources/codeescape/content.json | 37 +++++++++++++++++++ .../db/migration/V11__codeescape.sql | 27 ++++++++++++++ src/main/resources/static/css/codeescape.css | 1 + src/main/resources/static/css/theme.css | 2 + src/main/resources/static/js/codeescape.js | 17 +++++++++ .../templates/admin/codeescape-room.html | 1 + .../templates/admin/codeescape-scenario.html | 1 + .../resources/templates/admin/codeescape.html | 1 + .../resources/templates/admin/dashboard.html | 1 + src/main/resources/templates/codeescape.html | 1 + src/main/resources/templates/dashboard.html | 4 +- .../service/CodeEscapeServiceTest.java | 10 +++++ 31 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/mindflux/config/CodeEscapeSeeder.java create mode 100644 src/main/java/com/example/mindflux/controller/AdminCodeEscapeController.java create mode 100644 src/main/java/com/example/mindflux/controller/CodeEscapeApiController.java create mode 100644 src/main/java/com/example/mindflux/controller/CodeEscapeController.java create mode 100644 src/main/java/com/example/mindflux/dto/EscapeCheckRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/EscapeDoorRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/EscapeFinishRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/EscapeHintRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/EscapeResult.java create mode 100644 src/main/java/com/example/mindflux/model/EscapePuzzle.java create mode 100644 src/main/java/com/example/mindflux/model/EscapeRoom.java create mode 100644 src/main/java/com/example/mindflux/model/EscapeScenario.java create mode 100644 src/main/java/com/example/mindflux/repository/EscapePuzzleRepository.java create mode 100644 src/main/java/com/example/mindflux/repository/EscapeRoomRepository.java create mode 100644 src/main/java/com/example/mindflux/repository/EscapeScenarioRepository.java create mode 100644 src/main/java/com/example/mindflux/service/CodeEscapeService.java create mode 100644 src/main/resources/codeescape/content.json create mode 100644 src/main/resources/db/migration/V11__codeescape.sql create mode 100644 src/main/resources/static/css/codeescape.css create mode 100644 src/main/resources/static/js/codeescape.js create mode 100644 src/main/resources/templates/admin/codeescape-room.html create mode 100644 src/main/resources/templates/admin/codeescape-scenario.html create mode 100644 src/main/resources/templates/admin/codeescape.html create mode 100644 src/main/resources/templates/codeescape.html create mode 100644 src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 341c0b3..c45e41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,22 @@ Docker/CI-Härtung und erste Tests. Beschriftung reicht als Handlungsaufforderung und passt besser zur eigenständigen Designsprache. - Datenschutzerklärung um lokale Speicherung und Browser-Benachrichtigung ergänzt. +### CodeEscape +- **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. +- **Serverautoritativ:** V11-Schema mit `exam_scope`, lösungsfreie Session-DTOs, serverseitig + protokollierte Versuche/Hinweise/Reveals/Türen und vollständige Score-Neuberechnung beim Finish. + Fremde oder doppelte IDs, überlange Listen und unplausible Zeiten werden abgewiesen. +- **Vier Mechaniken:** freie Code-Eingabe, Choice, Reihenfolge und Matching; alle Lösungen bleiben + bis zur expliziten Auflösung auf dem Server. +- **Eigene visuelle Welt:** dunkle Facility-/CRT-Optik, Scanlines, Terminal-Dialoge, + Fragment-Chips und CodeEscape-Akzent im Dashboard; responsive für mobile Ansichten. +- **Admin-CRUD:** Szenarien, Räume und Rätsel inklusive JSON-Payload und Prüfungsbereich pflegbar. +- Dashboard, Security-CSRF-Ausnahme, Score-/XP-/Achievement-System und Admin-Dashboard integriert. +- Vier neue Service-Tests prüfen Lösungsfreiheit, serverseitige Hint-/Versuchsabrechnung sowie + Schutz vor fremden IDs und unplausiblen Laufzeiten. + ## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und diff --git a/mindfluxfuture.md b/mindfluxfuture.md index b8c8e89..752273e 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) +### Spiel 1: CodeEscape (Key: `codeescape`, Migration V11) ✅ MVP 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. @@ -262,7 +262,8 @@ 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. **CodeEscape (V11)** – validiert die Wiederverwendung der DevLab-Checker/-Renderer mit minimalem Risiko. ≈ 5,5 PT +2. **CodeEscape (V11) ✅** – serverautoritatives MVP mit 3 Szenarien, 9 Räumen, 24 Rätseln, + Admin-CRUD, `exam_scope`, Session-Tracking 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/CodeEscapeSeeder.java b/src/main/java/com/example/mindflux/config/CodeEscapeSeeder.java new file mode 100644 index 0000000..16753b8 --- /dev/null +++ b/src/main/java/com/example/mindflux/config/CodeEscapeSeeder.java @@ -0,0 +1,2 @@ +package com.example.mindflux.config; import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.*;import org.slf4j.*;import org.springframework.boot.CommandLineRunner;import org.springframework.core.io.ClassPathResource;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional; +@Component public class CodeEscapeSeeder implements CommandLineRunner {private static final Logger log=LoggerFactory.getLogger(CodeEscapeSeeder.class);private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public CodeEscapeSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;}@Override @Transactional public void run(String...a){if(repo.count()>0)return;try{JsonNode root=mapper.readTree(new ClassPathResource("codeescape/content.json").getInputStream());int si=0,pc=0;for(JsonNode sn:root.path("scenarios")){EscapeScenario s=new EscapeScenario(sn.path("slug").asText(),sn.path("title").asText(),sn.path("difficulty").asText(),sn.path("examScope").asText("AP1"),sn.path("description").asText(),sn.path("timeLimitSeconds").asInt(900),si++);int ri=0;for(JsonNode rn:sn.path("rooms")){EscapeRoom r=new EscapeRoom(rn.path("title").asText(),rn.path("intro").asText(),rn.path("doorPrompt").asText(),rn.path("doorCode").asText(),rn.path("successText").asText(),ri++);int pi=0;for(JsonNode pn:rn.path("puzzles")){r.addPuzzle(new EscapePuzzle(pn.path("type").asText(),pn.path("prompt").asText(),pn.path("points").asInt(100),pn.path("fragment").asText(),pi++,mapper.writeValueAsString(pn.path("payload"))));pc++;}s.addRoom(r);}repo.save(s);}log.info("CodeEscape: {} Szenarien / {} Rätsel geladen.",si,pc);}catch(Exception e){log.error("CodeEscape-Seed fehlgeschlagen",e);}}} diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index dd3ddac..bcab29c 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -42,7 +42,7 @@ public class SecurityConfig { .csrf(csrf -> csrf // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. - .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/controller/AdminCodeEscapeController.java b/src/main/java/com/example/mindflux/controller/AdminCodeEscapeController.java new file mode 100644 index 0000000..1f33e56 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/AdminCodeEscapeController.java @@ -0,0 +1,2 @@ +package com.example.mindflux.controller; import com.example.mindflux.service.CodeEscapeService;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.*; +@Controller @RequestMapping("/admin/codeescape") public class AdminCodeEscapeController {private final CodeEscapeService service;public AdminCodeEscapeController(CodeEscapeService s){service=s;}@GetMapping public String list(Model m){m.addAttribute("scenarios",service.all());return"admin/codeescape";}@PostMapping("/add")public String add(@RequestParam String title,@RequestParam String difficulty,@RequestParam String examScope,@RequestParam(required=false)String description,@RequestParam(defaultValue="900")int timeLimitSeconds){Long id=service.addScenario(title,difficulty,examScope,description,timeLimitSeconds);return"redirect:/admin/codeescape/"+id;}@GetMapping("/{id}")public String scenario(@PathVariable Long id,Model m){m.addAttribute("scenario",service.scenario(id));return"admin/codeescape-scenario";}@PostMapping("/{id}/rooms")public String room(@PathVariable Long id,@RequestParam String title,@RequestParam String intro,@RequestParam String doorPrompt,@RequestParam String doorCode,@RequestParam String successText){service.addRoom(id,title,intro,doorPrompt,doorCode,successText);return"redirect:/admin/codeescape/"+id;}@GetMapping("/rooms/{id}")public String roomPage(@PathVariable Long id,Model m){m.addAttribute("room",service.room(id));return"admin/codeescape-room";}@PostMapping("/rooms/{id}/puzzles")public String puzzle(@PathVariable Long id,@RequestParam String type,@RequestParam String prompt,@RequestParam int points,@RequestParam String codeFragment,@RequestParam String payload){service.addPuzzle(id,type,prompt,points,codeFragment,payload);return"redirect:/admin/codeescape/rooms/"+id;}@PostMapping("/{id}/toggle")public String toggle(@PathVariable Long id){service.toggle(id);return"redirect:/admin/codeescape";}@PostMapping("/{id}/delete")public String del(@PathVariable Long id){service.deleteScenario(id);return"redirect:/admin/codeescape";}@PostMapping("/rooms/{id}/delete")public String delRoom(@PathVariable Long id){var r=service.room(id);Long sid=r.getScenario().getId();service.deleteRoom(id);return"redirect:/admin/codeescape/"+sid;}@PostMapping("/puzzles/{id}/delete")public String delPuzzle(@PathVariable Long id,@RequestParam Long roomId){service.deletePuzzle(id);return"redirect:/admin/codeescape/rooms/"+roomId;}} diff --git a/src/main/java/com/example/mindflux/controller/CodeEscapeApiController.java b/src/main/java/com/example/mindflux/controller/CodeEscapeApiController.java new file mode 100644 index 0000000..ac2cbd4 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/CodeEscapeApiController.java @@ -0,0 +1,2 @@ +package com.example.mindflux.controller; import com.example.mindflux.dto.*;import com.example.mindflux.service.CodeEscapeService;import com.fasterxml.jackson.databind.JsonNode;import jakarta.servlet.http.HttpSession;import org.springframework.web.bind.annotation.*;import java.security.Principal; +@RestController @RequestMapping("/api/codeescape") public class CodeEscapeApiController {private final CodeEscapeService service;public CodeEscapeApiController(CodeEscapeService s){service=s;}@GetMapping("/scenarios")public JsonNode catalog(){return service.catalog();}@GetMapping("/session/{slug}")public JsonNode session(@PathVariable String slug,HttpSession s){return service.session(slug,s);}@PostMapping("/check")public JsonNode check(@RequestBody EscapeCheckRequest r,HttpSession s){return service.check(r.getPuzzleId(),r.isReveal(),r.getSubmission(),s);}@PostMapping("/door")public JsonNode door(@RequestBody EscapeDoorRequest r,HttpSession s){return service.door(r.getRoomId(),r.getCode(),s);}@PostMapping("/hint")public JsonNode hint(@RequestBody EscapeHintRequest r,HttpSession s){return service.hint(r.getPuzzleId(),r.getLevel(),s);}@PostMapping("/finish")public EscapeResult finish(@RequestBody EscapeFinishRequest r,Principal p,HttpSession s){return service.finish(p.getName(),r,s);}} diff --git a/src/main/java/com/example/mindflux/controller/CodeEscapeController.java b/src/main/java/com/example/mindflux/controller/CodeEscapeController.java new file mode 100644 index 0000000..acdd020 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/CodeEscapeController.java @@ -0,0 +1 @@ +package com.example.mindflux.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class CodeEscapeController{@GetMapping("/codeescape")public String page(){return "codeescape";}} diff --git a/src/main/java/com/example/mindflux/dto/EscapeCheckRequest.java b/src/main/java/com/example/mindflux/dto/EscapeCheckRequest.java new file mode 100644 index 0000000..9b9692d --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/EscapeCheckRequest.java @@ -0,0 +1 @@ +package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; public class EscapeCheckRequest {private Long puzzleId;private boolean reveal;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public boolean isReveal(){return reveal;}public void setReveal(boolean v){reveal=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}} diff --git a/src/main/java/com/example/mindflux/dto/EscapeDoorRequest.java b/src/main/java/com/example/mindflux/dto/EscapeDoorRequest.java new file mode 100644 index 0000000..0931ec8 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/EscapeDoorRequest.java @@ -0,0 +1 @@ +package com.example.mindflux.dto; public class EscapeDoorRequest {private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}} diff --git a/src/main/java/com/example/mindflux/dto/EscapeFinishRequest.java b/src/main/java/com/example/mindflux/dto/EscapeFinishRequest.java new file mode 100644 index 0000000..6f4f066 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/EscapeFinishRequest.java @@ -0,0 +1 @@ +package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; import java.util.*; public class EscapeFinishRequest {private String slug;private int durationSeconds;private boolean escaped;private List items=new ArrayList<>();private List doors=new ArrayList<>();public String getSlug(){return slug;}public void setSlug(String v){slug=v;}public int getDurationSeconds(){return durationSeconds;}public void setDurationSeconds(int v){durationSeconds=v;}public boolean isEscaped(){return escaped;}public void setEscaped(boolean v){escaped=v;}public List getItems(){return items;}public void setItems(List v){items=v;}public List getDoors(){return doors;}public void setDoors(List v){doors=v;}public static class Item{private Long puzzleId;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}}public static class Door{private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}}} diff --git a/src/main/java/com/example/mindflux/dto/EscapeHintRequest.java b/src/main/java/com/example/mindflux/dto/EscapeHintRequest.java new file mode 100644 index 0000000..2352dcf --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/EscapeHintRequest.java @@ -0,0 +1 @@ +package com.example.mindflux.dto; public class EscapeHintRequest {private Long puzzleId;private int level;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public int getLevel(){return level;}public void setLevel(int v){level=v;}} diff --git a/src/main/java/com/example/mindflux/dto/EscapeResult.java b/src/main/java/com/example/mindflux/dto/EscapeResult.java new file mode 100644 index 0000000..f0941f2 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/EscapeResult.java @@ -0,0 +1 @@ +package com.example.mindflux.dto; public record EscapeResult(int score,int baseScore,int maxScore,int timeBonus,int solvedPuzzles,int roomsOpened,boolean escaped,int stars,int highscore,boolean newHighscore,long plays){} diff --git a/src/main/java/com/example/mindflux/model/EscapePuzzle.java b/src/main/java/com/example/mindflux/model/EscapePuzzle.java new file mode 100644 index 0000000..08791b9 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/EscapePuzzle.java @@ -0,0 +1,7 @@ +package com.example.mindflux.model; +import jakarta.persistence.*; +@Entity @Table(name="escape_puzzles") public class EscapePuzzle { + @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="room_id") private EscapeRoom room; + @Column(name="puzzle_type") private String type; private String prompt; private int points=100; @Column(name="code_fragment") private String codeFragment; @Column(name="sort_order") private int sortOrder; private String payload; + public EscapePuzzle(){} public EscapePuzzle(String t,String p,int pts,String f,int o,String j){type=t;prompt=p;points=pts;codeFragment=f;sortOrder=o;payload=j;} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeRoom getRoom(){return room;} public void setRoom(EscapeRoom v){room=v;} public String getType(){return type;} public void setType(String v){type=v;} public String getPrompt(){return prompt;} public void setPrompt(String v){prompt=v;} public int getPoints(){return points;} public void setPoints(int v){points=v;} public String getCodeFragment(){return codeFragment;} public void setCodeFragment(String v){codeFragment=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public String getPayload(){return payload;} public void setPayload(String v){payload=v;} +} diff --git a/src/main/java/com/example/mindflux/model/EscapeRoom.java b/src/main/java/com/example/mindflux/model/EscapeRoom.java new file mode 100644 index 0000000..5d25918 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/EscapeRoom.java @@ -0,0 +1,9 @@ +package com.example.mindflux.model; +import jakarta.persistence.*; import java.util.*; +@Entity @Table(name="escape_rooms") +public class EscapeRoom { + @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="scenario_id") private EscapeScenario scenario; + private String title; private String intro; @Column(name="door_prompt") private String doorPrompt; @Column(name="door_code") private String doorCode; @Column(name="success_text") private String successText; @Column(name="sort_order") private int sortOrder; + @OneToMany(mappedBy="room",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List puzzles=new ArrayList<>(); + public EscapeRoom(){} public EscapeRoom(String t,String i,String p,String c,String s,int o){title=t;intro=i;doorPrompt=p;doorCode=c;successText=s;sortOrder=o;} public void addPuzzle(EscapePuzzle p){p.setRoom(this);puzzles.add(p);} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeScenario getScenario(){return scenario;} public void setScenario(EscapeScenario v){scenario=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getIntro(){return intro;} public void setIntro(String v){intro=v;} public String getDoorPrompt(){return doorPrompt;} public void setDoorPrompt(String v){doorPrompt=v;} public String getDoorCode(){return doorCode;} public void setDoorCode(String v){doorCode=v;} public String getSuccessText(){return successText;} public void setSuccessText(String v){successText=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public List getPuzzles(){return puzzles;} +} diff --git a/src/main/java/com/example/mindflux/model/EscapeScenario.java b/src/main/java/com/example/mindflux/model/EscapeScenario.java new file mode 100644 index 0000000..9712c9c --- /dev/null +++ b/src/main/java/com/example/mindflux/model/EscapeScenario.java @@ -0,0 +1,13 @@ +package com.example.mindflux.model; +import jakarta.persistence.*; import java.util.*; +@Entity @Table(name="escape_scenarios") +public class EscapeScenario { + @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; + @Column(unique=true,nullable=false) private String slug; private String title; private String difficulty; + @Column(name="exam_scope") private String examScope="AP1"; private String description; + @Column(name="time_limit_seconds") private int timeLimitSeconds=900; + @Column(name="sort_order") private int sortOrder; private boolean active=true; + @OneToMany(mappedBy="scenario",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List rooms=new ArrayList<>(); + public EscapeScenario(){} public EscapeScenario(String slug,String title,String difficulty,String examScope,String description,int limit,int order){this.slug=slug;this.title=title;this.difficulty=difficulty;this.examScope=examScope;this.description=description;this.timeLimitSeconds=limit;this.sortOrder=order;} + public void addRoom(EscapeRoom r){r.setScenario(this);rooms.add(r);} public Long getId(){return id;} public void setId(Long v){id=v;} public String getSlug(){return slug;} public void setSlug(String v){slug=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getDifficulty(){return difficulty;} public void setDifficulty(String v){difficulty=v;} public String getExamScope(){return examScope;} public void setExamScope(String v){examScope=v;} public String getDescription(){return description;} public void setDescription(String v){description=v;} public int getTimeLimitSeconds(){return timeLimitSeconds;} public void setTimeLimitSeconds(int v){timeLimitSeconds=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public boolean isActive(){return active;} public void setActive(boolean v){active=v;} public List getRooms(){return rooms;} +} diff --git a/src/main/java/com/example/mindflux/repository/EscapePuzzleRepository.java b/src/main/java/com/example/mindflux/repository/EscapePuzzleRepository.java new file mode 100644 index 0000000..25ea5e7 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/EscapePuzzleRepository.java @@ -0,0 +1 @@ +package com.example.mindflux.repository; import com.example.mindflux.model.EscapePuzzle; import org.springframework.data.jpa.repository.JpaRepository; public interface EscapePuzzleRepository extends JpaRepository{long countByRoomId(Long id);} diff --git a/src/main/java/com/example/mindflux/repository/EscapeRoomRepository.java b/src/main/java/com/example/mindflux/repository/EscapeRoomRepository.java new file mode 100644 index 0000000..0361cd5 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/EscapeRoomRepository.java @@ -0,0 +1 @@ +package com.example.mindflux.repository; import com.example.mindflux.model.EscapeRoom; import org.springframework.data.jpa.repository.JpaRepository; public interface EscapeRoomRepository extends JpaRepository{long countByScenarioId(Long id);} diff --git a/src/main/java/com/example/mindflux/repository/EscapeScenarioRepository.java b/src/main/java/com/example/mindflux/repository/EscapeScenarioRepository.java new file mode 100644 index 0000000..72b6088 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/EscapeScenarioRepository.java @@ -0,0 +1,2 @@ +package com.example.mindflux.repository; import com.example.mindflux.model.EscapeScenario; import org.springframework.data.jpa.repository.JpaRepository; import java.util.*; +public interface EscapeScenarioRepository extends JpaRepository{List findAllByActiveTrueOrderBySortOrderAscIdAsc();List findAllByOrderBySortOrderAscIdAsc();Optional findBySlug(String slug);boolean existsBySlug(String slug);} diff --git a/src/main/java/com/example/mindflux/service/CodeEscapeService.java b/src/main/java/com/example/mindflux/service/CodeEscapeService.java new file mode 100644 index 0000000..a27ea76 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/CodeEscapeService.java @@ -0,0 +1,26 @@ +package com.example.mindflux.service; +import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import com.example.mindflux.repository.*; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.node.*; import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; +@Service public class CodeEscapeService { + 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<>();} + 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 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 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");} + 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());} + // Admin + @Transactional(readOnly=true) public List all(){return scenarios.findAllByOrderBySortOrderAscIdAsc();}@Transactional(readOnly=true) public EscapeScenario scenario(Long id){EscapeScenario s=scenarios.findById(id).orElseThrow();s.getRooms().size();return s;}@Transactional(readOnly=true) public EscapeRoom room(Long id){EscapeRoom r=rooms.findById(id).orElseThrow();r.getPuzzles().size();return r;} + @Transactional public Long addScenario(String title,String difficulty,String scope,String description,int limit){String slug=title.toLowerCase().replaceAll("[^a-z0-9]+","-").replaceAll("(^-|-$)","");if(slug.isBlank())slug="scenario";String b=slug;int i=2;while(scenarios.existsBySlug(slug))slug=b+"-"+i++;EscapeScenario s=new EscapeScenario(slug,title.trim(),difficulty,SCOPES.contains(scope)?scope:"AP1",description,Math.max(300,Math.min(3600,limit)),(int)scenarios.count()+1);return scenarios.save(s).getId();} + @Transactional public void addRoom(Long sid,String title,String intro,String prompt,String code,String success){EscapeScenario s=scenarios.findById(sid).orElseThrow();s.addRoom(new EscapeRoom(title,intro,prompt,code,success,s.getRooms().size()));} + @Transactional public void addPuzzle(Long rid,String type,String prompt,int points,String fragment,String payload){if(!TYPES.contains(type)||parse(payload)==null)throw new IllegalArgumentException("Ungültiger Rätseltyp oder Payload.");EscapeRoom r=rooms.findById(rid).orElseThrow();r.addPuzzle(new EscapePuzzle(type,prompt,Math.max(10,Math.min(1000,points)),fragment,r.getPuzzles().size(),payload));} + @Transactional public void deleteScenario(Long id){scenarios.deleteById(id);}@Transactional public void deleteRoom(Long id){rooms.deleteById(id);}@Transactional public void deletePuzzle(Long id){puzzles.deleteById(id);}@Transactional public void toggle(Long id){scenarios.findById(id).ifPresent(s->s.setActive(!s.isActive()));} +} diff --git a/src/main/resources/codeescape/content.json b/src/main/resources/codeescape/content.json new file mode 100644 index 0000000..d47180c --- /dev/null +++ b/src/main/resources/codeescape/content.json @@ -0,0 +1,37 @@ +{"scenarios":[ +{"slug":"verlassener-serverraum","title":"Der verlassene Serverraum","difficulty":"leicht","examScope":"AP1","description":"Zahlensysteme, Ports und Netzwerkgrundlagen öffnen den Weg nach draußen.","timeLimitSeconds":900,"rooms":[ +{"title":"Rack 01","intro":"Notlicht flackert über einem verriegelten Rack. Das Terminal verlangt drei Fragmente.","doorPrompt":"Alle Fragmente in Fundreihenfolge eingeben.","doorCode":"4122SYN","successText":"Das Rack entriegelt. Hinter der Rückwand führt ein Wartungsgang weiter.","puzzles":[ +{"type":"code","prompt":"Binärterminal","points":100,"fragment":"41","payload":{"narrative":"Auf dem Display pulsiert 0b101001.","instruction":"Wandle die Binärzahl in Dezimal um.","placeholder":"Dezimalwert","solutions":["41"],"hints":["Gewichte von rechts: 1, 2, 4, 8, 16, 32.","32 + 8 + 1"],"explanation":"101001₂ entspricht 32+8+1 = 41."}}, +{"type":"choice","prompt":"Offener Wartungsport","points":100,"fragment":"22","payload":{"instruction":"Welcher Standardport gehört zu SSH?","options":["21","22","23","25"],"correct":"22","hints":["SSH ersetzt unsichere Fernwartung per Telnet.","Der Port liegt direkt nach FTP."],"explanation":"SSH nutzt standardmäßig TCP-Port 22."}}, +{"type":"order","prompt":"TCP-Verbindung","points":120,"fragment":"SYN","payload":{"instruction":"Bringe den TCP-Drei-Wege-Handshake in Reihenfolge.","items":["SYN","SYN-ACK","ACK"],"solution":["SYN","SYN-ACK","ACK"],"hints":["Der Client beginnt mit einer Synchronisationsanfrage.","Die Mitte bestätigt und synchronisiert zugleich."],"explanation":"SYN → SYN-ACK → ACK."}}]}, +{"title":"Patchfeld","intro":"Lose Leitungen hängen über dem Boden. Ein altes Patchpanel blockiert die Schleuse.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"6253UP","successText":"Die Link-LEDs springen an und die Schleuse gleitet auf.","puzzles":[ +{"type":"code","prompt":"Subnetz-Kapazität","points":120,"fragment":"62","payload":{"instruction":"Wie viele nutzbare IPv4-Hosts bietet ein /26-Netz klassisch?","solutions":["62"],"hints":["Ein /26 lässt 6 Hostbits.","2⁶ Adressen minus Netz und Broadcast."],"explanation":"64 Gesamtadressen minus 2 = 62 Hosts."}}, +{"type":"match","prompt":"Dienste verbinden","points":120,"fragment":"53","payload":{"instruction":"Ordne Dienst und Standardport zu.","pairs":[["DNS","53"],["HTTP","80"],["HTTPS","443"]],"hints":["Web unverschlüsselt liegt auf 80.","DNS nutzt 53, TLS-Web 443."],"explanation":"DNS 53, HTTP 80, HTTPS 443."}}, +{"type":"choice","prompt":"Kabeldiagnose","points":100,"fragment":"UP","payload":{"instruction":"Welche Anzeige bestätigt eine physische Ethernet-Verbindung?","options":["Link LED","DNS Cache","Default Route","TTL"],"correct":"Link LED","hints":["Gesucht ist Schicht 1.","Sie sitzt meist direkt an der Buchse."],"explanation":"Die Link-LED zeigt den physischen Link."}}]}, +{"title":"Kontrollraum","intro":"Die Hauptkonsole wartet auf die letzte Startsequenz.","doorPrompt":"Nutze die Fragmente der beiden Kontrollrätsel.","doorCode":"DHCP67","successText":"Die Außentür öffnet sich. Frische Nachtluft strömt herein.","puzzles":[ +{"type":"order","prompt":"DHCP-Ablauf","points":140,"fragment":"DHCP","payload":{"instruction":"Sortiere den DORA-Ablauf.","items":["Discover","Offer","Request","Acknowledge"],"solution":["Discover","Offer","Request","Acknowledge"],"hints":["Der Client sucht zuerst.","Discover, Offer, Request, Acknowledge."],"explanation":"DORA beschreibt die DHCP-Adressvergabe."}}, +{"type":"code","prompt":"DHCP-Serverport","points":100,"fragment":"67","payload":{"instruction":"Welchen UDP-Port nutzt der DHCP-Server?","solutions":["67"],"hints":["Client und Server verwenden zwei benachbarte Ports.","Der Server nutzt den kleineren: 67."],"explanation":"DHCP-Server: UDP 67, Client: UDP 68."}}]}]}, +{"slug":"lockdown-rechenzentrum","title":"Lockdown im Rechenzentrum","difficulty":"mittel","examScope":"AP2_FISI","description":"RAID, USV, Backup und Virtualisierung unter Zeitdruck.","timeLimitSeconds":1100,"rooms":[ +{"title":"Stromschleuse","intro":"Die USV hält nur noch wenige Minuten. Das Energiemanagement ist verriegelt.","doorPrompt":"Gib alle drei Fragmente in Fundreihenfolge ein.","doorCode":"3TBUSVR5","successText":"Die Last wechselt auf den sicheren Pfad.","puzzles":[ +{"type":"code","prompt":"RAID-5-Kapazität","points":150,"fragment":"3TB","payload":{"instruction":"Vier 1-TB-Platten im RAID 5: nutzbare Kapazität?","solutions":["3 TB","3TB","3"],"hints":["Eine Plattenkapazität geht für Parität verloren.","(4−1) × 1 TB"],"explanation":"RAID 5 nutzt n−1 Plattenkapazitäten."}}, +{"type":"choice","prompt":"USV-Typ","points":140,"fragment":"USV","payload":{"instruction":"Welche USV entkoppelt Verbraucher dauerhaft über den Wechselrichter?","options":["Offline","Line-interactive","Online-Doppelwandler","Steckdosenleiste"],"correct":"Online-Doppelwandler","hints":["Die Last wird permanent neu erzeugt.","VFI/Online-Doppelwandler."],"explanation":"Online-USV: Netz → Gleichrichter → Zwischenkreis → Wechselrichter."}}, +{"type":"match","prompt":"RAID-Eigenschaften","points":140,"fragment":"R5","payload":{"instruction":"Ordne Level und Kerneigenschaft.","pairs":[["RAID 0","Striping"],["RAID 1","Spiegelung"],["RAID 5","verteilte Parität"]],"hints":["RAID 1 dupliziert Daten.","RAID 5 verteilt Paritätsinformationen."],"explanation":"Die Zuordnung beschreibt die Grundverfahren."}}]}, +{"title":"Backup-Tresor","intro":"Ein Ransomware-Alarm sperrt das Backup-System.","doorPrompt":"Verbinde Backupfolge und Schutzprinzip.","doorCode":"VOLL321","successText":"Das unveränderliche Backup wird freigegeben.","puzzles":[ +{"type":"order","prompt":"GFS-Wiederherstellung","points":160,"fragment":"VOLL","payload":{"instruction":"Sortiere eine Wiederherstellung mit inkrementellen Backups.","items":["Vollsicherung","Inkrement 1","Inkrement 2","Inkrement 3"],"solution":["Vollsicherung","Inkrement 1","Inkrement 2","Inkrement 3"],"hints":["Basis ist immer das letzte Vollbackup.","Danach jedes Inkrement chronologisch."],"explanation":"Inkrementelle Ketten benötigen Vollbackup plus alle Folgeinkremente."}}, +{"type":"code","prompt":"3-2-1-Regel","points":140,"fragment":"321","payload":{"instruction":"Welche drei Ziffern beschreiben Kopien, Medientypen und externen Standort?","solutions":["321","3-2-1"],"hints":["Drei Kopien auf zwei Medien.","Eine Kopie außer Haus."],"explanation":"3 Kopien, 2 Medientypen, 1 extern."}}]}, +{"title":"Hypervisor-Kern","intro":"Die Managementkonsole meldet Ressourcenüberbuchung.","doorPrompt":"Kombiniere alle Fragmente in Fundreihenfolge.","doorCode":"TYPE1NOSNAPISO","successText":"Der Cluster übernimmt und der Lockdown endet.","puzzles":[ +{"type":"choice","prompt":"Bare-Metal-Hypervisor","points":150,"fragment":"TYPE1","payload":{"instruction":"Welcher Hypervisor läuft direkt auf der Hardware?","options":["Typ 1","Typ 2","Container Runtime","JVM"],"correct":"Typ 1","hints":["Kein Hostbetriebssystem dazwischen.","Auch Bare-Metal genannt."],"explanation":"Typ-1-Hypervisor laufen direkt auf der Hardware."}}, +{"type":"code","prompt":"Snapshot-Falle","points":150,"fragment":"NOSNAP","payload":{"instruction":"Ist ein VM-Snapshot ein vollwertiges Backup? Antworte ja oder nein.","solutions":["nein","no"],"hints":["Snapshot und Basisdatenträger bleiben gekoppelt.","Ein Defekt des Datastores betrifft beides."],"explanation":"Snapshots ersetzen kein unabhängiges Backup."}}, +{"type":"match","prompt":"Virtualisierungsgrenzen","points":150,"fragment":"ISO","payload":{"instruction":"Ordne Technik und Isolation.","pairs":[["VM","eigener Kernel"],["Container","geteilter Host-Kernel"],["VLAN","logische Netzsegmentierung"]],"hints":["Container teilen den Kernel.","VLAN segmentiert das Netz, nicht Prozesse."],"explanation":"VM, Container und VLAN isolieren auf verschiedenen Ebenen."}}]}]}, +{"slug":"gehacktes-buero","title":"Das gehackte Büro","difficulty":"schwer","examScope":"CROSS","description":"Zertifikate, Kryptografie, SQL und Incident Response auf AP2-Niveau.","timeLimitSeconds":1200,"rooms":[ +{"title":"Forensik-Labor","intro":"Ein kompromittierter Client hält die Beweise verschlüsselt.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"SHA256443CIA","successText":"Das Beweisarchiv wird lesbar.","puzzles":[ +{"type":"choice","prompt":"Hashverfahren","points":180,"fragment":"SHA256","payload":{"instruction":"Welches Verfahren ist für Integritätsprüfungen heute geeignet?","options":["MD5","SHA-1","SHA-256","ROT13"],"correct":"SHA-256","hints":["MD5 und SHA-1 gelten als kollisionsgeschwächt.","Die gesuchte SHA-2-Variante hat 256 Bit."],"explanation":"SHA-256 ist eine verbreitete kryptografische Hashfunktion."}}, +{"type":"code","prompt":"TLS-Port","points":140,"fragment":"443","payload":{"instruction":"Standardport für HTTPS?","solutions":["443"],"hints":["HTTP nutzt 80.","HTTPS nutzt 443."],"explanation":"HTTPS verwendet TCP 443."}}, +{"type":"match","prompt":"Schutzziele","points":170,"fragment":"CIA","payload":{"instruction":"Ordne CIA-Schutzziel und deutsche Bedeutung.","pairs":[["Confidentiality","Vertraulichkeit"],["Integrity","Integrität"],["Availability","Verfügbarkeit"]],"hints":["CIA ist nicht der Nachrichtendienst in diesem Kontext.","C=Confidentiality, I=Integrity, A=Availability."],"explanation":"Die CIA-Triade bildet zentrale Informationssicherheitsziele."}}]}, +{"title":"Datenbank-Bunker","intro":"Die Angreifer haben Spuren in einer unsicheren Suchmaske hinterlassen.","doorPrompt":"Verbinde SQL-Schutz und Transaktionsfragment.","doorCode":"PREPAREDACID","successText":"Der Datenbankzugang ist wieder unter Kontrolle.","puzzles":[ +{"type":"choice","prompt":"SQL-Injection stoppen","points":190,"fragment":"PREPARED","payload":{"instruction":"Welche Maßnahme trennt SQL-Code zuverlässig von Eingabedaten?","options":["Prepared Statements","HTML-Escaping","Base64","Mehr RAM"],"correct":"Prepared Statements","hints":["Parameter werden gebunden statt konkateniert.","Prepared Statements."],"explanation":"Parametrisierte Statements verhindern das Einschleusen von SQL-Syntax."}}, +{"type":"order","prompt":"Sichere Transaktion","points":180,"fragment":"ACID","payload":{"instruction":"Ordne den vereinfachten Ablauf.","items":["BEGIN","Änderungen ausführen","Validieren","COMMIT"],"solution":["BEGIN","Änderungen ausführen","Validieren","COMMIT"],"hints":["Transaktion öffnen, arbeiten, prüfen, bestätigen.","COMMIT steht am Ende."],"explanation":"Erst nach erfolgreicher Validierung wird committed."}}]}, +{"title":"Incident-Leitstand","intro":"Die letzte Tür verlangt eine saubere Reaktion statt hektischer Einzelaktionen.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"ISOLATECALEAST","successText":"Die Systeme sind eingedämmt. Der Ausgang ist frei.","puzzles":[ +{"type":"order","prompt":"Incident Response","points":200,"fragment":"ISOLATE","payload":{"instruction":"Sortiere die ersten Reaktionsschritte.","items":["Erkennen","Eindämmen","Beweise sichern","Bereinigen","Wiederherstellen"],"solution":["Erkennen","Eindämmen","Beweise sichern","Bereinigen","Wiederherstellen"],"hints":["Vor Bereinigung muss der Vorfall eingedämmt und dokumentiert sein.","Erkennen → Eindämmen → Beweise → Bereinigen → Wiederherstellen."],"explanation":"Die Reihenfolge erhält Beweise und begrenzt Schaden."}}, +{"type":"choice","prompt":"Zertifikatsvertrauen","points":180,"fragment":"CA","payload":{"instruction":"Wer bestätigt die Bindung zwischen Identität und öffentlichem Schlüssel?","options":["Certificate Authority","DHCP-Server","Hypervisor","Switch"],"correct":"Certificate Authority","hints":["Sie signiert Zertifikate.","Certificate Authority (CA)."],"explanation":"Eine CA validiert und signiert Zertifikate."}}, +{"type":"code","prompt":"Prinzip der Rechte","points":160,"fragment":"LEAST","payload":{"instruction":"Ergänze: Principle of ___ Privilege.","solutions":["least"],"caseSensitive":false,"hints":["Nur minimal notwendige Rechte.","Das englische Wort bedeutet ‚am wenigsten‘."],"explanation":"Least Privilege reduziert Angriffsfläche."}}]}]}]} diff --git a/src/main/resources/db/migration/V11__codeescape.sql b/src/main/resources/db/migration/V11__codeescape.sql new file mode 100644 index 0000000..edc273d --- /dev/null +++ b/src/main/resources/db/migration/V11__codeescape.sql @@ -0,0 +1,27 @@ +CREATE TABLE escape_scenarios ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + difficulty VARCHAR(30) NOT NULL DEFAULT 'leicht', + exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1', + description VARCHAR(1200), + time_limit_seconds INT NOT NULL DEFAULT 900, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT chk_escape_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS')) +); +CREATE TABLE escape_rooms ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + scenario_id BIGINT NOT NULL REFERENCES escape_scenarios(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, intro VARCHAR(2000), door_prompt VARCHAR(500), + door_code VARCHAR(50) NOT NULL, success_text VARCHAR(1000), sort_order INT NOT NULL DEFAULT 0 +); +CREATE TABLE escape_puzzles ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES escape_rooms(id) ON DELETE CASCADE, + puzzle_type VARCHAR(30) NOT NULL, prompt VARCHAR(500) NOT NULL, + points INT NOT NULL DEFAULT 100, code_fragment VARCHAR(20) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, payload VARCHAR(8000) NOT NULL +); +CREATE INDEX idx_escape_rooms_scenario ON escape_rooms(scenario_id); +CREATE INDEX idx_escape_puzzles_room ON escape_puzzles(room_id); diff --git a/src/main/resources/static/css/codeescape.css b/src/main/resources/static/css/codeescape.css new file mode 100644 index 0000000..6095604 --- /dev/null +++ b/src/main/resources/static/css/codeescape.css @@ -0,0 +1 @@ +.esc-app{--accent:var(--esc-accent);--glow-s:var(--esc-glow);min-height:100vh;background:radial-gradient(circle at 50% -20%,color-mix(in srgb,var(--esc-accent) 15%,transparent),transparent 45%),repeating-linear-gradient(0deg,rgba(255,255,255,.018) 0 1px,transparent 1px 4px),var(--bg);color:var(--text);font-family:"Courier New",monospace}.esc-top{min-height:76px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:12px 24px;border-bottom:1px solid color-mix(in srgb,var(--esc-accent) 45%,var(--border));background:color-mix(in srgb,var(--panel) 93%,transparent);position:sticky;top:0;z-index:20}.esc-back{color:var(--muted);text-decoration:none}.esc-brand{text-align:center}.esc-brand b{display:block;color:var(--esc-accent);font-size:1.25rem;letter-spacing:.18em;text-shadow:0 0 14px var(--esc-glow)}.esc-brand span{font-size:.62rem;color:var(--muted);letter-spacing:.15em}.esc-timer{justify-self:end;border:1px solid var(--border);padding:.55rem .8rem;border-radius:7px;color:var(--esc-accent);font-size:1.15rem}.esc-timer.alarm{color:var(--danger);border-color:var(--danger);animation:esc-alarm 1s infinite}.esc-view{max-width:1180px;margin:auto;padding:2rem 1.2rem}.esc-loading{text-align:center;color:var(--muted);padding:20vh 0}.esc-hero{border:1px solid color-mix(in srgb,var(--esc-accent) 50%,var(--border));background:var(--surface);padding:1.4rem;border-radius:14px;margin-bottom:1.2rem;box-shadow:0 0 28px color-mix(in srgb,var(--esc-accent) 12%,transparent)}.esc-eyebrow{color:var(--esc-accent);font-size:.7rem;letter-spacing:.15em}.esc-hero h1{color:var(--heading);margin:.45rem 0}.esc-hero p{color:var(--muted);line-height:1.6}.esc-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.esc-card,.esc-puzzle{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.1rem;position:relative;overflow:hidden}.esc-card:before,.esc-puzzle:before{content:"";position:absolute;left:0;top:0;width:4px;height:100%;background:var(--esc-accent);box-shadow:0 0 12px var(--esc-glow)}.esc-card h2,.esc-puzzle h3{color:var(--heading);margin:.35rem 0}.esc-card p{color:var(--muted);min-height:4.5em;line-height:1.5}.esc-meta{display:flex;gap:.4rem;flex-wrap:wrap;margin:.7rem 0}.esc-chip{font-size:.68rem;border:1px solid var(--border);border-radius:99px;padding:.25rem .5rem;color:var(--muted)}.esc-btn{border:1px solid var(--esc-accent);background:transparent;color:var(--esc-accent);padding:.65rem .9rem;border-radius:8px;font:700 .78rem inherit;cursor:pointer}.esc-btn:hover,.esc-btn.primary{background:var(--esc-accent);color:#160d00;box-shadow:0 0 16px var(--esc-glow)}.esc-btn.danger{border-color:var(--danger);color:var(--danger)}.esc-room-head{display:flex;justify-content:space-between;gap:1rem;align-items:start;margin-bottom:1rem}.esc-room-head h1{color:var(--heading)}.esc-room-head p{color:var(--muted);margin-top:.4rem}.esc-progress{color:var(--esc-accent)}.esc-puzzles{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.esc-puzzle.done{border-color:var(--success)}.esc-puzzle.locked{opacity:.5}.esc-puzzle .status{color:var(--muted);font-size:.72rem;margin:.5rem 0}.esc-inventory{margin:1rem 0;padding:1rem;background:var(--surface);border:1px dashed var(--border-accent);border-radius:12px}.esc-fragments{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.6rem}.esc-fragment{padding:.45rem .7rem;border:1px solid var(--esc-accent);color:var(--esc-accent);background:color-mix(in srgb,var(--esc-accent) 8%,transparent);border-radius:6px}.esc-door{display:flex;gap:.7rem;align-items:end;background:var(--surface);padding:1rem;border-radius:12px;border:1px solid var(--border)}.esc-door label{flex:1;color:var(--muted)}.esc-input,.esc-select{width:100%;margin-top:.35rem;padding:.7rem;background:#080604;color:var(--text);border:1px solid var(--border);border-radius:7px;font:inherit}.esc-modal{position:fixed;inset:0;z-index:100;display:grid;place-items:center;padding:1rem;background:rgba(0,0,0,.82);backdrop-filter:blur(5px)}.esc-dialog{width:min(700px,100%);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--esc-accent);border-radius:14px;padding:1.3rem;box-shadow:0 0 50px var(--esc-glow)}.esc-dialog h2{color:var(--heading);margin:.4rem 0}.esc-dialog p{color:var(--muted);line-height:1.55}.esc-options{display:grid;gap:.55rem;margin:1rem 0}.esc-option{padding:.7rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--text);cursor:pointer;text-align:left}.esc-option.selected{border-color:var(--esc-accent);color:var(--esc-accent)}.esc-pairs{display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin:1rem 0}.esc-actions{display:flex;gap:.55rem;flex-wrap:wrap;margin-top:1rem}.esc-feedback{min-height:1.5rem;margin-top:.7rem}.esc-feedback.ok{color:var(--success)}.esc-feedback.bad{color:var(--danger)}.esc-result{text-align:center;max-width:720px;margin:5vh auto;background:var(--surface);border:1px solid var(--esc-accent);border-radius:18px;padding:2rem}.esc-result .score{font-size:3rem;color:var(--esc-accent);text-shadow:0 0 20px var(--esc-glow)}.esc-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.7rem;margin:1rem 0}.esc-stats div{padding:.8rem;background:var(--surface-2);border-radius:8px}.esc-stats b{display:block;color:var(--heading)}@keyframes esc-alarm{50%{box-shadow:0 0 20px var(--danger)}}@media(max-width:800px){.esc-grid,.esc-puzzles{grid-template-columns:1fr}.esc-top{grid-template-columns:1fr auto}.esc-brand{display:none}.esc-door{flex-direction:column;align-items:stretch}.esc-stats{grid-template-columns:1fr}.esc-pairs{grid-template-columns:1fr}} diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index f39864f..77cf8b6 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -123,6 +123,8 @@ a { color:var(--secondary); } .mf-play { margin-top:.9rem; align-self:flex-start; display:inline-flex; align-items:center; gap:.45rem; font-size:.74rem; font-weight:800; letter-spacing:.08em; color:var(--accent); border:1px solid var(--border-accent); border-radius:8px; padding:.5rem .85rem; text-decoration:none; transition:all .18s; } .mf-card:hover .mf-play { background:var(--accent); color:var(--accent-ink); box-shadow:0 0 16px var(--glow-s); } .mf-play svg { width:14px; height:14px; } +.mf-card-codeescape { --accent:var(--esc-accent); --glow-s:var(--esc-glow); --glow-w:color-mix(in srgb,var(--esc-accent) 16%,transparent); background:repeating-linear-gradient(0deg,rgba(255,255,255,.015) 0 1px,transparent 1px 4px),var(--surface); } +.mf-card-codeescape::after { content:"ACCESS"; position:absolute; right:12px; top:9px; color:var(--esc-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.6; } /* ===== Panels / Widgets ===== */ .mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); } diff --git a/src/main/resources/static/js/codeescape.js b/src/main/resources/static/js/codeescape.js new file mode 100644 index 0000000..2247440 --- /dev/null +++ b/src/main/resources/static/js/codeescape.js @@ -0,0 +1,17 @@ +(function(){'use strict';const view=document.getElementById('escView'),timerEl=document.getElementById('escTimer');let catalog=[],session=null,roomIndex=0,start=0,tickId=null,answers=new Map(),fragments=new Map(),selected=null; +const api=(url,opt)=>fetch('/api/codeescape'+url,{headers:{'Content-Type':'application/json'},...opt}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.message||'Serverfehler');return j}); +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 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 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(roomIndexFACILITY 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/admin/codeescape-room.html b/src/main/resources/templates/admin/codeescape-room.html new file mode 100644 index 0000000..9db5e48 --- /dev/null +++ b/src/main/resources/templates/admin/codeescape-room.html @@ -0,0 +1 @@ +CodeEscape Raum
← Szenario

Rätsel hinzufügen

TypTitelFragment
diff --git a/src/main/resources/templates/admin/codeescape-scenario.html b/src/main/resources/templates/admin/codeescape-scenario.html new file mode 100644 index 0000000..87c0768 --- /dev/null +++ b/src/main/resources/templates/admin/codeescape-scenario.html @@ -0,0 +1 @@ +CodeEscape Szenario
← Szenarien

Raum hinzufügen

diff --git a/src/main/resources/templates/admin/codeescape.html b/src/main/resources/templates/admin/codeescape.html new file mode 100644 index 0000000..f563036 --- /dev/null +++ b/src/main/resources/templates/admin/codeescape.html @@ -0,0 +1 @@ +Admin · CodeEscape
← Admin

CodeEscape · Szenarien

TitelScopeStatusAktion
diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 904c9cf..3b7e2c5 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -48,6 +48,7 @@ 🎯 TechTacular-Fragen verwalten 📜 Syntax-Saga-Missionen verwalten ⚗️ Dev-Lab-Aufgaben verwalten + 🚪 CodeEscape-Szenarien verwalten

Hier entsteht noch Großes... ✨ Bleib dran! diff --git a/src/main/resources/templates/codeescape.html b/src/main/resources/templates/codeescape.html new file mode 100644 index 0000000..272f40b --- /dev/null +++ b/src/main/resources/templates/codeescape.html @@ -0,0 +1 @@ +CodeEscape · Mindflux

← Dashboard
CODEESCAPEFACILITY ACCESS PROTOCOL
--:--
FACILITY WIRD INITIALISIERT …
diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 38e965c..f5b9827 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -135,14 +135,14 @@ ['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'], ['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'], ['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'], - ['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'], + ['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/codeescape'], ['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'], ['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'], ['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'], ['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux'] ]; document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>` - + ${GI[ic]}

${t}

${d}

SPIEL STARTEN diff --git a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java new file mode 100644 index 0000000..18080bb --- /dev/null +++ b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java @@ -0,0 +1,10 @@ +package com.example.mindflux.service; +import com.example.mindflux.dto.*;import com.example.mindflux.model.*;import com.example.mindflux.repository.*;import com.fasterxml.jackson.databind.*;import org.junit.jupiter.api.*;import org.springframework.mock.web.MockHttpSession;import java.util.*;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*; +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 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 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()));} +}