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 {
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); }
}
@@ -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) { }
@@ -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.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<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());
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<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");
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<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);
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;
}
@@ -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<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()); }
}
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<Long> bugIds; 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; }
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, 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()); }
}
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-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)}}
+6 -4
View File
@@ -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 `<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('');
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.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 = `<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(); });
}
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 = `<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 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();
+1 -1
View File
@@ -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);
}
+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.*;
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));
}
}