diff --git a/mindfluxfuture.md b/mindfluxfuture.md index 5ae881f..b143176 100644 --- a/mindfluxfuture.md +++ b/mindfluxfuture.md @@ -154,7 +154,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: --- -### Spiel 2: BugHunter (Key: `bughunter`, Migration V12) +### Spiel 2: BugHunter (Key: `bughunter`, Migration V12) ✅ MVP umgesetzt **Konzept:** Der Spieler ist Debugging-Detektiv und arbeitet **Fallakten** ab. Ein Fall („Hunt") ist ein Mini-Projekt aus 2–4 Dateien, das sich falsch verhält; Beweismittel sind simulierte Konsolen-Ausgabe / Stacktrace / Ticket-Text. Pro Bug eine Drei-Phasen-Schleife: **ORTEN** (fehlerhafte Zeile im Tab-Code-Viewer anklicken) → **KLASSIFIZIEREN** (Syntax / Logik / Laufzeit / Sicherheit) → **FIXEN** (korrigierte Zeile aus 4 Kandidaten wählen). Saubere Bugs bauen eine **Combo-Serie** auf. Stufen: Junior / Senior / Principal. @@ -167,7 +167,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: - Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bughunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`. **Datenmodell (`V12__bughunter.sql`):** -- `bughunter_hunts` (id, slug UNIQUE, title, code_language, difficulty DEFAULT 'junior', description, briefing VARCHAR(2000), console_output VARCHAR(4000), sort_order, active) +- `bughunter_hunts` (id, slug UNIQUE, title, code_language, difficulty DEFAULT 'junior', exam_scope, description, briefing VARCHAR(2000), console_output VARCHAR(4000), sort_order, active) - `bughunter_files` (id, hunt_id FK CASCADE, filename, content VARCHAR(8000), sort_order) – eigene Tabelle, damit der Code-Viewer-Inhalt von allen Bugs geteilt wird und im Admin als Textarea editierbar ist - `bughunter_bugs` (id, hunt_id FK CASCADE, points DEFAULT 100, sort_order, payload VARCHAR(8000)) – Payload: `{"file","line","category","evidence","fixOptions":[4],"fixIndex","explanation"}` - Indizes auf beide FKs. @@ -179,11 +179,11 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: **API (`/api/bughunter`):** `GET /hunts`, `GET /session/{slug}`, `POST /check` (`{bugId, phase, reveal, submission}`), `POST /finish` (`{slug, durationSeconds, items:[{bugId, locate:{file,line,attempts,revealed}, classify:{category,…}, fix:{choice,…}}]}` → `{score, baseScore, maxScore, comboBonus, maxCombo, bugsSolved, totalBugs, stars, highscore, newHighscore, plays}`). -**Frontend:** `templates/bug-hunter.html`, `static/js/bughunter.js` (State-Machine `LOBBY → BRIEFING → HUNT {pro Bug: LOCATE → CLASSIFY → FIX → DEBRIEF} → RESULT`), `static/css/bughunter.css` Präfix `bh-`. Layout: **links 60 %** Datei-Tabs + Monospace-Code-Viewer mit Zeilennummern-Gutter (Zeilenklick nur in LOCATE aktiv); **rechts 40 %** Beweis-Karte (Terminal-Style `consoleOutput`, Briefing, Bug-Hinweis), Aktions-Panel (wechselt pro Phase; Kategorie-/Fix-Buttons nutzen das `choice`-Renderer-Muster aus `devlabgame.js`), Combo-Chip + Fortschritt („Bug 2/3"). Debriefing tauscht die Bug-Zeile sichtbar gegen `fixedLine` (blaues Diff-Highlight). +**Frontend:** `templates/bughunter.html`, `static/js/bughunter.js` (State-Machine `LOBBY → BRIEFING → HUNT {pro Bug: LOCATE → CLASSIFY → FIX → DEBRIEF} → RESULT`), `static/css/bughunter.css` Präfix `bh-`. Layout: **links 60 %** Datei-Tabs + Monospace-Code-Viewer mit Zeilennummern-Gutter (Zeilenklick nur in LOCATE aktiv); **rechts 40 %** Beweis-Karte (Terminal-Style `consoleOutput`, Briefing, Bug-Hinweis), Aktions-Panel (wechselt pro Phase; Kategorie-/Fix-Buttons nutzen das `choice`-Renderer-Muster aus `devlabgame.js`), Combo-Chip + Fortschritt („Bug 2/3"). Debriefing tauscht die Bug-Zeile sichtbar gegen `fixedLine` (blaues Diff-Highlight). -**Content & Seeder:** `BugHunterSeeder` liest `resources/bughunter/content.json`; der komplette Bug-Knoten wird wie bei `DevLabSeeder` per `toString()` zum Payload. Startumfang: **6 Fälle** – 3 Junior (1 Datei, 1–2 Bugs: Off-by-one, `=` vs. `==`, fehlendes `break`), 2 Senior (2 Dateien: NPE via Stacktrace, SQL-Injection, Integer-Division), 1 Principal (3 Dateien, Symptom in Datei A, Ursache in Datei B) ≈ 13–15 Bugs. Drei Beispiel-Fälle sind im Design voll ausgearbeitet („Fall #1: Die falsche Rechnung" – Off-by-one in Java; „Fall #2: Das offene Login" – SQL-Injection in PHP; „Fall #3: Der Absturz um Mitternacht" – NPE mit Stacktrace-Lesen), inkl. Code, Fix-Optionen und Erklärungen. +**Content & Seeder:** `BugHunterSeeder` liest `resources/bughunter/content.json`; der komplette Bug-Knoten wird wie bei `DevLabSeeder` per `toString()` zum Payload. Der MVP enthält **6 Fälle mit je einem repräsentativen Bug**: drei Junior-Fälle (Off-by-one, String-Vergleich, fehlendes `break`), zwei Senior-Fälle (NPE via Stacktrace, SQL-Injection) und einen Principal-Fall zur Nebenläufigkeit. Alle enthalten Code, Fix-Optionen und Erklärungen; die Admin-Oberfläche erlaubt die geplante Erweiterung auf mehrere Bugs pro Fall. -**Admin:** `AdminBugHunterController` (`/admin/bug-hunter`), 2 Templates (Fall-Liste + Fall-Detail mit Datei-Textareas und Bug-Payload-JSON). Service validiert beim Speichern, dass `file` im Fall existiert und `line` im Zeilenbereich liegt. +**Admin:** `AdminBugHunterController` (`/admin/bughunter`), 2 Templates (Fall-Liste + Fall-Detail mit Datei-Textareas und Bug-Payload-JSON). Service validiert beim Speichern, dass `file` im Fall existiert und `line` im Zeilenbereich liegt. **Aufwand:** MVP ≈ 4,5 PT. Ausbau 1 (+1 PT): Freitext-Fix statt Optionen (`anyEquals` mit Whitespace-Normalisierung wie Dev-Lab-`cloze`), „Sauber gelöst"-Badge. Ausbau 2 (+1 PT): „Blitzjagd"-Tagesmodus (3 zufällige Junior-Bugs, 90-s-Soft-Timer). @@ -279,7 +279,7 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention 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 +3. **BugHunter (V12) ✅** – Fallakten, serverautoritatives Drei-Phasen-Debugging, Combo-Wertung, Admin-CRUD und sechs Startfälle umgesetzt. 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/BugHunterSeeder.java b/src/main/java/com/example/mindflux/config/BugHunterSeeder.java new file mode 100644 index 0000000..66ed67a --- /dev/null +++ b/src/main/java/com/example/mindflux/config/BugHunterSeeder.java @@ -0,0 +1,31 @@ +package com.example.mindflux.config; + +import com.example.mindflux.model.*; +import com.example.mindflux.repository.BugHunterHuntRepository; +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; + +/** Erstbefüllung der Fallakten. Der vollständige Bug-Knoten bleibt als serverseitiger Payload erhalten. */ +@Component +public class BugHunterSeeder implements CommandLineRunner { + private static final Logger log = LoggerFactory.getLogger(BugHunterSeeder.class); + private final BugHunterHuntRepository hunts; private final ObjectMapper mapper; + public BugHunterSeeder(BugHunterHuntRepository hunts, ObjectMapper mapper) { this.hunts = hunts; this.mapper = mapper; } + @Override @Transactional public void run(String... args) { + if (hunts.count() > 0) return; + try { + JsonNode root = mapper.readTree(new ClassPathResource("bughunter/content.json").getInputStream()); int order = 0, count = 0; + for (JsonNode node : root.path("hunts")) { + BugHunterHunt hunt = new BugHunterHunt(node.path("slug").asText(), node.path("title").asText(), node.path("language").asText("Java"), node.path("difficulty").asText("junior"), node.path("examScope").asText("AP1"), node.path("description").asText(""), node.path("briefing").asText(""), node.path("consoleOutput").asText(""), order++); + int fileOrder = 0; for (JsonNode file : node.path("files")) hunt.addFile(new BugHunterFile(file.path("filename").asText(), file.path("content").asText(), fileOrder++)); + int bugOrder = 0; for (JsonNode bug : node.path("bugs")) { String payload = mapper.writeValueAsString(bug); if (payload.length() <= 8000) { hunt.addBug(new BugHunterBug(bug.path("points").asInt(100), bugOrder++, payload)); count++; } } + if (!hunt.getFiles().isEmpty() && !hunt.getBugs().isEmpty()) hunts.save(hunt); + } + log.info("BugHunter: {} Fallakten geladen.", count); + } catch (Exception ex) { log.error("BugHunter-Seed fehlgeschlagen", ex); } + } +} diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index bcab29c..7d70c61 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/**", "/api/codeescape/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/controller/AdminBugHunterController.java b/src/main/java/com/example/mindflux/controller/AdminBugHunterController.java new file mode 100644 index 0000000..585c470 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/AdminBugHunterController.java @@ -0,0 +1,21 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.service.BugHunterService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller @RequestMapping("/admin/bughunter") +public class AdminBugHunterController { + private final BugHunterService service; + public AdminBugHunterController(BugHunterService service) { this.service = service; } + @GetMapping public String list(Model model) { model.addAttribute("hunts", service.all()); return "admin/bughunter"; } + @PostMapping("/add") public String add(@RequestParam String title, @RequestParam String language, @RequestParam String difficulty, @RequestParam String examScope, @RequestParam(required = false) String description, @RequestParam(required = false) String briefing, @RequestParam(required = false) String consoleOutput) { Long id = service.addHunt(title, language, difficulty, examScope, description, briefing, consoleOutput); return id == null ? "redirect:/admin/bughunter" : "redirect:/admin/bughunter/" + id; } + @GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { model.addAttribute("hunt", service.hunt(id)); return "admin/bughunter-hunt"; } + @PostMapping("/{id}/files") public String addFile(@PathVariable Long id, @RequestParam String filename, @RequestParam String content) { service.addFile(id, filename, content); return "redirect:/admin/bughunter/" + id; } + @PostMapping("/{id}/bugs") public String addBug(@PathVariable Long id, @RequestParam(defaultValue = "100") int points, @RequestParam String payload) { service.addBug(id, points, payload); return "redirect:/admin/bughunter/" + id; } + @PostMapping("/{id}/toggle") public String toggle(@PathVariable Long id) { service.toggle(id); return "redirect:/admin/bughunter"; } + @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { service.deleteHunt(id); return "redirect:/admin/bughunter"; } + @PostMapping("/files/{id}/delete") public String deleteFile(@PathVariable Long id, @RequestParam Long huntId) { service.deleteFile(id); return "redirect:/admin/bughunter/" + huntId; } + @PostMapping("/bugs/{id}/delete") public String deleteBug(@PathVariable Long id, @RequestParam Long huntId) { service.deleteBug(id); return "redirect:/admin/bughunter/" + huntId; } +} diff --git a/src/main/java/com/example/mindflux/controller/BugHunterApiController.java b/src/main/java/com/example/mindflux/controller/BugHunterApiController.java new file mode 100644 index 0000000..4f198f5 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/BugHunterApiController.java @@ -0,0 +1,18 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.dto.*; +import com.example.mindflux.service.BugHunterService; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpSession; +import java.security.Principal; + +@RestController @RequestMapping("/api/bughunter") +public class BugHunterApiController { + private final BugHunterService service; + public BugHunterApiController(BugHunterService service) { this.service = service; } + @GetMapping("/hunts") public JsonNode catalog() { return service.catalog(); } + @GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug, HttpSession httpSession) { return service.session(slug, httpSession); } + @PostMapping("/check") public JsonNode check(@RequestBody BugHunterCheckRequest request, HttpSession httpSession) { return service.check(request.getBugId(), request.getPhase(), request.isReveal(), request.getSubmission(), httpSession); } + @PostMapping("/finish") public BugHunterResult finish(@RequestBody BugHunterFinishRequest request, Principal principal, HttpSession httpSession) { return service.finish(principal.getName(), request, httpSession); } +} diff --git a/src/main/java/com/example/mindflux/controller/BugHunterController.java b/src/main/java/com/example/mindflux/controller/BugHunterController.java new file mode 100644 index 0000000..d41d82b --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/BugHunterController.java @@ -0,0 +1,4 @@ +package com.example.mindflux.controller; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +@Controller public class BugHunterController { @GetMapping("/bughunter") public String page() { return "bughunter"; } } diff --git a/src/main/java/com/example/mindflux/dto/BugHunterCheckRequest.java b/src/main/java/com/example/mindflux/dto/BugHunterCheckRequest.java new file mode 100644 index 0000000..c4d7335 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/BugHunterCheckRequest.java @@ -0,0 +1,14 @@ +package com.example.mindflux.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +public class BugHunterCheckRequest { + private Long bugId; + private String phase; + private boolean reveal; + private JsonNode submission; + public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; } + public String getPhase() { return phase; } public void setPhase(String value) { phase = value; } + public boolean isReveal() { return reveal; } public void setReveal(boolean value) { reveal = value; } + public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; } +} diff --git a/src/main/java/com/example/mindflux/dto/BugHunterFinishRequest.java b/src/main/java/com/example/mindflux/dto/BugHunterFinishRequest.java new file mode 100644 index 0000000..4342fdd --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/BugHunterFinishRequest.java @@ -0,0 +1,32 @@ +package com.example.mindflux.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +/** Rohantworten eines BugHunter-Durchlaufs; die Wertung wird komplett serverseitig rekonstruiert. */ +public class BugHunterFinishRequest { + private String slug; + private int durationSeconds; + private List items; + public String getSlug() { return slug; } public void setSlug(String value) { slug = value; } + public int getDurationSeconds() { return durationSeconds; } public void setDurationSeconds(int value) { durationSeconds = value; } + public List getItems() { return items; } public void setItems(List value) { items = value; } + public static class Item { + private Long bugId; + private Phase locate; + private Phase classify; + private Phase fix; + public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; } + public Phase getLocate() { return locate; } public void setLocate(Phase value) { locate = value; } + public Phase getClassify() { return classify; } public void setClassify(Phase value) { classify = value; } + public Phase getFix() { return fix; } public void setFix(Phase value) { fix = value; } + } + public static class Phase { + private JsonNode submission; + private int attempts; + private boolean revealed; + public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; } + public int getAttempts() { return attempts; } public void setAttempts(int value) { attempts = value; } + public boolean isRevealed() { return revealed; } public void setRevealed(boolean value) { revealed = value; } + } +} diff --git a/src/main/java/com/example/mindflux/dto/BugHunterResult.java b/src/main/java/com/example/mindflux/dto/BugHunterResult.java new file mode 100644 index 0000000..c2fa512 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/BugHunterResult.java @@ -0,0 +1,5 @@ +package com.example.mindflux.dto; + +public record BugHunterResult(int score, int baseScore, int maxScore, int comboBonus, int maxCombo, + int bugsSolved, int totalBugs, int stars, int highscore, + boolean newHighscore, long plays) { } diff --git a/src/main/java/com/example/mindflux/model/BugHunterBug.java b/src/main/java/com/example/mindflux/model/BugHunterBug.java new file mode 100644 index 0000000..70d0b9b --- /dev/null +++ b/src/main/java/com/example/mindflux/model/BugHunterBug.java @@ -0,0 +1,19 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +@Entity @Table(name = "bughunter_bugs") +public class BugHunterBug { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt; + private int points = 100; + @Column(name = "sort_order") private int sortOrder; + @Column(length = 8000) private String payload; + public BugHunterBug() { } + public BugHunterBug(int points, int sortOrder, String payload) { this.points = points; this.sortOrder = sortOrder; this.payload = payload; } + public Long getId() { return id; } public void setId(Long value) { id = value; } + public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; } + public int getPoints() { return points; } public void setPoints(int value) { points = value; } + public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; } + public String getPayload() { return payload; } public void setPayload(String value) { payload = value; } +} diff --git a/src/main/java/com/example/mindflux/model/BugHunterFile.java b/src/main/java/com/example/mindflux/model/BugHunterFile.java new file mode 100644 index 0000000..585a641 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/BugHunterFile.java @@ -0,0 +1,19 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +@Entity @Table(name = "bughunter_files") +public class BugHunterFile { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt; + private String filename; + @Column(length = 8000) private String content; + @Column(name = "sort_order") private int sortOrder; + public BugHunterFile() { } + public BugHunterFile(String filename, String content, int sortOrder) { this.filename = filename; this.content = content; this.sortOrder = sortOrder; } + public Long getId() { return id; } public void setId(Long value) { id = value; } + public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; } + public String getFilename() { return filename; } public void setFilename(String value) { filename = value; } + public String getContent() { return content; } public void setContent(String value) { content = value; } + public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; } +} diff --git a/src/main/java/com/example/mindflux/model/BugHunterHunt.java b/src/main/java/com/example/mindflux/model/BugHunterHunt.java new file mode 100644 index 0000000..f54a1c1 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/BugHunterHunt.java @@ -0,0 +1,45 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.util.*; + +@Entity @Table(name = "bughunter_hunts") +public class BugHunterHunt { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false, unique = true) private String slug; + @Column(nullable = false) private String title; + @Column(name = "code_language") private String codeLanguage; + private String difficulty; + @Column(name = "exam_scope") private String examScope = "AP1"; + private String description; + @Column(length = 2000) private String briefing; + @Column(name = "console_output", length = 4000) private String consoleOutput; + @Column(name = "sort_order") private int sortOrder; + private boolean active = true; + @OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC, id ASC") private List files = new ArrayList<>(); + @OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC, id ASC") private List bugs = new ArrayList<>(); + + public BugHunterHunt() { } + public BugHunterHunt(String slug, String title, String codeLanguage, String difficulty, String examScope, + String description, String briefing, String consoleOutput, int sortOrder) { + this.slug = slug; this.title = title; this.codeLanguage = codeLanguage; this.difficulty = difficulty; + this.examScope = examScope; this.description = description; this.briefing = briefing; + this.consoleOutput = consoleOutput; this.sortOrder = sortOrder; + } + public void addFile(BugHunterFile file) { file.setHunt(this); files.add(file); } + public void addBug(BugHunterBug bug) { bug.setHunt(this); bugs.add(bug); } + public Long getId() { return id; } public void setId(Long id) { this.id = id; } + public String getSlug() { return slug; } public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } + public String getCodeLanguage() { return codeLanguage; } public void setCodeLanguage(String value) { codeLanguage = value; } + public String getDifficulty() { return difficulty; } public void setDifficulty(String value) { difficulty = value; } + public String getExamScope() { return examScope; } public void setExamScope(String value) { examScope = value; } + public String getDescription() { return description; } public void setDescription(String value) { description = value; } + public String getBriefing() { return briefing; } public void setBriefing(String value) { briefing = value; } + public String getConsoleOutput() { return consoleOutput; } public void setConsoleOutput(String value) { consoleOutput = value; } + public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; } + public boolean isActive() { return active; } public void setActive(boolean value) { active = value; } + public List getFiles() { return files; } public List getBugs() { return bugs; } +} diff --git a/src/main/java/com/example/mindflux/repository/BugHunterBugRepository.java b/src/main/java/com/example/mindflux/repository/BugHunterBugRepository.java new file mode 100644 index 0000000..560e870 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/BugHunterBugRepository.java @@ -0,0 +1,4 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.BugHunterBug; +import org.springframework.data.jpa.repository.JpaRepository; +public interface BugHunterBugRepository extends JpaRepository { long countByHuntId(Long huntId); } diff --git a/src/main/java/com/example/mindflux/repository/BugHunterFileRepository.java b/src/main/java/com/example/mindflux/repository/BugHunterFileRepository.java new file mode 100644 index 0000000..bae60e1 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/BugHunterFileRepository.java @@ -0,0 +1,4 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.BugHunterFile; +import org.springframework.data.jpa.repository.JpaRepository; +public interface BugHunterFileRepository extends JpaRepository { long countByHuntId(Long huntId); } diff --git a/src/main/java/com/example/mindflux/repository/BugHunterHuntRepository.java b/src/main/java/com/example/mindflux/repository/BugHunterHuntRepository.java new file mode 100644 index 0000000..59c1fd4 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/BugHunterHuntRepository.java @@ -0,0 +1,10 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.BugHunterHunt; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.*; +public interface BugHunterHuntRepository extends JpaRepository { + List findAllByActiveTrueOrderBySortOrderAscIdAsc(); + List findAllByOrderBySortOrderAscIdAsc(); + Optional findBySlug(String slug); + boolean existsBySlug(String slug); +} diff --git a/src/main/java/com/example/mindflux/service/BugHunterService.java b/src/main/java/com/example/mindflux/service/BugHunterService.java new file mode 100644 index 0000000..ac49659 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/BugHunterService.java @@ -0,0 +1,176 @@ +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.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.servlet.http.HttpSession; +import java.util.*; + +/** Serverseitige Drei-Phasen-Ermittlung für BugHunter. Lösungsdaten bleiben im Bug-Payload. */ +@Service +public class BugHunterService { + private static final String GAME = "bughunter"; + private static final Set CATEGORIES = Set.of("syntax", "logik", "laufzeit", "sicherheit"); + private static final Set SCOPES = Set.of("AP1", "AP2_FIAE", "AP2_FISI", "CROSS"); + private static final int MAX_DURATION_SECONDS = 14_400; + private final BugHunterHuntRepository hunts; + private final BugHunterFileRepository files; + private final BugHunterBugRepository bugs; + private final GameScoreService scores; + private final ObjectMapper mapper; + private final Random random = new Random(); + + public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs, + GameScoreService scores, ObjectMapper mapper) { + this.hunts = hunts; this.files = files; this.bugs = bugs; this.scores = scores; this.mapper = mapper; + } + + @Transactional(readOnly = true) + public JsonNode catalog() { + ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts"); + for (BugHunterHunt hunt : hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc()) { + if (hunt.getBugs().isEmpty() || hunt.getFiles().isEmpty()) continue; + ObjectNode row = result.addObject(); row.put("slug", hunt.getSlug()); row.put("title", hunt.getTitle()); + row.put("language", hunt.getCodeLanguage()); row.put("difficulty", hunt.getDifficulty()); + row.put("examScope", hunt.getExamScope()); row.put("description", hunt.getDescription()); + row.put("bugCount", hunt.getBugs().size()); row.put("fileCount", hunt.getFiles().size()); + } + return root; + } + + @Transactional(readOnly = true) + public JsonNode session(String slug, HttpSession httpSession) { + ObjectNode root = mapper.createObjectNode(); + BugHunterHunt hunt = hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElse(null); + if (hunt == null) { root.put("found", false); return root; } + root.put("found", true); root.put("slug", hunt.getSlug()); root.put("title", hunt.getTitle()); + root.put("language", hunt.getCodeLanguage()); root.put("difficulty", hunt.getDifficulty()); root.put("examScope", hunt.getExamScope()); + root.put("description", nullToEmpty(hunt.getDescription())); root.put("briefing", nullToEmpty(hunt.getBriefing())); + root.put("consoleOutput", nullToEmpty(hunt.getConsoleOutput())); + ArrayNode fs = root.putArray("files"); + for (BugHunterFile file : hunt.getFiles()) { ObjectNode f = fs.addObject(); f.put("filename", file.getFilename()); ArrayNode lines = f.putArray("lines"); for (String line : file.getContent().split("\\R", -1)) lines.add(line); } + ArrayNode bs = root.putArray("bugs"); int maxScore = 0; + for (BugHunterBug bug : hunt.getBugs()) { JsonNode payload = parse(bug.getPayload()); if (payload == null) continue; ObjectNode b = bs.addObject(); b.put("bugId", bug.getId()); b.put("points", bug.getPoints()); b.put("evidence", payload.path("evidence").asText("")); maxScore += bug.getPoints(); } + root.put("totalBugs", bs.size()); root.put("maxScore", maxScore); + Set bugIds = new HashSet<>(); bs.forEach(b -> bugIds.add(b.path("bugId").asLong())); + httpSession.setAttribute("bughunter.run", new RunState(hunt.getSlug(), bugIds, System.currentTimeMillis())); + return root; + } + + @Transactional(readOnly = true) + public JsonNode check(Long bugId, String phase, boolean reveal, JsonNode submission, HttpSession httpSession) { + if (!isPhase(phase)) throw new IllegalArgumentException("Unbekannte Ermittlungsphase."); + RunState run = run(httpSession); + if (bugId == null || !run.bugIds.contains(bugId)) throw new IllegalArgumentException("Bug gehört nicht zu dieser Fallakte."); + BugHunterBug bug = bugId == null ? null : bugs.findById(bugId).orElse(null); + JsonNode payload = bug == null ? null : parse(bug.getPayload()); + if (payload == null) throw new IllegalArgumentException("Fallakte nicht gefunden."); + PhaseState phaseState = run.state(bugId, phase); + if ((phase.equals("classify") && !run.state(bugId, "locate").done) || (phase.equals("fix") && !run.state(bugId, "classify").done)) throw new IllegalArgumentException("Schließe zuerst die vorherige Ermittlungsphase ab."); + if (phaseState.done) throw new IllegalArgumentException("Diese Ermittlungsphase ist bereits abgeschlossen."); + if (!reveal && phaseState.attempts >= 3) throw new IllegalArgumentException("Nach drei Fehlversuchen kannst du die Auflösung anzeigen."); + JsonNode sub = submission == null ? mapper.createObjectNode() : submission; + boolean correct = switch (phase) { case "locate" -> locationMatches(payload, sub); case "classify" -> categoryMatches(payload, sub); case "fix" -> fixMatches(payload, sub); default -> false; }; + if (reveal) { phaseState.revealed = true; phaseState.done = true; } + else { phaseState.attempts++; phaseState.submission = sub.toString(); if (correct) phaseState.done = true; } + ObjectNode result = mapper.createObjectNode(); result.put("correct", correct); result.put("resolved", correct || reveal); result.put("phase", phase); + if (correct && payload.hasNonNull("successText")) result.put("successText", payload.get("successText").asText()); + if (correct || reveal) addPhaseResolution(result, payload, phase); + return result; + } + + @Transactional + public BugHunterResult finish(String username, BugHunterFinishRequest request, HttpSession httpSession) { + if (request == null || request.getSlug() == null || request.getSlug().isBlank()) throw new IllegalArgumentException("Fallakte fehlt."); + if (request.getDurationSeconds() < 1 || request.getDurationSeconds() > MAX_DURATION_SECONDS) throw new IllegalArgumentException("Unplausible Spieldauer."); + BugHunterHunt hunt = hunts.findBySlug(request.getSlug()).filter(BugHunterHunt::isActive) + .orElseThrow(() -> new IllegalArgumentException("Fallakte nicht gefunden.")); + RunState run = run(httpSession); + if (!hunt.getSlug().equals(run.slug)) throw new IllegalArgumentException("Die Fallakte wurde nicht angefordert."); + List submitted = request.getItems() == null ? List.of() : request.getItems(); + if (submitted.size() > hunt.getBugs().size()) throw new IllegalArgumentException("Zu viele Bugs eingereicht."); + Map byId = new HashMap<>(); + Set allowed = new HashSet<>(); for (BugHunterBug bug : hunt.getBugs()) allowed.add(bug.getId()); + for (BugHunterFinishRequest.Item item : submitted) { + if (item == null || item.getBugId() == null || !allowed.contains(item.getBugId()) || byId.putIfAbsent(item.getBugId(), item) != null) + throw new IllegalArgumentException("Ungültige oder doppelte Bug-ID."); + } + int base = 0, max = 0, solved = 0, combo = 0, maxCombo = 0, comboBonus = 0; + for (BugHunterBug bug : hunt.getBugs()) { + max += bug.getPoints(); BugHunterFinishRequest.Item item = byId.get(bug.getId()); + if (item == null) { combo = 0; continue; } + JsonNode payload = parse(bug.getPayload()); if (payload == null) { combo = 0; continue; } + PhaseState located = run.state(bug.getId(), "locate"), classified = run.state(bug.getId(), "classify"), fixed = run.state(bug.getId(), "fix"); + boolean locate = phaseCorrect(payload, "locate", located); + boolean classify = phaseCorrect(payload, "classify", classified); + boolean fix = phaseCorrect(payload, "fix", fixed); + base += award(bug.getPoints(), .4, located, locate); + base += award(bug.getPoints(), .2, classified, classify); + base += award(bug.getPoints(), .4, fixed, fix); + if (locate && classify && fix) solved++; + if (isClean(located, locate) && isClean(classified, classify) && isClean(fixed, fix)) { + combo++; maxCombo = Math.max(maxCombo, combo); comboBonus += (int) Math.round(bug.getPoints() * (Math.min(combo, 4) - 1) * .1); + } else combo = 0; + } + int stars = stars(base, max); ScoreResult saved = scores.recordScore(username, GAME, hunt.getSlug(), base + comboBonus, solved, request.getDurationSeconds()); + httpSession.removeAttribute("bughunter.run"); + return new BugHunterResult(base + comboBonus, base, max, comboBonus, maxCombo, solved, hunt.getBugs().size(), stars, saved.getHighscore(), saved.isNewHighscore(), saved.getPlays()); + } + + @Transactional(readOnly = true) public List all() { return hunts.findAllByOrderBySortOrderAscIdAsc(); } + @Transactional(readOnly = true) public BugHunterHunt hunt(Long id) { BugHunterHunt hunt = hunts.findById(id).orElse(null); if (hunt != null) { hunt.getFiles().size(); hunt.getBugs().size(); } return hunt; } + @Transactional + public Long addHunt(String title, String language, String difficulty, String examScope, String description, String briefing, String consoleOutput) { + if (blank(title)) return null; String clean = title.trim(); + BugHunterHunt hunt = new BugHunterHunt(uniqueSlug(clean), clean, cap(language, 50, "Java"), normalizeDifficulty(difficulty), normalizeScope(examScope), cap(description, 1200, ""), cap(briefing, 2000, ""), cap(consoleOutput, 4000, ""), (int) hunts.count() + 1); + return hunts.save(hunt).getId(); + } + @Transactional public void addFile(Long huntId, String filename, String content) { hunts.findById(huntId).ifPresent(hunt -> { if (!blank(filename) && content != null && content.length() <= 8000) { hunt.addFile(new BugHunterFile(cap(filename, 255, ""), content, hunt.getFiles().size())); hunts.save(hunt); } }); } + @Transactional public void addBug(Long huntId, int points, String payload) { hunts.findById(huntId).ifPresent(hunt -> { if (validPayload(hunt, payload)) { hunt.addBug(new BugHunterBug(normalizePoints(points), hunt.getBugs().size(), payload.trim())); hunts.save(hunt); } }); } + @Transactional public void deleteHunt(Long id) { hunts.deleteById(id); } + @Transactional public void deleteFile(Long id) { files.deleteById(id); } + @Transactional public void deleteBug(Long id) { bugs.deleteById(id); } + @Transactional public void toggle(Long id) { hunts.findById(id).ifPresent(h -> h.setActive(!h.isActive())); } + + private boolean validPayload(BugHunterHunt hunt, String raw) { + JsonNode p = parse(raw); if (p == null || raw.length() > 8000 || blank(p.path("file").asText()) || p.path("line").asInt(0) < 1 || !CATEGORIES.contains(p.path("category").asText()) || !p.path("fixOptions").isArray()) return false; + BugHunterFile file = hunt.getFiles().stream().filter(f -> f.getFilename().equals(p.path("file").asText())).findFirst().orElse(null); + int index = p.path("fixIndex").asInt(-1); return file != null && p.path("line").asInt() <= file.getContent().split("\\R", -1).length && index >= 0 && index < p.path("fixOptions").size(); + } + private boolean phaseCorrect(JsonNode p, String phase, PhaseState answer) { return answer != null && !answer.revealed && answer.submission != null && matches(p, phase, parse(answer.submission)); } + private boolean isClean(PhaseState answer, boolean correct) { return correct && answer != null && answer.attempts == 1 && !answer.revealed; } + private int award(int points, double weight, PhaseState answer, boolean correct) { if (!correct || answer == null) return 0; int attempts = Math.max(1, Math.min(answer.attempts, 3)); double factor = attempts == 1 ? 1 : attempts == 2 ? .6 : .3; return (int) Math.round(points * weight * factor); } + private boolean matches(JsonNode p, String phase, JsonNode sub) { return switch (phase) { case "locate" -> locationMatches(p, sub); case "classify" -> categoryMatches(p, sub); case "fix" -> fixMatches(p, sub); default -> false; }; } + private boolean locationMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("file").asText(), sub.path("file").asText()) && p.path("line").asInt(-1) == sub.path("line").asInt(-2); } + private boolean categoryMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("category").asText(), sub.path("category").asText()); } + private boolean fixMatches(JsonNode p, JsonNode sub) { int index = p.path("fixIndex").asInt(-1); JsonNode options = p.path("fixOptions"); return index >= 0 && index < options.size() && textEquals(options.get(index).asText(), sub.path("choice").asText()); } + private void addPhaseResolution(ObjectNode target, JsonNode p, String phase) { + if (phase.equals("locate")) { ObjectNode location = target.putObject("location"); location.put("file", p.path("file").asText()); location.put("line", p.path("line").asInt()); } + else if (phase.equals("classify")) { target.put("category", p.path("category").asText()); ArrayNode options = target.putArray("fixOptions"); List shuffled = new ArrayList<>(); p.path("fixOptions").forEach(o -> shuffled.add(o.asText())); Collections.shuffle(shuffled, random); shuffled.forEach(options::add); } + else { target.put("fixedLine", p.path("fixedLine").asText()); target.put("explanation", p.path("explanation").asText()); } + } + private JsonNode parse(String raw) { try { return raw == null || raw.isBlank() ? null : mapper.readTree(raw); } catch (Exception e) { return null; } } + private String uniqueSlug(String title) { String base = title.toLowerCase().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss").replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); if (base.isBlank()) base = "hunt"; String result = base; int n = 2; while (hunts.existsBySlug(result)) result = base + "-" + n++; return result; } + private int stars(int score, int max) { if (max <= 0 || score <= 0) return 0; double ratio = (double) score / max; return ratio >= .8 ? 3 : ratio >= .5 ? 2 : 1; } + private int normalizePoints(int value) { return value <= 0 ? 100 : Math.min(value, 1000); } + private String normalizeDifficulty(String value) { return Set.of("junior", "senior", "principal").contains(value) ? value : "junior"; } + private String normalizeScope(String value) { return SCOPES.contains(value) ? value : "AP1"; } + private boolean isPhase(String value) { return Set.of("locate", "classify", "fix").contains(value); } + private boolean blank(String value) { return value == null || value.isBlank(); } + private String cap(String value, int max, String fallback) { if (value == null || value.isBlank()) return fallback; String clean = value.trim(); return clean.length() <= max ? clean : clean.substring(0, max); } + private String nullToEmpty(String value) { return value == null ? "" : value; } + private boolean textEquals(String first, String second) { return normalize(first).equalsIgnoreCase(normalize(second)); } + private String normalize(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " "); } + private RunState run(HttpSession session) { Object value = session.getAttribute("bughunter.run"); if (!(value instanceof RunState run)) throw new IllegalArgumentException("Fordere zuerst eine Fallakte an."); return run; } + public static class RunState { + final String slug; final Set bugIds; final long startedAt; final Map> phases = new HashMap<>(); + RunState(String slug, Set bugIds, long startedAt) { this.slug = slug; this.bugIds = Set.copyOf(bugIds); this.startedAt = startedAt; } + PhaseState state(Long bugId, String phase) { return phases.computeIfAbsent(bugId, ignored -> new HashMap<>()).computeIfAbsent(phase, ignored -> new PhaseState()); } + } + static class PhaseState { int attempts; boolean revealed; boolean done; String submission; } +} diff --git a/src/main/resources/bughunter/content.json b/src/main/resources/bughunter/content.json new file mode 100644 index 0000000..b95cdd0 --- /dev/null +++ b/src/main/resources/bughunter/content.json @@ -0,0 +1,43 @@ +{ + "hunts": [ + { + "slug": "falsche-rechnung", "title": "Fall #1: Die falsche Rechnung", "language": "Java", "difficulty": "junior", "examScope": "AP1", + "description": "Eine Rechnung liefert im letzten Monat einen falschen Wert.", "briefing": "Die Monatsauswertung soll alle zwölf Monate addieren. Das Ticket nennt nur einen fehlenden Dezemberwert.", "consoleOutput": "[report] Summe 2025: 11 Monate verarbeitet\n[warn] Erwartet: 12 Monatswerte", + "files": [{"filename":"Jahresreport.java","content":"public class Jahresreport {\n static int summe(int[] monate) {\n int total = 0;\n for (int i = 0; i <= monate.length; i++) {\n total += monate[i];\n }\n return total;\n }\n}"}], + "bugs": [{"points":100,"file":"Jahresreport.java","line":4,"category":"logik","evidence":"Die Schleife greift nach dem letzten Array-Element zu.","fixOptions":["for (int i = 0; i < monate.length; i++) {","for (int i = 1; i <= monate.length; i++) {","for (int i = 0; i <= monate.length - 1; i += 2) {","for (int i = monate.length; i >= 0; i--) {"],"fixIndex":0,"fixedLine":" for (int i = 0; i < monate.length; i++) {","explanation":"Bei Arrays ist der letzte gültige Index length - 1. Mit < length werden alle Elemente genau einmal verarbeitet."}] + }, + { + "slug": "ampel-bleibt-rot", "title": "Fall #2: Die sture Ampel", "language": "Java", "difficulty": "junior", "examScope": "AP1", + "description": "Die Ampel zeigt immer Rot – selbst bei einer grünen Konfiguration.", "briefing": "Im Schulungsprojekt wird der Status aus einer Konfigurationsdatei gelesen.", "consoleOutput": "Status aus config: green\nUI-Ausgabe: ROT", + "files": [{"filename":"Ampel.java","content":"public class Ampel {\n static String anzeigen(String status) {\n if (status = \"green\") {\n return \"GRÜN\";\n }\n return \"ROT\";\n }\n}"}], + "bugs": [{"points":100,"file":"Ampel.java","line":3,"category":"syntax","evidence":"Der Vergleich verändert den Wert statt zwei Werte zu vergleichen.","fixOptions":["if (\"green\".equals(status)) {","if (status := \"green\") {","if (status.equals = \"green\") {","if (status =! \"green\") {"],"fixIndex":0,"fixedLine":" if (\"green\".equals(status)) {","explanation":"equals vergleicht Zeichenketten. Die Literalkonstante links verhindert zusätzlich eine NullPointerException."}] + }, + { + "slug": "menue-fallt-durch", "title": "Fall #3: Das fallende Menü", "language": "Java", "difficulty": "junior", "examScope": "AP1", + "description": "Der Punkt ‚Status‘ öffnet unerwartet den Abmelde-Dialog.", "briefing": "Ein Azubi meldet einen Fehler im Konsolenmenü. Prüfe die Verzweigungen.", "consoleOutput": "Eingabe: 2\nStatus: OK\nAbmeldung gestartet", + "files": [{"filename":"Menue.java","content":"switch (auswahl) {\n case 1:\n System.out.println(\"Start\");\n break;\n case 2:\n System.out.println(\"Status: OK\");\n case 3:\n System.out.println(\"Abmeldung gestartet\");\n break;\n default:\n System.out.println(\"Ungültig\");\n}"}], + "bugs": [{"points":100,"file":"Menue.java","line":6,"category":"logik","evidence":"Nach dem Status-Fall wird der nächste case ebenfalls ausgeführt.","fixOptions":[" break;"," continue;"," return false;"," case 2: break;"],"fixIndex":0,"fixedLine":" break;","explanation":"Ohne break fällt ein switch in den nächsten Fall durch (fall-through)."}] + }, + { + "slug": "absturz-um-mitternacht", "title": "Fall #4: Der Absturz um Mitternacht", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE", + "description": "Ein nächtlicher Job stürzt sporadisch ab.", "briefing": "Der Job läuft um 00:00 Uhr. Der Stacktrace zeigt auf die Formatierung des letzten Ereignisses.", "consoleOutput": "java.lang.NullPointerException: Cannot invoke \"String.toUpperCase()\"\n at Nachtjob.letztesEreignis(Nachtjob.java:5)\n at Nachtjob.main(Nachtjob.java:10)", + "files": [{"filename":"Nachtjob.java","content":"public class Nachtjob {\n static String letztesEreignis(String event) {\n if (event == null) {\n System.out.println(\"Kein Ereignis vorhanden\");\n }\n return event.toUpperCase();\n }\n\n public static void main(String[] args) {\n System.out.println(letztesEreignis(null));\n }\n}"}], + "bugs": [{"points":150,"file":"Nachtjob.java","line":5,"category":"laufzeit","evidence":"Der Stacktrace verweist direkt auf toUpperCase in Zeile 5.","fixOptions":[" return \"KEIN EREIGNIS\";"," event = event.toUpperCase();"," throw new NullPointerException();"," System.out.println(event.toUpperCase());"],"fixIndex":0,"fixedLine":" return \"KEIN EREIGNIS\";","explanation":"Nach der Null-Prüfung muss die Methode einen sicheren Ersatzwert zurückgeben, bevor event verwendet wird."}] + }, + { + "slug": "offenes-login", "title": "Fall #5: Das offene Login", "language": "PHP", "difficulty": "senior", "examScope": "AP2_FIAE", + "description": "Ein Sicherheitscheck meldet eine manipulierbare Login-Abfrage.", "briefing": "Die Eingabe eines Benutzernamens landet direkt in der SQL-Abfrage. Finde die Schwachstelle und wähle den sicheren Fix.", "consoleOutput": "Scanner: CWE-89 SQL Injection\nTest-Eingabe: ' OR '1'='1", + "files": [{"filename":"login.php","content":"query($sql)->fetch();\n}\n?>"}], + "bugs": [{"points":150,"file":"login.php","line":3,"category":"sicherheit","evidence":"Der externe Wert $name wird in einen SQL-String interpoliert.","fixOptions":[" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);"," $name = addslashes($name); $sql = \"SELECT id FROM users WHERE name = '$name'\";"," $sql = \"SELECT id FROM users WHERE name LIKE '%$name%'\";"," $sql = \"SELECT id FROM users WHERE name = \" . $name;"],"fixIndex":0,"fixedLine":" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);","explanation":"Prepared Statements trennen SQL-Code und Nutzdaten. Escaping allein ist kein zuverlässiger Schutz."}] + }, + { + "slug": "geteilte-liste", "title": "Fall #6: Die geteilte Liste", "language": "Java", "difficulty": "principal", "examScope": "AP2_FIAE", + "description": "Parallele Worker verlieren gelegentlich Aufträge aus ihrer Queue.", "briefing": "Zwei Worker greifen gleichzeitig auf die gemeinsame Auftragsliste zu. Die Ursache liegt nicht dort, wo der Fehler sichtbar wird.", "consoleOutput": "Exception in thread worker-2 java.util.ConcurrentModificationException\n at java.base/java.util.ArrayList$Itr.next\n at Worker.run(Worker.java:6)", + "files": [ + {"filename":"Worker.java","content":"public class Worker implements Runnable {\n private final Auftragsliste liste;\n public Worker(Auftragsliste liste) { this.liste = liste; }\n public void run() {\n for (String job : liste.jobs()) {\n System.out.println(job);\n }\n }\n}"}, + {"filename":"Auftragsliste.java","content":"import java.util.*;\npublic class Auftragsliste {\n private final List jobs = new ArrayList<>();\n public void add(String job) { jobs.add(job); }\n public List jobs() { return jobs; }\n}"} + ], + "bugs": [{"points":200,"file":"Auftragsliste.java","line":5,"category":"sicherheit","evidence":"Die interne, veränderbare Liste wird direkt an parallele Aufrufer zurückgegeben.","fixOptions":[" public List jobs() { return List.copyOf(jobs); }"," public List jobs() { return new ArrayList<>(); }"," public List jobs() { return null; }"," public List jobs() { jobs.clear(); return jobs; }"],"fixIndex":0,"fixedLine":" public List jobs() { return List.copyOf(jobs); }","explanation":"Eine unveränderliche Momentaufnahme verhindert, dass externe Aufrufer die interne Liste gleichzeitig verändern. Für echte gemeinsame Mutation wäre zusätzlich eine threadsichere Datenstruktur nötig."}] + } + ] +} diff --git a/src/main/resources/db/migration/V12__bughunter.sql b/src/main/resources/db/migration/V12__bughunter.sql new file mode 100644 index 0000000..88fb492 --- /dev/null +++ b/src/main/resources/db/migration/V12__bughunter.sql @@ -0,0 +1,33 @@ +CREATE TABLE bughunter_hunts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + code_language VARCHAR(50) NOT NULL DEFAULT 'Java', + difficulty VARCHAR(30) NOT NULL DEFAULT 'junior', + exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1', + description VARCHAR(1200), + briefing VARCHAR(2000), + console_output VARCHAR(4000), + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT chk_bughunter_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS')) +); + +CREATE TABLE bughunter_files ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + content VARCHAR(8000) NOT NULL, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE bughunter_bugs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE, + points INT NOT NULL DEFAULT 100, + sort_order INT NOT NULL DEFAULT 0, + payload VARCHAR(8000) NOT NULL +); + +CREATE INDEX idx_bughunter_files_hunt ON bughunter_files(hunt_id); +CREATE INDEX idx_bughunter_bugs_hunt ON bughunter_bugs(hunt_id); diff --git a/src/main/resources/static/css/bughunter.css b/src/main/resources/static/css/bughunter.css new file mode 100644 index 0000000..ae2a2d6 --- /dev/null +++ b/src/main/resources/static/css/bughunter.css @@ -0,0 +1 @@ +.bh-app{--accent:var(--bh-accent);--glow-s:var(--bh-glow);min-height:100vh;color:var(--text);background:radial-gradient(circle at 10% 0,color-mix(in srgb,var(--bh-accent) 14%,transparent),transparent 33%),linear-gradient(135deg,rgba(0,0,0,.28),transparent 55%),var(--bg);font-family:Georgia,"Times New Roman",serif}.bh-top{min-height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:12px 24px;border-bottom:1px solid color-mix(in srgb,var(--bh-accent) 45%,var(--border));background:color-mix(in srgb,var(--panel) 94%,transparent);position:sticky;top:0;z-index:5}.bh-back{color:var(--muted);text-decoration:none}.bh-brand{text-align:center}.bh-brand b{display:block;color:var(--bh-accent);font:700 1.25rem/1 "Courier New",monospace;letter-spacing:.2em;text-shadow:0 0 14px var(--bh-glow)}.bh-brand span,.bh-kicker{font:700 .66rem/1.3 "Courier New",monospace;letter-spacing:.13em;color:var(--muted)}.bh-top .mf-a11y-host{justify-self:end}.bh-view{max-width:1240px;margin:auto;padding:2rem 1.25rem}.bh-loading{text-align:center;padding:20vh 0;color:var(--muted);font-family:"Courier New",monospace}.bh-hero,.bh-briefing,.bh-debrief,.bh-result{position:relative;border:1px solid color-mix(in srgb,var(--bh-accent) 55%,var(--border));border-radius:14px;background:linear-gradient(110deg,color-mix(in srgb,var(--surface) 95%,transparent),var(--surface));padding:1.6rem;box-shadow:0 12px 34px rgba(0,0,0,.18)}.bh-hero:before,.bh-briefing:before,.bh-debrief:before{content:"";position:absolute;inset:8px;border:1px solid color-mix(in srgb,var(--bh-accent) 26%,transparent);pointer-events:none}.bh-hero h1,.bh-briefing h1,.bh-hunt-head h1,.bh-debrief h1,.bh-result h1{color:var(--heading);margin:.45rem 0}.bh-hero p,.bh-briefing p,.bh-case p,.bh-debrief p{line-height:1.6;color:var(--muted);max-width:850px}.bh-stamp{display:inline-block;border:2px solid var(--bh-accent);color:var(--bh-accent);font:700 .7rem/1 "Courier New",monospace;letter-spacing:.12em;padding:.28rem .42rem;transform:rotate(-3deg);margin:0 .65rem .4rem 0}.bh-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-top:1rem}.bh-case{border:1px solid var(--border);border-radius:10px;background:var(--surface);padding:1.1rem;box-shadow:4px 5px 0 color-mix(in srgb,var(--bh-accent) 16%,transparent)}.bh-case h2{color:var(--heading);margin:.4rem 0}.bh-case-no{color:var(--bh-accent);font:700 .68rem "Courier New",monospace;letter-spacing:.08em}.bh-tags{display:flex;gap:.4rem;flex-wrap:wrap;margin:.9rem 0}.bh-tags span{border:1px solid var(--border);border-radius:99px;padding:.22rem .5rem;font:700 .65rem "Courier New",monospace;color:var(--muted)}.bh-btn{border:1px solid var(--bh-accent);background:transparent;border-radius:6px;padding:.65rem .85rem;color:var(--bh-accent);font:700 .72rem "Courier New",monospace;letter-spacing:.06em;cursor:pointer}.bh-btn:hover,.bh-btn.primary{background:var(--bh-accent);color:#19090e;box-shadow:0 0 16px var(--bh-glow)}.bh-briefing pre,.bh-evidence pre{white-space:pre-wrap;background:#0d0c0f;color:#d8cbd0;border-left:3px solid var(--bh-accent);padding:.8rem;font:normal .78rem/1.5 "Courier New",monospace}.bh-rules{display:flex;gap:.7rem;flex-wrap:wrap;margin:1rem 0}.bh-rules div{background:var(--surface-2);padding:.65rem;border-radius:7px;color:var(--muted)}.bh-rules b{display:inline-grid;place-items:center;width:1.4rem;height:1.4rem;border-radius:50%;background:var(--bh-accent);color:#210c12;margin-right:.35rem}.bh-hunt-head{display:flex;justify-content:space-between;gap:1rem;align-items:center}.bh-combo{border:1px solid var(--bh-accent);border-radius:50%;width:68px;height:68px;display:grid;place-content:center;text-align:center;color:var(--bh-accent);font:700 1.25rem "Courier New",monospace}.bh-combo small{font-size:.46rem;text-transform:uppercase;color:var(--muted)}.bh-progress{display:flex;gap:.45rem;margin:1rem 0}.bh-progress i{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--border);font:700 .75rem "Courier New",monospace;color:var(--muted);font-style:normal}.bh-progress i.current{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:0 0 10px var(--bh-glow)}.bh-progress i.done{background:var(--success);border-color:var(--success);color:#08131a}.bh-layout{display:grid;grid-template-columns:3fr 2fr;gap:1rem}.bh-code-area,.bh-side>*,.bh-action{border:1px solid var(--border);border-radius:10px;background:var(--surface)}.bh-tabs{display:flex;overflow:auto;border-bottom:1px solid var(--border)}.bh-tabs button{border:0;border-right:1px solid var(--border);background:transparent;padding:.7rem .9rem;color:var(--muted);font:700 .73rem "Courier New",monospace;cursor:pointer}.bh-tabs button.active{background:color-mix(in srgb,var(--bh-accent) 14%,transparent);color:var(--bh-accent)}.bh-code{padding:.55rem 0;overflow:auto;max-height:65vh;background:#0a090b}.bh-line{width:100%;min-width:max-content;display:grid;grid-template-columns:52px 1fr;gap:.8rem;text-align:left;border:0;border-left:3px solid transparent;padding:.1rem .8rem;background:transparent;color:#dad5d7;font:normal .83rem/1.55 "Courier New",monospace}.bh-line:not(:disabled){cursor:pointer}.bh-line:not(:disabled):hover,.bh-line.selected{background:color-mix(in srgb,var(--bh-accent) 17%,transparent);border-left-color:var(--bh-accent)}.bh-line span{text-align:right;color:#776e72;user-select:none}.bh-side{display:grid;gap:1rem;align-content:start}.bh-evidence,.bh-action{padding:1rem}.bh-evidence p{line-height:1.5}.bh-action h2{color:var(--heading);margin:.4rem 0}.bh-action p{color:var(--muted);line-height:1.45}.bh-selected{font-family:"Courier New",monospace;color:var(--bh-accent)!important}.bh-options{display:grid;gap:.45rem;margin:.8rem 0}.bh-options button{border:1px solid var(--border);background:var(--surface-2);border-radius:6px;padding:.65rem;color:var(--text);text-align:left;cursor:pointer}.bh-options button.selected{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:inset 0 0 0 1px var(--bh-accent)}.bh-attempts{font:normal .75rem "Courier New",monospace!important}.bh-feedback{min-height:1.4rem;font:700 .8rem "Courier New",monospace}.bh-feedback.ok{color:var(--success)}.bh-feedback.bad{color:var(--danger)}.bh-actions{display:flex;gap:.55rem;flex-wrap:wrap;margin-top:.6rem}.bh-debrief,.bh-result{text-align:center;max-width:780px;margin:7vh auto}.bh-diff{display:grid;gap:.35rem;text-align:left;background:#0b0a0c;padding:1rem;margin:1rem 0;font:normal .8rem "Courier New",monospace}.bh-diff span:first-child{color:var(--danger)}.bh-diff span:nth-of-type(2){color:var(--success)}.bh-diff code{color:#ded7db}.bh-score{font:700 3.5rem "Courier New",monospace;color:var(--bh-accent);text-shadow:0 0 20px var(--bh-glow)}.bh-stars{color:var(--bh-accent);font-size:1.5rem}.bh-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.7rem;margin:1rem 0}.bh-stats div{padding:.75rem;background:var(--surface-2);border-radius:7px;color:var(--muted);font-size:.8rem}.bh-stats b{display:block;color:var(--heading);font:700 1.1rem "Courier New",monospace;margin-bottom:.2rem}@media(max-width:850px){.bh-grid{grid-template-columns:1fr 1fr}.bh-layout{grid-template-columns:1fr}.bh-code{max-height:45vh}}@media(max-width:560px){.bh-top{grid-template-columns:1fr auto}.bh-brand{display:none}.bh-grid{grid-template-columns:1fr}.bh-stats{grid-template-columns:1fr 1fr}.bh-view{padding:1.1rem .75rem}.bh-line{grid-template-columns:38px 1fr;font-size:.74rem}.bh-hunt-head h1{font-size:1.35rem}} diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index 77cf8b6..69552f7 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -126,6 +126,9 @@ a { color:var(--secondary); } .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; } +.mf-card-bughunter { --accent:var(--bh-accent); --glow-s:var(--bh-glow); --glow-w:color-mix(in srgb,var(--bh-accent) 16%,transparent); background:linear-gradient(135deg,color-mix(in srgb,var(--bh-accent) 9%,transparent),var(--surface)); } +.mf-card-bughunter::after { content:"CASE FILE"; position:absolute; right:12px; top:9px; color:var(--bh-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.7; } + /* ===== Panels / Widgets ===== */ .mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); } .mf-panel.accent { border-color:var(--border-accent); box-shadow:0 0 20px var(--glow-w); } diff --git a/src/main/resources/static/js/bughunter.js b/src/main/resources/static/js/bughunter.js new file mode 100644 index 0000000..de4f435 --- /dev/null +++ b/src/main/resources/static/js/bughunter.js @@ -0,0 +1,72 @@ +(function () { + 'use strict'; + const view = document.getElementById('bhView'); + let catalog = [], session, index = 0, started = 0, selectedFile = '', selectedLine = 0, selected = '', options = [], answers = []; + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const api = (url, opt) => fetch('/api/bughunter' + url, {headers:{'Content-Type':'application/json'}, ...opt}).then(async response => { const body = await response.json(); if (!response.ok) throw new Error(body.message || 'Serverfehler'); return body; }); + const post = (url, body) => api(url, {method:'POST', body:JSON.stringify(body)}); + const seconds = () => Math.max(1, Math.round((Date.now() - started) / 1000)); + const current = () => session.bugs[index]; + const state = () => answers[index]; + function loadCatalog() { api('/hunts').then(data => { catalog = data.hunts || []; renderCatalog(); }).catch(error); } + function renderCatalog() { + view.innerHTML = `
VERTRAULICDETektivbüro · DEBUGGING-FALLAKTEN

Welche Spur verfolgst du?

Arbeite dich durch echte Fehlerbilder: Ort, Kategorie und Fix müssen zusammenpassen. Präzision baut deine Ermittlungsserie auf.

${catalog.map(h => `
${esc(h.examScope)} · ${esc(h.difficulty).toUpperCase()}

${esc(h.title)}

${esc(h.description)}

${esc(h.language)}${h.fileCount} Dateien${h.bugCount} Bug${h.bugCount === 1 ? '' : 's'}
`).join('')}
`; + view.querySelectorAll('[data-start]').forEach(button => button.addEventListener('click', () => begin(button.dataset.start))); + } + function begin(slug) { api('/session/' + encodeURIComponent(slug)).then(data => { if (!data.found) throw new Error('Fallakte nicht verfügbar.'); session = data; index = 0; answers = session.bugs.map(() => ({locate:null, classify:null, fix:null})); started = Date.now(); renderBriefing(); }).catch(error); } + function renderBriefing() { + view.innerHTML = `
NEUE AKTE${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()} · ${esc(session.language)}

${esc(session.title)}

${esc(session.briefing)}

${esc(session.consoleOutput)}
1 Ort markieren
2 Fehler einordnen
3 sauberen Fix wählen
`; + document.getElementById('bhStart').addEventListener('click', () => { selectedFile = session.files[0]?.filename || ''; selectedLine = 0; selected = ''; options = []; renderHunt(); }); + } + function activePhase() { const s = state(); return !s.locate?.done ? 'locate' : !s.classify?.done ? 'classify' : 'fix'; } + function renderHunt() { + const bug = current(), s = state(), phase = activePhase(); + const evidence = bug.evidence || 'Untersuche die Fallakte und folge den Spuren.'; + view.innerHTML = `
AKTE ${index + 1} / ${session.bugs.length} · ${esc(session.difficulty).toUpperCase()}

${esc(session.title)}

${comboText()}saubere Serie
${session.bugs.map((_, i) => `${i + 1}`).join('')}
${session.files.map(file => ``).join('')}
${renderCode(phase)}
`; + view.querySelectorAll('[data-file]').forEach(tab => tab.addEventListener('click', () => { selectedFile = tab.dataset.file; selectedLine = 0; renderHunt(); })); + bindAction(phase, bug); + } + function renderCode(phase) { + const file = session.files.find(f => f.filename === selectedFile) || session.files[0]; + if (!file) return '

Keine Datei in der Akte.

'; + return `
${file.lines.map((line, i) => { const lineNo = i + 1, marked = phase === 'locate' && selectedLine === lineNo; return ``; }).join('')}
`; + } + function renderAction(phase, bug, s) { + const labels = {locate:'1 · ORTEN', classify:'2 · KLASSIFIZIEREN', fix:'3 · FIXEN'}; + let content = ''; + if (phase === 'locate') content = `

Klicke die fehlerhafte Zeile im Code an.

${selectedLine ? `${esc(selectedFile)}:${selectedLine}` : 'Noch keine Zeile markiert'}

`; + if (phase === 'classify') content = `

Welche Fehlerklasse erklärt den Fund am besten?

${[['syntax','Syntax'],['logik','Logik'],['laufzeit','Laufzeit'],['sicherheit','Sicherheit']].map(([value,label]) => ``).join('')}
`; + if (phase === 'fix') content = `

Wähle die Zeile, die den Fehler sauber behebt.

${options.map(value => ``).join('')}
`; + const attempts = s[phase]?.attempts || 0; + return `
${labels[phase]}

${phase === 'locate' ? 'Wo liegt der Bug?' : phase === 'classify' ? 'Wie heißt das Problem?' : 'Wie wird er behoben?'}

${content}

Versuche: ${attempts}/3

`; + } + function bindAction(phase, bug) { + if (phase === 'locate') view.querySelectorAll('[data-line]').forEach(line => line.addEventListener('click', () => { selectedLine = Number(line.dataset.line); renderHunt(); })); + view.querySelectorAll('[data-choice]').forEach(button => button.addEventListener('click', () => { selected = button.dataset.choice; view.querySelectorAll('[data-choice]').forEach(item => item.classList.remove('selected')); button.classList.add('selected'); })); + document.getElementById('bhCheck').addEventListener('click', () => submit(phase, bug, false)); + document.getElementById('bhReveal').addEventListener('click', () => submit(phase, bug, true)); + } + function submission(phase) { return phase === 'locate' ? {file:selectedFile, line:selectedLine} : phase === 'classify' ? {category:selected} : {choice:selected}; } + function submit(phase, bug, reveal) { + const sub = submission(phase); if (!reveal && ((phase === 'locate' && !sub.line) || (phase !== 'locate' && !selected))) { feedback('Triff zuerst eine Auswahl.', 'bad'); return; } + const phaseState = state()[phase] || {attempts:0}; phaseState.attempts = Math.min(3, phaseState.attempts + 1); phaseState.submission = sub; + post('/check', {bugId:bug.bugId, phase, reveal, submission:sub}).then(result => { + if (!result.resolved) { state()[phase] = phaseState; feedback('Noch nicht stimmig. Lies die Beweise erneut.', 'bad'); return; } + phaseState.revealed = reveal; phaseState.done = true; state()[phase] = phaseState; + if (phase === 'classify') options = result.fixOptions || []; if (phase === 'fix') { showDebrief(result); return; } + selected = ''; if (phase === 'locate') selectedLine = result.location?.line || selectedLine; + feedback(reveal ? 'Auflösung notiert – weiter zur nächsten Phase.' : 'Spur bestätigt – weiter zur nächsten Phase.', 'ok'); setTimeout(renderHunt, 650); + }).catch(err => feedback(err.message, 'bad')); + } + function showDebrief(result) { + const bug = current(); view.innerHTML = `
GELÖSTAKTE ${index + 1} ABGESCHLOSSEN

Die Spur ist gesichert.

${esc(result.explanation || 'Die Fallakte wurde aktualisiert.')}

− fehlerhafte Zeile${esc(selectedFile)}:${state().locate?.submission?.line || '?'}+ Korrektur${esc(result.fixedLine || '')}
`; + document.getElementById('bhNext').addEventListener('click', () => { index++; selected = ''; selectedLine = 0; options = []; if (index >= session.bugs.length) finish(); else { selectedFile = session.files[0]?.filename || ''; renderHunt(); } }); + } + function finish() { post('/finish', {slug:session.slug, durationSeconds:seconds(), items:answers.map((answer, i) => ({bugId:session.bugs[i].bugId, locate:strip(answer.locate), classify:strip(answer.classify), fix:strip(answer.fix)}))}).then(renderResult).catch(error); } + function strip(value) { return value ? {submission:value.submission, attempts:value.attempts, revealed:!!value.revealed} : null; } + function comboText() { let combo = 0; for (let i = 0; i < index; i++) { const a = answers[i]; const clean = ['locate','classify','fix'].every(key => a[key] && a[key].attempts === 1 && !a[key].revealed); combo = clean ? combo + 1 : 0; } return `×${combo}`; } + function renderResult(result) { view.innerHTML = `
ERMITTLUNG ABGESCHLOSSEN

Fallakten geschlossen.

${result.score}

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

${result.baseScore}Basis
+${result.comboBonus}Combo
${result.bugsSolved}/${result.totalBugs}gelöst
×${result.maxCombo}Serie
${result.highscore}Highscore
${result.plays}Akten
`; document.getElementById('bhAgain').addEventListener('click', loadCatalog); } + function feedback(message, type) { const target = document.getElementById('bhFeedback'); if (target) { target.textContent = message; target.className = 'bh-feedback ' + type; } } + function error(err) { view.innerHTML = `

AKTE NICHT LESBAR

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

`; document.getElementById('bhReload').addEventListener('click', loadCatalog); } + loadCatalog(); +})(); diff --git a/src/main/resources/templates/admin/bughunter-hunt.html b/src/main/resources/templates/admin/bughunter-hunt.html new file mode 100644 index 0000000..269a92e --- /dev/null +++ b/src/main/resources/templates/admin/bughunter-hunt.html @@ -0,0 +1 @@ +BugHunter Fallakte
← Fallakten

Datei hinzufügen

Bug hinzufügen

Dateiname und Zeile müssen zu einer vorhandenen Datei passen; Kategorie: syntax, logik, laufzeit oder sicherheit.

Dateien

DateiZeilen

Bugs

PunktePayload

Fallakte nicht gefunden.

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

BugHunter · Fallakten

TitelStufeScopeFälleStatus
diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index a55790c..cbdd17c 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -49,6 +49,7 @@ 📜 Syntax-Saga-Missionen verwalten ⚗️ Dev-Lab-Aufgaben verwalten 🚪 SystemEscape-Szenarien verwalten + 🕵️ BugHunter-Fallakten verwalten

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

← Dashboard
BUGHUNTERINVESTIGATION BUREAU
FALLAKTEN WERDEN GEÖFFNET …
diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 741a5a8..7aed815 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -137,12 +137,12 @@ ['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'], ['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/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'], + ['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/bughunter'], ['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/BugHunterServiceTest.java b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java new file mode 100644 index 0000000..e764e46 --- /dev/null +++ b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java @@ -0,0 +1,45 @@ +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 BugHunterServiceTest { + BugHunterHuntRepository hunts = mock(BugHunterHuntRepository.class); BugHunterFileRepository files = mock(BugHunterFileRepository.class); BugHunterBugRepository bugs = mock(BugHunterBugRepository.class); GameScoreService scores = mock(GameScoreService.class); + ObjectMapper mapper = new ObjectMapper(); BugHunterService service; BugHunterHunt hunt; BugHunterBug bug; MockHttpSession session; + @BeforeEach void setup() { + service = new BugHunterService(hunts, files, bugs, scores, mapper); session = new MockHttpSession(); + hunt = new BugHunterHunt("test", "Test", "Java", "junior", "AP1", "", "Briefing", "Trace", 0); hunt.setId(1L); + hunt.addFile(new BugHunterFile("Demo.java", "class Demo {\n void run() {}\n}", 0)); + bug = new BugHunterBug(100, 0, "{\"file\":\"Demo.java\",\"line\":2,\"category\":\"logik\",\"evidence\":\"Spur\",\"fixOptions\":[\"fixed();\",\"broken();\"],\"fixIndex\":0,\"fixedLine\":\"fixed();\",\"explanation\":\"Warum\"}"); bug.setId(2L); hunt.addBug(bug); + when(hunts.findBySlug("test")).thenReturn(Optional.of(hunt)); when(bugs.findById(2L)).thenReturn(Optional.of(bug)); + when(scores.recordScore(anyString(), anyString(), anyString(), anyInt(), anyInt(), anyInt())).thenAnswer(call -> new ScoreResult(call.getArgument(3), call.getArgument(3), true, 1)); + } + @Test void sessionLeaksNeitherLocationCategoryNorFix() { + String raw = service.session("test", session).toString(); + assertFalse(raw.contains("\"line\"")); assertFalse(raw.contains("category")); assertFalse(raw.contains("fixOptions")); assertFalse(raw.contains("fixed();")); + } + @Test void phasesAreGatedAndScoreUsesServerRecordedAttempts() { + service.session("test", session); + assertThrows(IllegalArgumentException.class, () -> service.check(2L, "classify", false, mapper.createObjectNode().put("category", "logik"), session)); + assertTrue(service.check(2L, "locate", false, mapper.createObjectNode().put("file", "Demo.java").put("line", 2), session).path("correct").asBoolean()); + JsonNode classify = service.check(2L, "classify", false, mapper.createObjectNode().put("category", "logik"), session); + assertTrue(classify.path("correct").asBoolean()); assertEquals(2, classify.path("fixOptions").size()); + assertTrue(service.check(2L, "fix", false, mapper.createObjectNode().put("choice", "fixed();"), session).path("correct").asBoolean()); + BugHunterFinishRequest request = new BugHunterFinishRequest(); request.setSlug("test"); request.setDurationSeconds(3); BugHunterFinishRequest.Item item = new BugHunterFinishRequest.Item(); item.setBugId(2L); request.setItems(List.of(item)); + BugHunterResult result = service.finish("alice", request, session); + assertEquals(100, result.baseScore()); assertEquals(100, result.score()); assertEquals(1, result.maxCombo()); + } + @Test void foreignBugAndUnstartedCheckAreRejected() { + assertThrows(IllegalArgumentException.class, () -> service.check(2L, "locate", false, mapper.createObjectNode(), session)); + service.session("test", session); + assertThrows(IllegalArgumentException.class, () -> service.check(99L, "locate", false, mapper.createObjectNode(), session)); + } +}