From 3eef7cdb40e551e1dd4999686d1f5aa96fbeaac2 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Mon, 13 Jul 2026 21:55:07 +0200 Subject: [PATCH] Add BugHunter clean solve replay variants --- .../controller/BugHunterApiController.java | 5 +- .../example/mindflux/dto/BugHunterResult.java | 2 +- .../mindflux/model/BugHunterHuntProgress.java | 27 +++++++++++ .../BugHunterHuntProgressRepository.java | 10 ++++ .../mindflux/service/BugHunterService.java | 48 ++++++++++++++----- .../db/migration/V13__bughunter_progress.sql | 11 +++++ .../static/css/accessibility-placement.css | 1 + src/main/resources/static/js/bughunter.js | 10 ++-- src/main/resources/static/js/theme.js | 2 +- src/main/resources/templates/bughunter.html | 2 +- .../service/BugHunterServiceTest.java | 10 ++-- 11 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/example/mindflux/model/BugHunterHuntProgress.java create mode 100644 src/main/java/com/example/mindflux/repository/BugHunterHuntProgressRepository.java create mode 100644 src/main/resources/db/migration/V13__bughunter_progress.sql diff --git a/src/main/java/com/example/mindflux/controller/BugHunterApiController.java b/src/main/java/com/example/mindflux/controller/BugHunterApiController.java index 4f198f5..5e6591f 100644 --- a/src/main/java/com/example/mindflux/controller/BugHunterApiController.java +++ b/src/main/java/com/example/mindflux/controller/BugHunterApiController.java @@ -11,8 +11,9 @@ import java.security.Principal; 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); } + @GetMapping("/hunts") public JsonNode catalog(Principal principal) { return service.catalog(principal == null ? null : principal.getName()); } + @GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug, Principal principal, HttpSession httpSession) { return service.session(slug, principal == null ? null : principal.getName(), httpSession); } + @PostMapping("/reset/{slug}") public void reset(@PathVariable String slug, Principal principal) { service.reset(principal.getName(), slug); } @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/dto/BugHunterResult.java b/src/main/java/com/example/mindflux/dto/BugHunterResult.java index c2fa512..d74566c 100644 --- a/src/main/java/com/example/mindflux/dto/BugHunterResult.java +++ b/src/main/java/com/example/mindflux/dto/BugHunterResult.java @@ -2,4 +2,4 @@ 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) { } + boolean newHighscore, long plays, boolean cleanSolve) { } diff --git a/src/main/java/com/example/mindflux/model/BugHunterHuntProgress.java b/src/main/java/com/example/mindflux/model/BugHunterHuntProgress.java new file mode 100644 index 0000000..c44d15b --- /dev/null +++ b/src/main/java/com/example/mindflux/model/BugHunterHuntProgress.java @@ -0,0 +1,27 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "bughunter_hunt_progress", uniqueConstraints = @UniqueConstraint(columnNames = {"username", "hunt_slug"})) +public class BugHunterHuntProgress { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) private String username; + @Column(name = "hunt_slug", nullable = false) private String huntSlug; + @Column(name = "solved_clean", nullable = false) private boolean solvedClean; + @Column(name = "variant_index", nullable = false) private int variantIndex; + @Column(name = "solved_at") private LocalDateTime solvedAt; + + public BugHunterHuntProgress() { } + public BugHunterHuntProgress(String username, String huntSlug) { this.username = username; this.huntSlug = huntSlug; } + public Long getId() { return id; } + public String getUsername() { return username; } + public String getHuntSlug() { return huntSlug; } + public boolean isSolvedClean() { return solvedClean; } + public void setSolvedClean(boolean value) { solvedClean = value; } + public int getVariantIndex() { return variantIndex; } + public void setVariantIndex(int value) { variantIndex = value; } + public LocalDateTime getSolvedAt() { return solvedAt; } + public void setSolvedAt(LocalDateTime value) { solvedAt = value; } +} diff --git a/src/main/java/com/example/mindflux/repository/BugHunterHuntProgressRepository.java b/src/main/java/com/example/mindflux/repository/BugHunterHuntProgressRepository.java new file mode 100644 index 0000000..cdec050 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/BugHunterHuntProgressRepository.java @@ -0,0 +1,10 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.BugHunterHuntProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.*; + +public interface BugHunterHuntProgressRepository extends JpaRepository { + Optional findByUsernameAndHuntSlug(String username, String huntSlug); + List findAllByUsername(String username); +} diff --git a/src/main/java/com/example/mindflux/service/BugHunterService.java b/src/main/java/com/example/mindflux/service/BugHunterService.java index 9421623..ac419f0 100644 --- a/src/main/java/com/example/mindflux/service/BugHunterService.java +++ b/src/main/java/com/example/mindflux/service/BugHunterService.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.node.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.servlet.http.HttpSession; +import java.time.LocalDateTime; import java.util.*; /** Serverseitige Drei-Phasen-Ermittlung für BugHunter. Lösungsdaten bleiben im Bug-Payload. */ @@ -22,18 +23,21 @@ public class BugHunterService { private final BugHunterHuntRepository hunts; private final BugHunterFileRepository files; private final BugHunterBugRepository bugs; + private final BugHunterHuntProgressRepository progress; 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; + BugHunterHuntProgressRepository progress, GameScoreService scores, ObjectMapper mapper) { + this.hunts = hunts; this.files = files; this.bugs = bugs; this.progress = progress; this.scores = scores; this.mapper = mapper; } @Transactional(readOnly = true) - public JsonNode catalog() { + public JsonNode catalog(String username) { ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts"); + Map bySlug = new HashMap<>(); + if (!blank(username)) for (BugHunterHuntProgress item : progress.findAllByUsername(username)) bySlug.put(item.getHuntSlug(), item); List ordered = new ArrayList<>(hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc()); ordered.sort(Comparator.comparingInt((BugHunterHunt hunt) -> DIFFICULTY_ORDER.getOrDefault(hunt.getDifficulty(), Integer.MAX_VALUE)).thenComparingInt(BugHunterHunt::getSortOrder)); for (BugHunterHunt hunt : ordered) { @@ -42,12 +46,13 @@ public class BugHunterService { 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()); + row.put("solved", Optional.ofNullable(bySlug.get(hunt.getSlug())).map(BugHunterHuntProgress::isSolvedClean).orElse(false)); } return root; } @Transactional(readOnly = true) - public JsonNode session(String slug, HttpSession httpSession) { + public JsonNode session(String slug, String username, 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; } @@ -55,13 +60,17 @@ public class BugHunterService { 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())); + int variant = variantFor(username, hunt.getSlug()); root.put("variant", variant); + Map offsets = new HashMap<>(); for (BugHunterFile file : hunt.getFiles()) offsets.put(file.getFilename(), lineOffset(file.getFilename(), variant)); + List orderedFiles = new ArrayList<>(hunt.getFiles()); if (orderedFiles.size() > 1 && variant > 0) Collections.rotate(orderedFiles, -Math.floorMod(variant, orderedFiles.size())); 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); } + for (BugHunterFile file : orderedFiles) { ObjectNode f = fs.addObject(); f.put("filename", file.getFilename()); ArrayNode lines = f.putArray("lines"); for (String line : variantContent(file.getContent(), variant, offsets.get(file.getFilename())).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(); } + Map effectivePayloads = new HashMap<>(); + for (BugHunterBug bug : hunt.getBugs()) { JsonNode payload = parse(bug.getPayload()); if (payload == null) continue; ObjectNode effective = payload.deepCopy(); effective.put("line", payload.path("line").asInt() + offsets.getOrDefault(payload.path("file").asText(), 0)); effectivePayloads.put(bug.getId(), effective); ObjectNode b = bs.addObject(); b.put("bugId", bug.getId()); b.put("points", bug.getPoints()); b.put("evidence", effective.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())); + httpSession.setAttribute("bughunter.run", new RunState(hunt.getSlug(), bugIds, effectivePayloads, System.currentTimeMillis())); return root; } @@ -70,8 +79,7 @@ public class BugHunterService { 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()); + JsonNode payload = run.payload(bugId); 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."); @@ -107,7 +115,7 @@ public class BugHunterService { 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; } + JsonNode payload = run.payload(bug.getId()); 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); @@ -120,9 +128,18 @@ public class BugHunterService { combo++; maxCombo = Math.max(maxCombo, combo); comboBonus += (int) Math.round(bug.getPoints() * (Math.min(combo, 4) - 1) * .1); } else combo = 0; } + boolean cleanSolve = solved == hunt.getBugs().size() && maxCombo == hunt.getBugs().size(); + if (cleanSolve && !blank(username)) markCleanSolved(username, hunt.getSlug()); 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()); + return new BugHunterResult(base + comboBonus, base, max, comboBonus, maxCombo, solved, hunt.getBugs().size(), stars, saved.getHighscore(), saved.isNewHighscore(), saved.getPlays(), cleanSolve); + } + + @Transactional public void reset(String username, String slug) { + if (blank(username) || blank(slug)) throw new IllegalArgumentException("Fallakte fehlt."); + hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElseThrow(() -> new IllegalArgumentException("Fallakte nicht gefunden.")); + BugHunterHuntProgress item = progress.findByUsernameAndHuntSlug(username, slug).orElseGet(() -> new BugHunterHuntProgress(username, slug)); + item.setVariantIndex(item.getVariantIndex() + 1); progress.save(item); } @Transactional(readOnly = true) public List all() { return hunts.findAllByOrderBySortOrderAscIdAsc(); } @@ -158,6 +175,10 @@ public class BugHunterService { 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 int variantFor(String username, String slug) { return blank(username) ? 0 : progress.findByUsernameAndHuntSlug(username, slug).map(BugHunterHuntProgress::getVariantIndex).orElse(0); } + private int lineOffset(String filename, int variant) { return variant <= 0 ? 0 : 1 + Math.floorMod(filename.hashCode() + variant, 3); } + private String variantContent(String content, int variant, int offset) { return offset <= 0 ? content : "// Aktenvermerk · Variante " + (variant + 1) + "\n".repeat(offset) + content; } + private void markCleanSolved(String username, String slug) { BugHunterHuntProgress item = progress.findByUsernameAndHuntSlug(username, slug).orElseGet(() -> new BugHunterHuntProgress(username, slug)); item.setSolvedClean(true); item.setSolvedAt(LocalDateTime.now()); progress.save(item); } 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); } @@ -171,8 +192,9 @@ public class BugHunterService { 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; } + final String slug; final Set bugIds; final Map payloads; final long startedAt; final Map> phases = new HashMap<>(); + RunState(String slug, Set bugIds, Map payloads, long startedAt) { this.slug = slug; this.bugIds = Set.copyOf(bugIds); this.payloads = Map.copyOf(payloads); this.startedAt = startedAt; } + JsonNode payload(Long bugId) { return payloads.get(bugId); } 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/db/migration/V13__bughunter_progress.sql b/src/main/resources/db/migration/V13__bughunter_progress.sql new file mode 100644 index 0000000..18e300a --- /dev/null +++ b/src/main/resources/db/migration/V13__bughunter_progress.sql @@ -0,0 +1,11 @@ +CREATE TABLE bughunter_hunt_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(100) NOT NULL, + hunt_slug VARCHAR(150) NOT NULL, + solved_clean BOOLEAN NOT NULL DEFAULT FALSE, + variant_index INT NOT NULL DEFAULT 0, + solved_at TIMESTAMP, + CONSTRAINT uq_bughunter_progress_user_hunt UNIQUE (username, hunt_slug) +); + +CREATE INDEX idx_bughunter_progress_user ON bughunter_hunt_progress(username); diff --git a/src/main/resources/static/css/accessibility-placement.css b/src/main/resources/static/css/accessibility-placement.css index 375a46e..baf33d8 100644 --- a/src/main/resources/static/css/accessibility-placement.css +++ b/src/main/resources/static/css/accessibility-placement.css @@ -12,4 +12,5 @@ body[class*="mf-a11y-skin-"] .mf-a11y-head{background:color-mix(in srgb,var(--mf body[class*="mf-a11y-skin-"] .mf-a11y-close{background:var(--mf-a11y-accent);color:var(--mf-a11y-ink)} body[class*="mf-a11y-skin-"] .mf-a11y-option[aria-pressed="true"]{border-color:var(--mf-a11y-accent);background:color-mix(in srgb,var(--mf-a11y-accent) 18%,var(--surface));box-shadow:inset 0 0 0 2px var(--mf-a11y-accent)} .bh-cabinet-level{grid-column:1/-1;margin:.5rem 0 -.15rem;padding-bottom:.35rem;border-bottom:1px solid #60473d;color:var(--bh-accent);font:700 .66rem "Courier New",monospace;letter-spacing:.12em} +.bh-drawer.solved{overflow:hidden}.bh-solved-stamp{display:inline-block;border:3px solid #79bf91;border-radius:4px;padding:.24rem .48rem;color:#79bf91;font:700 .78rem/1 "Courier New",monospace;letter-spacing:.12em;transform:rotate(-12deg);text-shadow:none;box-shadow:0 0 12px color-mix(in srgb,#79bf91 26%,transparent);pointer-events:none}.bh-drawer .bh-solved-stamp{position:absolute;right:3.1rem;top:.75rem}.bh-solved-stamp-detail{position:absolute;right:1.4rem;top:1.25rem;font-size:1rem}.bh-solved-stamp-result{margin:0 0 .8rem;font-size:1.15rem} @media(max-width:700px){.mf-a11y-panel{inset:70px 6px auto 6px!important;width:auto;max-height:calc(100dvh - 82px)}} diff --git a/src/main/resources/static/js/bughunter.js b/src/main/resources/static/js/bughunter.js index 7267ca3..fd31e58 100644 --- a/src/main/resources/static/js/bughunter.js +++ b/src/main/resources/static/js/bughunter.js @@ -16,15 +16,17 @@ const drawers = ['junior','senior','principal'].map(level => { const cases = catalog.map((hunt, i) => ({hunt, i})).filter(entry => entry.hunt.difficulty === level); if (!cases.length) return ''; - return `
${difficultyNames[level]}
${cases.map(({hunt, i}) => ``).join('')}`; + return `
${difficultyNames[level]}
${cases.map(({hunt, i}) => ``).join('')}`; }).join(''); - view.innerHTML = `
VERTRAULICHDEBUG-FALLAKTEN

Welche Spur verfolgst du?

Öffne eine Akte, lies die Beweise und sichere deinen Befund Schritt für Schritt. Jede Akte bleibt so lange offen, wie du sie brauchst.

AKTENSCHRANK · ${catalog.length} AKTEN

Fallarchiv

Nach Schwierigkeit sortiert: Grundlagen, Praxis, Komplexfälle.

BUGHUNTER · ARCHIV
${drawers}
AUSGEWÄHLTE AKTE${esc(selectedHunt.examScope)} · ${esc(selectedHunt.difficulty).toUpperCase()} · ${esc(selectedHunt.language)}

${esc(selectedHunt.title)}

${esc(selectedHunt.description)}

${selectedHunt.fileCount} ${selectedHunt.fileCount === 1 ? 'Datei' : 'Dateien'}${selectedHunt.bugCount} Bug${selectedHunt.bugCount === 1 ? '' : 's'}
`; + view.innerHTML = `
VERTRAULICHDEBUG-FALLAKTEN

Welche Spur verfolgst du?

Öffne eine Akte, lies die Beweise und sichere deinen Befund Schritt für Schritt. Jede Akte bleibt so lange offen, wie du sie brauchst.

AKTENSCHRANK · ${catalog.length} AKTEN

Fallarchiv

Nach Schwierigkeit sortiert: Grundlagen, Praxis, Komplexfälle.

BUGHUNTER · ARCHIV
${drawers}
${selectedHunt.solved ? 'GELÖST' : ''}AUSGEWÄHLTE AKTE${esc(selectedHunt.examScope)} · ${esc(selectedHunt.difficulty).toUpperCase()} · ${esc(selectedHunt.language)}

${esc(selectedHunt.title)}

${esc(selectedHunt.description)}

${selectedHunt.fileCount} ${selectedHunt.fileCount === 1 ? 'Datei' : 'Dateien'}${selectedHunt.bugCount} Bug${selectedHunt.bugCount === 1 ? '' : 's'}
${selectedHunt.solved ? `` : ''}
`; view.querySelectorAll('[data-select]').forEach(drawer => drawer.addEventListener('click', () => { selectedCase = Number(drawer.dataset.select); renderCatalog(); })); view.querySelector('[data-start]')?.addEventListener('click', event => begin(event.currentTarget.dataset.start)); + view.querySelector('[data-reset]')?.addEventListener('click', event => resetCase(event.currentTarget.dataset.reset)); } function begin(slug) { api('/session/' + encodeURIComponent(slug)).then(data => { if (!data.found) throw new Error('Fallakte nicht verfügbar.'); session = data; index = 0; handoff = null; answers = session.bugs.map(() => ({locate:null, classify:null, fix:null})); started = Date.now(); renderBriefing(); }).catch(error); } + function resetCase(slug) { post('/reset/' + encodeURIComponent(slug), {}).then(() => begin(slug)).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
`; + view.innerHTML = `
NEUE AKTE${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()} · ${esc(session.language)}${session.variant ? ` · VARIANTE ${session.variant + 1}` : ''}

${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'; } @@ -83,7 +85,7 @@ 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 renderResult(result) { view.innerHTML = `
${result.cleanSolve ? 'GELÖST' : ''}ERMITTLUNG ABGESCHLOSSEN

${result.cleanSolve ? 'Fall fehlerfrei geschlossen.' : '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/static/js/theme.js b/src/main/resources/static/js/theme.js index ab14928..4643a73 100644 --- a/src/main/resources/static/js/theme.js +++ b/src/main/resources/static/js/theme.js @@ -32,7 +32,7 @@ document.head.appendChild(accessibilityCss); var accessibilityPlacementCss = document.createElement('link'); accessibilityPlacementCss.rel = 'stylesheet'; - accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=4'; + accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=5'; accessibilityPlacementCss.setAttribute('data-accessibility', ''); document.head.appendChild(accessibilityPlacementCss); } diff --git a/src/main/resources/templates/bughunter.html b/src/main/resources/templates/bughunter.html index 4444a84..d5dde77 100644 --- a/src/main/resources/templates/bughunter.html +++ b/src/main/resources/templates/bughunter.html @@ -1 +1 @@ -BugHunter · Mindflux
← Dashboard
BUGHUNTERDEBUG-FALLAKTEN
FALLAKTEN WERDEN GEÖFFNET …
+BugHunter · Mindflux
← Dashboard
BUGHUNTERDEBUG-FALLAKTEN
FALLAKTEN WERDEN GEÖFFNET …
diff --git a/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java index e764e46..56c2ef7 100644 --- a/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java +++ b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java @@ -12,10 +12,10 @@ 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); + BugHunterHuntRepository hunts = mock(BugHunterHuntRepository.class); BugHunterFileRepository files = mock(BugHunterFileRepository.class); BugHunterBugRepository bugs = mock(BugHunterBugRepository.class); BugHunterHuntProgressRepository progress = mock(BugHunterHuntProgressRepository.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(); + service = new BugHunterService(hunts, files, bugs, progress, 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); @@ -23,11 +23,11 @@ class BugHunterServiceTest { 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(); + String raw = service.session("test", "alice", 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); + service.session("test", "alice", 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); @@ -39,7 +39,7 @@ class BugHunterServiceTest { } @Test void foreignBugAndUnstartedCheckAreRejected() { assertThrows(IllegalArgumentException.class, () -> service.check(2L, "locate", false, mapper.createObjectNode(), session)); - service.session("test", session); + service.session("test", "alice", session); assertThrows(IllegalArgumentException.class, () -> service.check(99L, "locate", false, mapper.createObjectNode(), session)); } }