Add BugHunter clean solve replay variants
mindflux-ci / build (push) Successful in 22s

This commit is contained in:
AliceDarkest
2026-07-13 21:55:07 +02:00
parent 6d243c8987
commit 3eef7cdb40
11 changed files with 101 additions and 27 deletions
@@ -11,8 +11,9 @@ import java.security.Principal;
public class BugHunterApiController { public class BugHunterApiController {
private final BugHunterService service; private final BugHunterService service;
public BugHunterApiController(BugHunterService service) { this.service = service; } public BugHunterApiController(BugHunterService service) { this.service = service; }
@GetMapping("/hunts") public JsonNode catalog() { return service.catalog(); } @GetMapping("/hunts") public JsonNode catalog(Principal principal) { return service.catalog(principal == null ? null : principal.getName()); }
@GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug, HttpSession httpSession) { return service.session(slug, httpSession); } @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("/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); } @PostMapping("/finish") public BugHunterResult finish(@RequestBody BugHunterFinishRequest request, Principal principal, HttpSession httpSession) { return service.finish(principal.getName(), request, httpSession); }
} }
@@ -2,4 +2,4 @@ package com.example.mindflux.dto;
public record BugHunterResult(int score, int baseScore, int maxScore, int comboBonus, int maxCombo, public record BugHunterResult(int score, int baseScore, int maxScore, int comboBonus, int maxCombo,
int bugsSolved, int totalBugs, int stars, int highscore, int bugsSolved, int totalBugs, int stars, int highscore,
boolean newHighscore, long plays) { } boolean newHighscore, long plays, boolean cleanSolve) { }
@@ -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; }
}
@@ -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<BugHunterHuntProgress, Long> {
Optional<BugHunterHuntProgress> findByUsernameAndHuntSlug(String username, String huntSlug);
List<BugHunterHuntProgress> findAllByUsername(String username);
}
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.node.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** Serverseitige Drei-Phasen-Ermittlung für BugHunter. Lösungsdaten bleiben im Bug-Payload. */ /** 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 BugHunterHuntRepository hunts;
private final BugHunterFileRepository files; private final BugHunterFileRepository files;
private final BugHunterBugRepository bugs; private final BugHunterBugRepository bugs;
private final BugHunterHuntProgressRepository progress;
private final GameScoreService scores; private final GameScoreService scores;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Random random = new Random(); private final Random random = new Random();
public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs, public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs,
GameScoreService scores, ObjectMapper mapper) { BugHunterHuntProgressRepository progress, GameScoreService scores, ObjectMapper mapper) {
this.hunts = hunts; this.files = files; this.bugs = bugs; this.scores = scores; this.mapper = mapper; this.hunts = hunts; this.files = files; this.bugs = bugs; this.progress = progress; this.scores = scores; this.mapper = mapper;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public JsonNode catalog() { public JsonNode catalog(String username) {
ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts"); ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts");
Map<String, BugHunterHuntProgress> bySlug = new HashMap<>();
if (!blank(username)) for (BugHunterHuntProgress item : progress.findAllByUsername(username)) bySlug.put(item.getHuntSlug(), item);
List<BugHunterHunt> ordered = new ArrayList<>(hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc()); List<BugHunterHunt> ordered = new ArrayList<>(hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc());
ordered.sort(Comparator.comparingInt((BugHunterHunt hunt) -> DIFFICULTY_ORDER.getOrDefault(hunt.getDifficulty(), Integer.MAX_VALUE)).thenComparingInt(BugHunterHunt::getSortOrder)); ordered.sort(Comparator.comparingInt((BugHunterHunt hunt) -> DIFFICULTY_ORDER.getOrDefault(hunt.getDifficulty(), Integer.MAX_VALUE)).thenComparingInt(BugHunterHunt::getSortOrder));
for (BugHunterHunt hunt : ordered) { for (BugHunterHunt hunt : ordered) {
@@ -42,12 +46,13 @@ public class BugHunterService {
row.put("language", hunt.getCodeLanguage()); row.put("difficulty", hunt.getDifficulty()); row.put("language", hunt.getCodeLanguage()); row.put("difficulty", hunt.getDifficulty());
row.put("examScope", hunt.getExamScope()); row.put("description", hunt.getDescription()); row.put("examScope", hunt.getExamScope()); row.put("description", hunt.getDescription());
row.put("bugCount", hunt.getBugs().size()); row.put("fileCount", hunt.getFiles().size()); 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; return root;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public JsonNode session(String slug, HttpSession httpSession) { public JsonNode session(String slug, String username, HttpSession httpSession) {
ObjectNode root = mapper.createObjectNode(); ObjectNode root = mapper.createObjectNode();
BugHunterHunt hunt = hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElse(null); BugHunterHunt hunt = hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElse(null);
if (hunt == null) { root.put("found", false); return root; } 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("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("description", nullToEmpty(hunt.getDescription())); root.put("briefing", nullToEmpty(hunt.getBriefing()));
root.put("consoleOutput", nullToEmpty(hunt.getConsoleOutput())); root.put("consoleOutput", nullToEmpty(hunt.getConsoleOutput()));
int variant = variantFor(username, hunt.getSlug()); root.put("variant", variant);
Map<String, Integer> offsets = new HashMap<>(); for (BugHunterFile file : hunt.getFiles()) offsets.put(file.getFilename(), lineOffset(file.getFilename(), variant));
List<BugHunterFile> orderedFiles = new ArrayList<>(hunt.getFiles()); if (orderedFiles.size() > 1 && variant > 0) Collections.rotate(orderedFiles, -Math.floorMod(variant, orderedFiles.size()));
ArrayNode fs = root.putArray("files"); 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; 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<Long, JsonNode> 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); root.put("totalBugs", bs.size()); root.put("maxScore", maxScore);
Set<Long> bugIds = new HashSet<>(); bs.forEach(b -> bugIds.add(b.path("bugId").asLong())); Set<Long> 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; return root;
} }
@@ -70,8 +79,7 @@ public class BugHunterService {
if (!isPhase(phase)) throw new IllegalArgumentException("Unbekannte Ermittlungsphase."); if (!isPhase(phase)) throw new IllegalArgumentException("Unbekannte Ermittlungsphase.");
RunState run = run(httpSession); RunState run = run(httpSession);
if (bugId == null || !run.bugIds.contains(bugId)) throw new IllegalArgumentException("Bug gehört nicht zu dieser Fallakte."); 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 = run.payload(bugId);
JsonNode payload = bug == null ? null : parse(bug.getPayload());
if (payload == null) throw new IllegalArgumentException("Fallakte nicht gefunden."); if (payload == null) throw new IllegalArgumentException("Fallakte nicht gefunden.");
PhaseState phaseState = run.state(bugId, phase); 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 ((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()) { for (BugHunterBug bug : hunt.getBugs()) {
max += bug.getPoints(); BugHunterFinishRequest.Item item = byId.get(bug.getId()); max += bug.getPoints(); BugHunterFinishRequest.Item item = byId.get(bug.getId());
if (item == null) { combo = 0; continue; } 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"); 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 locate = phaseCorrect(payload, "locate", located);
boolean classify = phaseCorrect(payload, "classify", classified); 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); combo++; maxCombo = Math.max(maxCombo, combo); comboBonus += (int) Math.round(bug.getPoints() * (Math.min(combo, 4) - 1) * .1);
} else combo = 0; } 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()); int stars = stars(base, max); ScoreResult saved = scores.recordScore(username, GAME, hunt.getSlug(), base + comboBonus, solved, request.getDurationSeconds());
httpSession.removeAttribute("bughunter.run"); 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<BugHunterHunt> all() { return hunts.findAllByOrderBySortOrderAscIdAsc(); } @Transactional(readOnly = true) public List<BugHunterHunt> 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()); } 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 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 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 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 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 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; } 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 { public static class RunState {
final String slug; final Set<Long> bugIds; final long startedAt; final Map<Long, Map<String, PhaseState>> phases = new HashMap<>(); final String slug; final Set<Long> bugIds; final Map<Long, JsonNode> payloads; final long startedAt; final Map<Long, Map<String, PhaseState>> phases = new HashMap<>();
RunState(String slug, Set<Long> bugIds, long startedAt) { this.slug = slug; this.bugIds = Set.copyOf(bugIds); this.startedAt = startedAt; } RunState(String slug, Set<Long> bugIds, Map<Long, JsonNode> 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()); } 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; } static class PhaseState { int attempts; boolean revealed; boolean done; String submission; }
@@ -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);
@@ -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-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)} 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-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)}} @media(max-width:700px){.mf-a11y-panel{inset:70px 6px auto 6px!important;width:auto;max-height:calc(100dvh - 82px)}}
+6 -4
View File
@@ -16,15 +16,17 @@
const drawers = ['junior','senior','principal'].map(level => { const drawers = ['junior','senior','principal'].map(level => {
const cases = catalog.map((hunt, i) => ({hunt, i})).filter(entry => entry.hunt.difficulty === level); const cases = catalog.map((hunt, i) => ({hunt, i})).filter(entry => entry.hunt.difficulty === level);
if (!cases.length) return ''; if (!cases.length) return '';
return `<div class="bh-cabinet-level">${difficultyNames[level]}</div>${cases.map(({hunt, i}) => `<button class="bh-drawer ${i === selectedCase ? 'active' : ''}" data-select="${i}" ${i === selectedCase ? 'aria-pressed="true"' : 'aria-pressed="false"'}><span class="bh-drawer-handle"></span><span class="bh-case-no">${esc(hunt.examScope)} · ${esc(hunt.difficulty).toUpperCase()}</span><strong>${esc(hunt.title)}</strong><small>${hunt.bugCount} Bug${hunt.bugCount === 1 ? '' : 's'} · ${hunt.fileCount} ${hunt.fileCount === 1 ? 'Datei' : 'Dateien'}</small></button>`).join('')}`; return `<div class="bh-cabinet-level">${difficultyNames[level]}</div>${cases.map(({hunt, i}) => `<button class="bh-drawer ${hunt.solved ? 'solved' : ''} ${i === selectedCase ? 'active' : ''}" data-select="${i}" ${i === selectedCase ? 'aria-pressed="true"' : 'aria-pressed="false"'}><span class="bh-drawer-handle"></span>${hunt.solved ? '<span class="bh-solved-stamp">GELÖST</span>' : ''}<span class="bh-case-no">${esc(hunt.examScope)} · ${esc(hunt.difficulty).toUpperCase()}</span><strong>${esc(hunt.title)}</strong><small>${hunt.bugCount} Bug${hunt.bugCount === 1 ? '' : 's'} · ${hunt.fileCount} ${hunt.fileCount === 1 ? 'Datei' : 'Dateien'}</small></button>`).join('')}`;
}).join(''); }).join('');
view.innerHTML = `<section class="bh-hero"><span class="bh-stamp">VERTRAULICH</span><span class="bh-kicker">DEBUG-FALLAKTEN</span><h1>Welche Spur verfolgst du?</h1><p>Öffne eine Akte, lies die Beweise und sichere deinen Befund Schritt für Schritt. Jede Akte bleibt so lange offen, wie du sie brauchst.</p></section><section class="bh-archive"><div class="bh-archive-head"><div><span class="bh-kicker">AKTENSCHRANK · ${catalog.length} AKTEN</span><h2>Fallarchiv</h2></div><p>Nach Schwierigkeit sortiert: Grundlagen, Praxis, Komplexfälle.</p></div><div class="bh-cabinet" aria-label="Fallakten nach Schwierigkeit"><div class="bh-cabinet-label">BUGHUNTER · ARCHIV</div>${drawers}</div><article class="bh-case-detail"><span class="bh-stamp">AUSGEWÄHLTE AKTE</span><span class="bh-kicker">${esc(selectedHunt.examScope)} · ${esc(selectedHunt.difficulty).toUpperCase()} · ${esc(selectedHunt.language)}</span><h2>${esc(selectedHunt.title)}</h2><p>${esc(selectedHunt.description)}</p><div class="bh-tags"><span>${selectedHunt.fileCount} ${selectedHunt.fileCount === 1 ? 'Datei' : 'Dateien'}</span><span>${selectedHunt.bugCount} Bug${selectedHunt.bugCount === 1 ? '' : 's'}</span></div><button class="bh-btn primary" data-start="${esc(selectedHunt.slug)}">AKTE ÖFFNEN</button></article></section>`; view.innerHTML = `<section class="bh-hero"><span class="bh-stamp">VERTRAULICH</span><span class="bh-kicker">DEBUG-FALLAKTEN</span><h1>Welche Spur verfolgst du?</h1><p>Öffne eine Akte, lies die Beweise und sichere deinen Befund Schritt für Schritt. Jede Akte bleibt so lange offen, wie du sie brauchst.</p></section><section class="bh-archive"><div class="bh-archive-head"><div><span class="bh-kicker">AKTENSCHRANK · ${catalog.length} AKTEN</span><h2>Fallarchiv</h2></div><p>Nach Schwierigkeit sortiert: Grundlagen, Praxis, Komplexfälle.</p></div><div class="bh-cabinet" aria-label="Fallakten nach Schwierigkeit"><div class="bh-cabinet-label">BUGHUNTER · ARCHIV</div>${drawers}</div><article class="bh-case-detail">${selectedHunt.solved ? '<span class="bh-solved-stamp bh-solved-stamp-detail">GELÖST</span>' : ''}<span class="bh-stamp">AUSGEWÄHLTE AKTE</span><span class="bh-kicker">${esc(selectedHunt.examScope)} · ${esc(selectedHunt.difficulty).toUpperCase()} · ${esc(selectedHunt.language)}</span><h2>${esc(selectedHunt.title)}</h2><p>${esc(selectedHunt.description)}</p><div class="bh-tags"><span>${selectedHunt.fileCount} ${selectedHunt.fileCount === 1 ? 'Datei' : 'Dateien'}</span><span>${selectedHunt.bugCount} Bug${selectedHunt.bugCount === 1 ? '' : 's'}</span></div><div class="bh-actions"><button class="bh-btn primary" data-start="${esc(selectedHunt.slug)}">${selectedHunt.solved ? 'ERNEUT SPIELEN' : 'AKTE ÖFFNEN'}</button>${selectedHunt.solved ? `<button class="bh-btn" data-reset="${esc(selectedHunt.slug)}">FALL VARIIEREN</button>` : ''}</div></article></section>`;
view.querySelectorAll('[data-select]').forEach(drawer => drawer.addEventListener('click', () => { selectedCase = Number(drawer.dataset.select); renderCatalog(); })); 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-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 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() { function renderBriefing() {
view.innerHTML = `<section class="bh-briefing"><span class="bh-stamp">NEUE AKTE</span><span class="bh-kicker">${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()} · ${esc(session.language)}</span><h1>${esc(session.title)}</h1><p>${esc(session.briefing)}</p><pre>${esc(session.consoleOutput)}</pre><div class="bh-rules"><div><b>1</b> Ort markieren</div><div><b>2</b> Fehler einordnen</div><div><b>3</b> sauberen Fix wählen</div></div><button class="bh-btn primary" id="bhStart">ERMITTLUNG STARTEN</button></section>`; view.innerHTML = `<section class="bh-briefing"><span class="bh-stamp">NEUE AKTE</span><span class="bh-kicker">${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()} · ${esc(session.language)}${session.variant ? ` · VARIANTE ${session.variant + 1}` : ''}</span><h1>${esc(session.title)}</h1><p>${esc(session.briefing)}</p><pre>${esc(session.consoleOutput)}</pre><div class="bh-rules"><div><b>1</b> Ort markieren</div><div><b>2</b> Fehler einordnen</div><div><b>3</b> sauberen Fix wählen</div></div><button class="bh-btn primary" id="bhStart">ERMITTLUNG STARTEN</button></section>`;
document.getElementById('bhStart').addEventListener('click', () => { selectedFile = session.files[0]?.filename || ''; selectedLine = 0; selected = ''; options = []; renderHunt(); }); 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 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 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 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 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 = `<section class="bh-result"><span class="bh-stamp">ERMITTLUNG ABGESCHLOSSEN</span><h1>Fallakten geschlossen.</h1><div class="bh-score">${result.score}</div><p class="bh-stars">${'★'.repeat(result.stars)}${'☆'.repeat(3-result.stars)}</p><div class="bh-stats"><div><b>${result.baseScore}</b>Basis</div><div><b>+${result.comboBonus}</b>Combo</div><div><b>${result.bugsSolved}/${result.totalBugs}</b>gelöst</div><div><b>×${result.maxCombo}</b>Serie</div><div><b>${result.highscore}</b>Highscore</div><div><b>${result.plays}</b>Akten</div></div><button class="bh-btn primary" id="bhAgain">WEITERE FÄLLE</button></section>`; document.getElementById('bhAgain').addEventListener('click', loadCatalog); } function renderResult(result) { view.innerHTML = `<section class="bh-result">${result.cleanSolve ? '<span class="bh-solved-stamp bh-solved-stamp-result">GELÖST</span>' : ''}<span class="bh-stamp">ERMITTLUNG ABGESCHLOSSEN</span><h1>${result.cleanSolve ? 'Fall fehlerfrei geschlossen.' : 'Fallakten geschlossen.'}</h1><div class="bh-score">${result.score}</div><p class="bh-stars">${'★'.repeat(result.stars)}${'☆'.repeat(3-result.stars)}</p><div class="bh-stats"><div><b>${result.baseScore}</b>Basis</div><div><b>+${result.comboBonus}</b>Combo</div><div><b>${result.bugsSolved}/${result.totalBugs}</b>gelöst</div><div><b>×${result.maxCombo}</b>Serie</div><div><b>${result.highscore}</b>Highscore</div><div><b>${result.plays}</b>Akten</div></div><button class="bh-btn primary" id="bhAgain">WEITERE FÄLLE</button></section>`; 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 feedback(message, type) { const target = document.getElementById('bhFeedback'); if (target) { target.textContent = message; target.className = 'bh-feedback ' + type; } }
function error(err) { view.innerHTML = `<section class="bh-result"><h1>AKTE NICHT LESBAR</h1><p>${esc(err.message || err)}</p><button class="bh-btn" id="bhReload">ERNEUT VERSUCHEN</button></section>`; document.getElementById('bhReload').addEventListener('click', loadCatalog); } function error(err) { view.innerHTML = `<section class="bh-result"><h1>AKTE NICHT LESBAR</h1><p>${esc(err.message || err)}</p><button class="bh-btn" id="bhReload">ERNEUT VERSUCHEN</button></section>`; document.getElementById('bhReload').addEventListener('click', loadCatalog); }
loadCatalog(); loadCatalog();
+1 -1
View File
@@ -32,7 +32,7 @@
document.head.appendChild(accessibilityCss); document.head.appendChild(accessibilityCss);
var accessibilityPlacementCss = document.createElement('link'); var accessibilityPlacementCss = document.createElement('link');
accessibilityPlacementCss.rel = 'stylesheet'; accessibilityPlacementCss.rel = 'stylesheet';
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=4'; accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=5';
accessibilityPlacementCss.setAttribute('data-accessibility', ''); accessibilityPlacementCss.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityPlacementCss); document.head.appendChild(accessibilityPlacementCss);
} }
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>BugHunter · Mindflux</title><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/bughunter.css?v=2"><script src="/js/theme.js"></script></head><body><main class="bh-app"><header class="bh-top"><a href="/dashboard" class="bh-back">← Dashboard</a><div class="bh-brand"><b>BUGHUNTER</b><span>DEBUG-FALLAKTEN</span></div><span class="mf-a11y-host"></span></header><section id="bhView" class="bh-view"><div class="bh-loading">FALLAKTEN WERDEN GEÖFFNET …</div></section></main><script src="/js/bughunter.js?v=4"></script></body></html> <!doctype html><html lang="de" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>BugHunter · Mindflux</title><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/bughunter.css?v=2"><script src="/js/theme.js"></script></head><body><main class="bh-app"><header class="bh-top"><a href="/dashboard" class="bh-back">← Dashboard</a><div class="bh-brand"><b>BUGHUNTER</b><span>DEBUG-FALLAKTEN</span></div><span class="mf-a11y-host"></span></header><section id="bhView" class="bh-view"><div class="bh-loading">FALLAKTEN WERDEN GEÖFFNET …</div></section></main><script src="/js/bughunter.js?v=5"></script></body></html>
@@ -12,10 +12,10 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class BugHunterServiceTest { 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; ObjectMapper mapper = new ObjectMapper(); BugHunterService service; BugHunterHunt hunt; BugHunterBug bug; MockHttpSession session;
@BeforeEach void setup() { @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 = 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)); 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); 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)); when(scores.recordScore(anyString(), anyString(), anyString(), anyInt(), anyInt(), anyInt())).thenAnswer(call -> new ScoreResult(call.getArgument(3), call.getArgument(3), true, 1));
} }
@Test void sessionLeaksNeitherLocationCategoryNorFix() { @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();")); assertFalse(raw.contains("\"line\"")); assertFalse(raw.contains("category")); assertFalse(raw.contains("fixOptions")); assertFalse(raw.contains("fixed();"));
} }
@Test void phasesAreGatedAndScoreUsesServerRecordedAttempts() { @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)); 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()); 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); JsonNode classify = service.check(2L, "classify", false, mapper.createObjectNode().put("category", "logik"), session);
@@ -39,7 +39,7 @@ class BugHunterServiceTest {
} }
@Test void foreignBugAndUnstartedCheckAreRejected() { @Test void foreignBugAndUnstartedCheckAreRejected() {
assertThrows(IllegalArgumentException.class, () -> service.check(2L, "locate", false, mapper.createObjectNode(), session)); 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)); assertThrows(IllegalArgumentException.class, () -> service.check(99L, "locate", false, mapper.createObjectNode(), session));
} }
} }