@@ -0,0 +1,31 @@
|
||||
package com.example.mindflux.config;
|
||||
|
||||
import com.example.mindflux.model.*;
|
||||
import com.example.mindflux.repository.BugHunterHuntRepository;
|
||||
import com.fasterxml.jackson.databind.*;
|
||||
import org.slf4j.*;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Erstbefüllung der Fallakten. Der vollständige Bug-Knoten bleibt als serverseitiger Payload erhalten. */
|
||||
@Component
|
||||
public class BugHunterSeeder implements CommandLineRunner {
|
||||
private static final Logger log = LoggerFactory.getLogger(BugHunterSeeder.class);
|
||||
private final BugHunterHuntRepository hunts; private final ObjectMapper mapper;
|
||||
public BugHunterSeeder(BugHunterHuntRepository hunts, ObjectMapper mapper) { this.hunts = hunts; this.mapper = mapper; }
|
||||
@Override @Transactional public void run(String... args) {
|
||||
if (hunts.count() > 0) return;
|
||||
try {
|
||||
JsonNode root = mapper.readTree(new ClassPathResource("bughunter/content.json").getInputStream()); int order = 0, count = 0;
|
||||
for (JsonNode node : root.path("hunts")) {
|
||||
BugHunterHunt hunt = new BugHunterHunt(node.path("slug").asText(), node.path("title").asText(), node.path("language").asText("Java"), node.path("difficulty").asText("junior"), node.path("examScope").asText("AP1"), node.path("description").asText(""), node.path("briefing").asText(""), node.path("consoleOutput").asText(""), order++);
|
||||
int fileOrder = 0; for (JsonNode file : node.path("files")) hunt.addFile(new BugHunterFile(file.path("filename").asText(), file.path("content").asText(), fileOrder++));
|
||||
int bugOrder = 0; for (JsonNode bug : node.path("bugs")) { String payload = mapper.writeValueAsString(bug); if (payload.length() <= 8000) { hunt.addBug(new BugHunterBug(bug.path("points").asInt(100), bugOrder++, payload)); count++; } }
|
||||
if (!hunt.getFiles().isEmpty() && !hunt.getBugs().isEmpty()) hunts.save(hunt);
|
||||
}
|
||||
log.info("BugHunter: {} Fallakten geladen.", count);
|
||||
} catch (Exception ex) { log.error("BugHunter-Seed fehlgeschlagen", ex); }
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf
|
||||
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
||||
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
|
||||
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**")
|
||||
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**")
|
||||
);
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.mindflux.controller;
|
||||
|
||||
import com.example.mindflux.service.BugHunterService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Controller @RequestMapping("/admin/bughunter")
|
||||
public class AdminBugHunterController {
|
||||
private final BugHunterService service;
|
||||
public AdminBugHunterController(BugHunterService service) { this.service = service; }
|
||||
@GetMapping public String list(Model model) { model.addAttribute("hunts", service.all()); return "admin/bughunter"; }
|
||||
@PostMapping("/add") public String add(@RequestParam String title, @RequestParam String language, @RequestParam String difficulty, @RequestParam String examScope, @RequestParam(required = false) String description, @RequestParam(required = false) String briefing, @RequestParam(required = false) String consoleOutput) { Long id = service.addHunt(title, language, difficulty, examScope, description, briefing, consoleOutput); return id == null ? "redirect:/admin/bughunter" : "redirect:/admin/bughunter/" + id; }
|
||||
@GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { model.addAttribute("hunt", service.hunt(id)); return "admin/bughunter-hunt"; }
|
||||
@PostMapping("/{id}/files") public String addFile(@PathVariable Long id, @RequestParam String filename, @RequestParam String content) { service.addFile(id, filename, content); return "redirect:/admin/bughunter/" + id; }
|
||||
@PostMapping("/{id}/bugs") public String addBug(@PathVariable Long id, @RequestParam(defaultValue = "100") int points, @RequestParam String payload) { service.addBug(id, points, payload); return "redirect:/admin/bughunter/" + id; }
|
||||
@PostMapping("/{id}/toggle") public String toggle(@PathVariable Long id) { service.toggle(id); return "redirect:/admin/bughunter"; }
|
||||
@PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { service.deleteHunt(id); return "redirect:/admin/bughunter"; }
|
||||
@PostMapping("/files/{id}/delete") public String deleteFile(@PathVariable Long id, @RequestParam Long huntId) { service.deleteFile(id); return "redirect:/admin/bughunter/" + huntId; }
|
||||
@PostMapping("/bugs/{id}/delete") public String deleteBug(@PathVariable Long id, @RequestParam Long huntId) { service.deleteBug(id); return "redirect:/admin/bughunter/" + huntId; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.mindflux.controller;
|
||||
|
||||
import com.example.mindflux.dto.*;
|
||||
import com.example.mindflux.service.BugHunterService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.security.Principal;
|
||||
|
||||
@RestController @RequestMapping("/api/bughunter")
|
||||
public class BugHunterApiController {
|
||||
private final BugHunterService service;
|
||||
public BugHunterApiController(BugHunterService service) { this.service = service; }
|
||||
@GetMapping("/hunts") public JsonNode catalog() { return service.catalog(); }
|
||||
@GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug, HttpSession httpSession) { return service.session(slug, httpSession); }
|
||||
@PostMapping("/check") public JsonNode check(@RequestBody BugHunterCheckRequest request, HttpSession httpSession) { return service.check(request.getBugId(), request.getPhase(), request.isReveal(), request.getSubmission(), httpSession); }
|
||||
@PostMapping("/finish") public BugHunterResult finish(@RequestBody BugHunterFinishRequest request, Principal principal, HttpSession httpSession) { return service.finish(principal.getName(), request, httpSession); }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.mindflux.controller;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@Controller public class BugHunterController { @GetMapping("/bughunter") public String page() { return "bughunter"; } }
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.mindflux.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
public class BugHunterCheckRequest {
|
||||
private Long bugId;
|
||||
private String phase;
|
||||
private boolean reveal;
|
||||
private JsonNode submission;
|
||||
public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; }
|
||||
public String getPhase() { return phase; } public void setPhase(String value) { phase = value; }
|
||||
public boolean isReveal() { return reveal; } public void setReveal(boolean value) { reveal = value; }
|
||||
public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.mindflux.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.List;
|
||||
|
||||
/** Rohantworten eines BugHunter-Durchlaufs; die Wertung wird komplett serverseitig rekonstruiert. */
|
||||
public class BugHunterFinishRequest {
|
||||
private String slug;
|
||||
private int durationSeconds;
|
||||
private List<Item> items;
|
||||
public String getSlug() { return slug; } public void setSlug(String value) { slug = value; }
|
||||
public int getDurationSeconds() { return durationSeconds; } public void setDurationSeconds(int value) { durationSeconds = value; }
|
||||
public List<Item> getItems() { return items; } public void setItems(List<Item> value) { items = value; }
|
||||
public static class Item {
|
||||
private Long bugId;
|
||||
private Phase locate;
|
||||
private Phase classify;
|
||||
private Phase fix;
|
||||
public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; }
|
||||
public Phase getLocate() { return locate; } public void setLocate(Phase value) { locate = value; }
|
||||
public Phase getClassify() { return classify; } public void setClassify(Phase value) { classify = value; }
|
||||
public Phase getFix() { return fix; } public void setFix(Phase value) { fix = value; }
|
||||
}
|
||||
public static class Phase {
|
||||
private JsonNode submission;
|
||||
private int attempts;
|
||||
private boolean revealed;
|
||||
public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; }
|
||||
public int getAttempts() { return attempts; } public void setAttempts(int value) { attempts = value; }
|
||||
public boolean isRevealed() { return revealed; } public void setRevealed(boolean value) { revealed = value; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.mindflux.dto;
|
||||
|
||||
public record BugHunterResult(int score, int baseScore, int maxScore, int comboBonus, int maxCombo,
|
||||
int bugsSolved, int totalBugs, int stars, int highscore,
|
||||
boolean newHighscore, long plays) { }
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.mindflux.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity @Table(name = "bughunter_bugs")
|
||||
public class BugHunterBug {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt;
|
||||
private int points = 100;
|
||||
@Column(name = "sort_order") private int sortOrder;
|
||||
@Column(length = 8000) private String payload;
|
||||
public BugHunterBug() { }
|
||||
public BugHunterBug(int points, int sortOrder, String payload) { this.points = points; this.sortOrder = sortOrder; this.payload = payload; }
|
||||
public Long getId() { return id; } public void setId(Long value) { id = value; }
|
||||
public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; }
|
||||
public int getPoints() { return points; } public void setPoints(int value) { points = value; }
|
||||
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||
public String getPayload() { return payload; } public void setPayload(String value) { payload = value; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.mindflux.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity @Table(name = "bughunter_files")
|
||||
public class BugHunterFile {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt;
|
||||
private String filename;
|
||||
@Column(length = 8000) private String content;
|
||||
@Column(name = "sort_order") private int sortOrder;
|
||||
public BugHunterFile() { }
|
||||
public BugHunterFile(String filename, String content, int sortOrder) { this.filename = filename; this.content = content; this.sortOrder = sortOrder; }
|
||||
public Long getId() { return id; } public void setId(Long value) { id = value; }
|
||||
public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; }
|
||||
public String getFilename() { return filename; } public void setFilename(String value) { filename = value; }
|
||||
public String getContent() { return content; } public void setContent(String value) { content = value; }
|
||||
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.mindflux.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.*;
|
||||
|
||||
@Entity @Table(name = "bughunter_hunts")
|
||||
public class BugHunterHunt {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||
@Column(nullable = false, unique = true) private String slug;
|
||||
@Column(nullable = false) private String title;
|
||||
@Column(name = "code_language") private String codeLanguage;
|
||||
private String difficulty;
|
||||
@Column(name = "exam_scope") private String examScope = "AP1";
|
||||
private String description;
|
||||
@Column(length = 2000) private String briefing;
|
||||
@Column(name = "console_output", length = 4000) private String consoleOutput;
|
||||
@Column(name = "sort_order") private int sortOrder;
|
||||
private boolean active = true;
|
||||
@OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("sortOrder ASC, id ASC") private List<BugHunterFile> files = new ArrayList<>();
|
||||
@OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("sortOrder ASC, id ASC") private List<BugHunterBug> bugs = new ArrayList<>();
|
||||
|
||||
public BugHunterHunt() { }
|
||||
public BugHunterHunt(String slug, String title, String codeLanguage, String difficulty, String examScope,
|
||||
String description, String briefing, String consoleOutput, int sortOrder) {
|
||||
this.slug = slug; this.title = title; this.codeLanguage = codeLanguage; this.difficulty = difficulty;
|
||||
this.examScope = examScope; this.description = description; this.briefing = briefing;
|
||||
this.consoleOutput = consoleOutput; this.sortOrder = sortOrder;
|
||||
}
|
||||
public void addFile(BugHunterFile file) { file.setHunt(this); files.add(file); }
|
||||
public void addBug(BugHunterBug bug) { bug.setHunt(this); bugs.add(bug); }
|
||||
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
|
||||
public String getSlug() { return slug; } public void setSlug(String slug) { this.slug = slug; }
|
||||
public String getTitle() { return title; } public void setTitle(String title) { this.title = title; }
|
||||
public String getCodeLanguage() { return codeLanguage; } public void setCodeLanguage(String value) { codeLanguage = value; }
|
||||
public String getDifficulty() { return difficulty; } public void setDifficulty(String value) { difficulty = value; }
|
||||
public String getExamScope() { return examScope; } public void setExamScope(String value) { examScope = value; }
|
||||
public String getDescription() { return description; } public void setDescription(String value) { description = value; }
|
||||
public String getBriefing() { return briefing; } public void setBriefing(String value) { briefing = value; }
|
||||
public String getConsoleOutput() { return consoleOutput; } public void setConsoleOutput(String value) { consoleOutput = value; }
|
||||
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||
public boolean isActive() { return active; } public void setActive(boolean value) { active = value; }
|
||||
public List<BugHunterFile> getFiles() { return files; } public List<BugHunterBug> getBugs() { return bugs; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.mindflux.repository;
|
||||
import com.example.mindflux.model.BugHunterBug;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface BugHunterBugRepository extends JpaRepository<BugHunterBug, Long> { long countByHuntId(Long huntId); }
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.mindflux.repository;
|
||||
import com.example.mindflux.model.BugHunterFile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface BugHunterFileRepository extends JpaRepository<BugHunterFile, Long> { long countByHuntId(Long huntId); }
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.mindflux.repository;
|
||||
import com.example.mindflux.model.BugHunterHunt;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.*;
|
||||
public interface BugHunterHuntRepository extends JpaRepository<BugHunterHunt, Long> {
|
||||
List<BugHunterHunt> findAllByActiveTrueOrderBySortOrderAscIdAsc();
|
||||
List<BugHunterHunt> findAllByOrderBySortOrderAscIdAsc();
|
||||
Optional<BugHunterHunt> findBySlug(String slug);
|
||||
boolean existsBySlug(String slug);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.example.mindflux.service;
|
||||
|
||||
import com.example.mindflux.dto.*;
|
||||
import com.example.mindflux.model.*;
|
||||
import com.example.mindflux.repository.*;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.util.*;
|
||||
|
||||
/** Serverseitige Drei-Phasen-Ermittlung für BugHunter. Lösungsdaten bleiben im Bug-Payload. */
|
||||
@Service
|
||||
public class BugHunterService {
|
||||
private static final String GAME = "bughunter";
|
||||
private static final Set<String> CATEGORIES = Set.of("syntax", "logik", "laufzeit", "sicherheit");
|
||||
private static final Set<String> SCOPES = Set.of("AP1", "AP2_FIAE", "AP2_FISI", "CROSS");
|
||||
private static final int MAX_DURATION_SECONDS = 14_400;
|
||||
private final BugHunterHuntRepository hunts;
|
||||
private final BugHunterFileRepository files;
|
||||
private final BugHunterBugRepository bugs;
|
||||
private final GameScoreService scores;
|
||||
private final ObjectMapper mapper;
|
||||
private final Random random = new Random();
|
||||
|
||||
public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs,
|
||||
GameScoreService scores, ObjectMapper mapper) {
|
||||
this.hunts = hunts; this.files = files; this.bugs = bugs; this.scores = scores; this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public JsonNode catalog() {
|
||||
ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts");
|
||||
for (BugHunterHunt hunt : hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc()) {
|
||||
if (hunt.getBugs().isEmpty() || hunt.getFiles().isEmpty()) continue;
|
||||
ObjectNode row = result.addObject(); row.put("slug", hunt.getSlug()); row.put("title", hunt.getTitle());
|
||||
row.put("language", hunt.getCodeLanguage()); row.put("difficulty", hunt.getDifficulty());
|
||||
row.put("examScope", hunt.getExamScope()); row.put("description", hunt.getDescription());
|
||||
row.put("bugCount", hunt.getBugs().size()); row.put("fileCount", hunt.getFiles().size());
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public JsonNode session(String slug, HttpSession httpSession) {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
BugHunterHunt hunt = hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElse(null);
|
||||
if (hunt == null) { root.put("found", false); return root; }
|
||||
root.put("found", true); root.put("slug", hunt.getSlug()); root.put("title", hunt.getTitle());
|
||||
root.put("language", hunt.getCodeLanguage()); root.put("difficulty", hunt.getDifficulty()); root.put("examScope", hunt.getExamScope());
|
||||
root.put("description", nullToEmpty(hunt.getDescription())); root.put("briefing", nullToEmpty(hunt.getBriefing()));
|
||||
root.put("consoleOutput", nullToEmpty(hunt.getConsoleOutput()));
|
||||
ArrayNode fs = root.putArray("files");
|
||||
for (BugHunterFile file : hunt.getFiles()) { ObjectNode f = fs.addObject(); f.put("filename", file.getFilename()); ArrayNode lines = f.putArray("lines"); for (String line : file.getContent().split("\\R", -1)) lines.add(line); }
|
||||
ArrayNode bs = root.putArray("bugs"); int maxScore = 0;
|
||||
for (BugHunterBug bug : hunt.getBugs()) { JsonNode payload = parse(bug.getPayload()); if (payload == null) continue; ObjectNode b = bs.addObject(); b.put("bugId", bug.getId()); b.put("points", bug.getPoints()); b.put("evidence", payload.path("evidence").asText("")); maxScore += bug.getPoints(); }
|
||||
root.put("totalBugs", bs.size()); root.put("maxScore", maxScore);
|
||||
Set<Long> bugIds = new HashSet<>(); bs.forEach(b -> bugIds.add(b.path("bugId").asLong()));
|
||||
httpSession.setAttribute("bughunter.run", new RunState(hunt.getSlug(), bugIds, System.currentTimeMillis()));
|
||||
return root;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public JsonNode check(Long bugId, String phase, boolean reveal, JsonNode submission, HttpSession httpSession) {
|
||||
if (!isPhase(phase)) throw new IllegalArgumentException("Unbekannte Ermittlungsphase.");
|
||||
RunState run = run(httpSession);
|
||||
if (bugId == null || !run.bugIds.contains(bugId)) throw new IllegalArgumentException("Bug gehört nicht zu dieser Fallakte.");
|
||||
BugHunterBug bug = bugId == null ? null : bugs.findById(bugId).orElse(null);
|
||||
JsonNode payload = bug == null ? null : parse(bug.getPayload());
|
||||
if (payload == null) throw new IllegalArgumentException("Fallakte nicht gefunden.");
|
||||
PhaseState phaseState = run.state(bugId, phase);
|
||||
if ((phase.equals("classify") && !run.state(bugId, "locate").done) || (phase.equals("fix") && !run.state(bugId, "classify").done)) throw new IllegalArgumentException("Schließe zuerst die vorherige Ermittlungsphase ab.");
|
||||
if (phaseState.done) throw new IllegalArgumentException("Diese Ermittlungsphase ist bereits abgeschlossen.");
|
||||
if (!reveal && phaseState.attempts >= 3) throw new IllegalArgumentException("Nach drei Fehlversuchen kannst du die Auflösung anzeigen.");
|
||||
JsonNode sub = submission == null ? mapper.createObjectNode() : submission;
|
||||
boolean correct = switch (phase) { case "locate" -> locationMatches(payload, sub); case "classify" -> categoryMatches(payload, sub); case "fix" -> fixMatches(payload, sub); default -> false; };
|
||||
if (reveal) { phaseState.revealed = true; phaseState.done = true; }
|
||||
else { phaseState.attempts++; phaseState.submission = sub.toString(); if (correct) phaseState.done = true; }
|
||||
ObjectNode result = mapper.createObjectNode(); result.put("correct", correct); result.put("resolved", correct || reveal); result.put("phase", phase);
|
||||
if (correct && payload.hasNonNull("successText")) result.put("successText", payload.get("successText").asText());
|
||||
if (correct || reveal) addPhaseResolution(result, payload, phase);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BugHunterResult finish(String username, BugHunterFinishRequest request, HttpSession httpSession) {
|
||||
if (request == null || request.getSlug() == null || request.getSlug().isBlank()) throw new IllegalArgumentException("Fallakte fehlt.");
|
||||
if (request.getDurationSeconds() < 1 || request.getDurationSeconds() > MAX_DURATION_SECONDS) throw new IllegalArgumentException("Unplausible Spieldauer.");
|
||||
BugHunterHunt hunt = hunts.findBySlug(request.getSlug()).filter(BugHunterHunt::isActive)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Fallakte nicht gefunden."));
|
||||
RunState run = run(httpSession);
|
||||
if (!hunt.getSlug().equals(run.slug)) throw new IllegalArgumentException("Die Fallakte wurde nicht angefordert.");
|
||||
List<BugHunterFinishRequest.Item> submitted = request.getItems() == null ? List.of() : request.getItems();
|
||||
if (submitted.size() > hunt.getBugs().size()) throw new IllegalArgumentException("Zu viele Bugs eingereicht.");
|
||||
Map<Long, BugHunterFinishRequest.Item> byId = new HashMap<>();
|
||||
Set<Long> allowed = new HashSet<>(); for (BugHunterBug bug : hunt.getBugs()) allowed.add(bug.getId());
|
||||
for (BugHunterFinishRequest.Item item : submitted) {
|
||||
if (item == null || item.getBugId() == null || !allowed.contains(item.getBugId()) || byId.putIfAbsent(item.getBugId(), item) != null)
|
||||
throw new IllegalArgumentException("Ungültige oder doppelte Bug-ID.");
|
||||
}
|
||||
int base = 0, max = 0, solved = 0, combo = 0, maxCombo = 0, comboBonus = 0;
|
||||
for (BugHunterBug bug : hunt.getBugs()) {
|
||||
max += bug.getPoints(); BugHunterFinishRequest.Item item = byId.get(bug.getId());
|
||||
if (item == null) { combo = 0; continue; }
|
||||
JsonNode payload = parse(bug.getPayload()); if (payload == null) { combo = 0; continue; }
|
||||
PhaseState located = run.state(bug.getId(), "locate"), classified = run.state(bug.getId(), "classify"), fixed = run.state(bug.getId(), "fix");
|
||||
boolean locate = phaseCorrect(payload, "locate", located);
|
||||
boolean classify = phaseCorrect(payload, "classify", classified);
|
||||
boolean fix = phaseCorrect(payload, "fix", fixed);
|
||||
base += award(bug.getPoints(), .4, located, locate);
|
||||
base += award(bug.getPoints(), .2, classified, classify);
|
||||
base += award(bug.getPoints(), .4, fixed, fix);
|
||||
if (locate && classify && fix) solved++;
|
||||
if (isClean(located, locate) && isClean(classified, classify) && isClean(fixed, fix)) {
|
||||
combo++; maxCombo = Math.max(maxCombo, combo); comboBonus += (int) Math.round(bug.getPoints() * (Math.min(combo, 4) - 1) * .1);
|
||||
} else combo = 0;
|
||||
}
|
||||
int stars = stars(base, max); ScoreResult saved = scores.recordScore(username, GAME, hunt.getSlug(), base + comboBonus, solved, request.getDurationSeconds());
|
||||
httpSession.removeAttribute("bughunter.run");
|
||||
return new BugHunterResult(base + comboBonus, base, max, comboBonus, maxCombo, solved, hunt.getBugs().size(), stars, saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true) public List<BugHunterHunt> all() { return hunts.findAllByOrderBySortOrderAscIdAsc(); }
|
||||
@Transactional(readOnly = true) public BugHunterHunt hunt(Long id) { BugHunterHunt hunt = hunts.findById(id).orElse(null); if (hunt != null) { hunt.getFiles().size(); hunt.getBugs().size(); } return hunt; }
|
||||
@Transactional
|
||||
public Long addHunt(String title, String language, String difficulty, String examScope, String description, String briefing, String consoleOutput) {
|
||||
if (blank(title)) return null; String clean = title.trim();
|
||||
BugHunterHunt hunt = new BugHunterHunt(uniqueSlug(clean), clean, cap(language, 50, "Java"), normalizeDifficulty(difficulty), normalizeScope(examScope), cap(description, 1200, ""), cap(briefing, 2000, ""), cap(consoleOutput, 4000, ""), (int) hunts.count() + 1);
|
||||
return hunts.save(hunt).getId();
|
||||
}
|
||||
@Transactional public void addFile(Long huntId, String filename, String content) { hunts.findById(huntId).ifPresent(hunt -> { if (!blank(filename) && content != null && content.length() <= 8000) { hunt.addFile(new BugHunterFile(cap(filename, 255, ""), content, hunt.getFiles().size())); hunts.save(hunt); } }); }
|
||||
@Transactional public void addBug(Long huntId, int points, String payload) { hunts.findById(huntId).ifPresent(hunt -> { if (validPayload(hunt, payload)) { hunt.addBug(new BugHunterBug(normalizePoints(points), hunt.getBugs().size(), payload.trim())); hunts.save(hunt); } }); }
|
||||
@Transactional public void deleteHunt(Long id) { hunts.deleteById(id); }
|
||||
@Transactional public void deleteFile(Long id) { files.deleteById(id); }
|
||||
@Transactional public void deleteBug(Long id) { bugs.deleteById(id); }
|
||||
@Transactional public void toggle(Long id) { hunts.findById(id).ifPresent(h -> h.setActive(!h.isActive())); }
|
||||
|
||||
private boolean validPayload(BugHunterHunt hunt, String raw) {
|
||||
JsonNode p = parse(raw); if (p == null || raw.length() > 8000 || blank(p.path("file").asText()) || p.path("line").asInt(0) < 1 || !CATEGORIES.contains(p.path("category").asText()) || !p.path("fixOptions").isArray()) return false;
|
||||
BugHunterFile file = hunt.getFiles().stream().filter(f -> f.getFilename().equals(p.path("file").asText())).findFirst().orElse(null);
|
||||
int index = p.path("fixIndex").asInt(-1); return file != null && p.path("line").asInt() <= file.getContent().split("\\R", -1).length && index >= 0 && index < p.path("fixOptions").size();
|
||||
}
|
||||
private boolean phaseCorrect(JsonNode p, String phase, PhaseState answer) { return answer != null && !answer.revealed && answer.submission != null && matches(p, phase, parse(answer.submission)); }
|
||||
private boolean isClean(PhaseState answer, boolean correct) { return correct && answer != null && answer.attempts == 1 && !answer.revealed; }
|
||||
private int award(int points, double weight, PhaseState answer, boolean correct) { if (!correct || answer == null) return 0; int attempts = Math.max(1, Math.min(answer.attempts, 3)); double factor = attempts == 1 ? 1 : attempts == 2 ? .6 : .3; return (int) Math.round(points * weight * factor); }
|
||||
private boolean matches(JsonNode p, String phase, JsonNode sub) { return switch (phase) { case "locate" -> locationMatches(p, sub); case "classify" -> categoryMatches(p, sub); case "fix" -> fixMatches(p, sub); default -> false; }; }
|
||||
private boolean locationMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("file").asText(), sub.path("file").asText()) && p.path("line").asInt(-1) == sub.path("line").asInt(-2); }
|
||||
private boolean categoryMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("category").asText(), sub.path("category").asText()); }
|
||||
private boolean fixMatches(JsonNode p, JsonNode sub) { int index = p.path("fixIndex").asInt(-1); JsonNode options = p.path("fixOptions"); return index >= 0 && index < options.size() && textEquals(options.get(index).asText(), sub.path("choice").asText()); }
|
||||
private void addPhaseResolution(ObjectNode target, JsonNode p, String phase) {
|
||||
if (phase.equals("locate")) { ObjectNode location = target.putObject("location"); location.put("file", p.path("file").asText()); location.put("line", p.path("line").asInt()); }
|
||||
else if (phase.equals("classify")) { target.put("category", p.path("category").asText()); ArrayNode options = target.putArray("fixOptions"); List<String> shuffled = new ArrayList<>(); p.path("fixOptions").forEach(o -> shuffled.add(o.asText())); Collections.shuffle(shuffled, random); shuffled.forEach(options::add); }
|
||||
else { target.put("fixedLine", p.path("fixedLine").asText()); target.put("explanation", p.path("explanation").asText()); }
|
||||
}
|
||||
private JsonNode parse(String raw) { try { return raw == null || raw.isBlank() ? null : mapper.readTree(raw); } catch (Exception e) { return null; } }
|
||||
private String uniqueSlug(String title) { String base = title.toLowerCase().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss").replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); if (base.isBlank()) base = "hunt"; String result = base; int n = 2; while (hunts.existsBySlug(result)) result = base + "-" + n++; return result; }
|
||||
private int stars(int score, int max) { if (max <= 0 || score <= 0) return 0; double ratio = (double) score / max; return ratio >= .8 ? 3 : ratio >= .5 ? 2 : 1; }
|
||||
private int normalizePoints(int value) { return value <= 0 ? 100 : Math.min(value, 1000); }
|
||||
private String normalizeDifficulty(String value) { return Set.of("junior", "senior", "principal").contains(value) ? value : "junior"; }
|
||||
private String normalizeScope(String value) { return SCOPES.contains(value) ? value : "AP1"; }
|
||||
private boolean isPhase(String value) { return Set.of("locate", "classify", "fix").contains(value); }
|
||||
private boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private String cap(String value, int max, String fallback) { if (value == null || value.isBlank()) return fallback; String clean = value.trim(); return clean.length() <= max ? clean : clean.substring(0, max); }
|
||||
private String nullToEmpty(String value) { return value == null ? "" : value; }
|
||||
private boolean textEquals(String first, String second) { return normalize(first).equalsIgnoreCase(normalize(second)); }
|
||||
private String normalize(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " "); }
|
||||
private RunState run(HttpSession session) { Object value = session.getAttribute("bughunter.run"); if (!(value instanceof RunState run)) throw new IllegalArgumentException("Fordere zuerst eine Fallakte an."); return run; }
|
||||
public static class RunState {
|
||||
final String slug; final Set<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; }
|
||||
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,43 @@
|
||||
{
|
||||
"hunts": [
|
||||
{
|
||||
"slug": "falsche-rechnung", "title": "Fall #1: Die falsche Rechnung", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||
"description": "Eine Rechnung liefert im letzten Monat einen falschen Wert.", "briefing": "Die Monatsauswertung soll alle zwölf Monate addieren. Das Ticket nennt nur einen fehlenden Dezemberwert.", "consoleOutput": "[report] Summe 2025: 11 Monate verarbeitet\n[warn] Erwartet: 12 Monatswerte",
|
||||
"files": [{"filename":"Jahresreport.java","content":"public class Jahresreport {\n static int summe(int[] monate) {\n int total = 0;\n for (int i = 0; i <= monate.length; i++) {\n total += monate[i];\n }\n return total;\n }\n}"}],
|
||||
"bugs": [{"points":100,"file":"Jahresreport.java","line":4,"category":"logik","evidence":"Die Schleife greift nach dem letzten Array-Element zu.","fixOptions":["for (int i = 0; i < monate.length; i++) {","for (int i = 1; i <= monate.length; i++) {","for (int i = 0; i <= monate.length - 1; i += 2) {","for (int i = monate.length; i >= 0; i--) {"],"fixIndex":0,"fixedLine":" for (int i = 0; i < monate.length; i++) {","explanation":"Bei Arrays ist der letzte gültige Index length - 1. Mit < length werden alle Elemente genau einmal verarbeitet."}]
|
||||
},
|
||||
{
|
||||
"slug": "ampel-bleibt-rot", "title": "Fall #2: Die sture Ampel", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||
"description": "Die Ampel zeigt immer Rot – selbst bei einer grünen Konfiguration.", "briefing": "Im Schulungsprojekt wird der Status aus einer Konfigurationsdatei gelesen.", "consoleOutput": "Status aus config: green\nUI-Ausgabe: ROT",
|
||||
"files": [{"filename":"Ampel.java","content":"public class Ampel {\n static String anzeigen(String status) {\n if (status = \"green\") {\n return \"GRÜN\";\n }\n return \"ROT\";\n }\n}"}],
|
||||
"bugs": [{"points":100,"file":"Ampel.java","line":3,"category":"syntax","evidence":"Der Vergleich verändert den Wert statt zwei Werte zu vergleichen.","fixOptions":["if (\"green\".equals(status)) {","if (status := \"green\") {","if (status.equals = \"green\") {","if (status =! \"green\") {"],"fixIndex":0,"fixedLine":" if (\"green\".equals(status)) {","explanation":"equals vergleicht Zeichenketten. Die Literalkonstante links verhindert zusätzlich eine NullPointerException."}]
|
||||
},
|
||||
{
|
||||
"slug": "menue-fallt-durch", "title": "Fall #3: Das fallende Menü", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||
"description": "Der Punkt ‚Status‘ öffnet unerwartet den Abmelde-Dialog.", "briefing": "Ein Azubi meldet einen Fehler im Konsolenmenü. Prüfe die Verzweigungen.", "consoleOutput": "Eingabe: 2\nStatus: OK\nAbmeldung gestartet",
|
||||
"files": [{"filename":"Menue.java","content":"switch (auswahl) {\n case 1:\n System.out.println(\"Start\");\n break;\n case 2:\n System.out.println(\"Status: OK\");\n case 3:\n System.out.println(\"Abmeldung gestartet\");\n break;\n default:\n System.out.println(\"Ungültig\");\n}"}],
|
||||
"bugs": [{"points":100,"file":"Menue.java","line":6,"category":"logik","evidence":"Nach dem Status-Fall wird der nächste case ebenfalls ausgeführt.","fixOptions":[" break;"," continue;"," return false;"," case 2: break;"],"fixIndex":0,"fixedLine":" break;","explanation":"Ohne break fällt ein switch in den nächsten Fall durch (fall-through)."}]
|
||||
},
|
||||
{
|
||||
"slug": "absturz-um-mitternacht", "title": "Fall #4: Der Absturz um Mitternacht", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||
"description": "Ein nächtlicher Job stürzt sporadisch ab.", "briefing": "Der Job läuft um 00:00 Uhr. Der Stacktrace zeigt auf die Formatierung des letzten Ereignisses.", "consoleOutput": "java.lang.NullPointerException: Cannot invoke \"String.toUpperCase()\"\n at Nachtjob.letztesEreignis(Nachtjob.java:5)\n at Nachtjob.main(Nachtjob.java:10)",
|
||||
"files": [{"filename":"Nachtjob.java","content":"public class Nachtjob {\n static String letztesEreignis(String event) {\n if (event == null) {\n System.out.println(\"Kein Ereignis vorhanden\");\n }\n return event.toUpperCase();\n }\n\n public static void main(String[] args) {\n System.out.println(letztesEreignis(null));\n }\n}"}],
|
||||
"bugs": [{"points":150,"file":"Nachtjob.java","line":5,"category":"laufzeit","evidence":"Der Stacktrace verweist direkt auf toUpperCase in Zeile 5.","fixOptions":[" return \"KEIN EREIGNIS\";"," event = event.toUpperCase();"," throw new NullPointerException();"," System.out.println(event.toUpperCase());"],"fixIndex":0,"fixedLine":" return \"KEIN EREIGNIS\";","explanation":"Nach der Null-Prüfung muss die Methode einen sicheren Ersatzwert zurückgeben, bevor event verwendet wird."}]
|
||||
},
|
||||
{
|
||||
"slug": "offenes-login", "title": "Fall #5: Das offene Login", "language": "PHP", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||
"description": "Ein Sicherheitscheck meldet eine manipulierbare Login-Abfrage.", "briefing": "Die Eingabe eines Benutzernamens landet direkt in der SQL-Abfrage. Finde die Schwachstelle und wähle den sicheren Fix.", "consoleOutput": "Scanner: CWE-89 SQL Injection\nTest-Eingabe: ' OR '1'='1",
|
||||
"files": [{"filename":"login.php","content":"<?php\nfunction login($pdo, $name) {\n $sql = \"SELECT id FROM users WHERE name = '$name'\";\n return $pdo->query($sql)->fetch();\n}\n?>"}],
|
||||
"bugs": [{"points":150,"file":"login.php","line":3,"category":"sicherheit","evidence":"Der externe Wert $name wird in einen SQL-String interpoliert.","fixOptions":[" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);"," $name = addslashes($name); $sql = \"SELECT id FROM users WHERE name = '$name'\";"," $sql = \"SELECT id FROM users WHERE name LIKE '%$name%'\";"," $sql = \"SELECT id FROM users WHERE name = \" . $name;"],"fixIndex":0,"fixedLine":" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);","explanation":"Prepared Statements trennen SQL-Code und Nutzdaten. Escaping allein ist kein zuverlässiger Schutz."}]
|
||||
},
|
||||
{
|
||||
"slug": "geteilte-liste", "title": "Fall #6: Die geteilte Liste", "language": "Java", "difficulty": "principal", "examScope": "AP2_FIAE",
|
||||
"description": "Parallele Worker verlieren gelegentlich Aufträge aus ihrer Queue.", "briefing": "Zwei Worker greifen gleichzeitig auf die gemeinsame Auftragsliste zu. Die Ursache liegt nicht dort, wo der Fehler sichtbar wird.", "consoleOutput": "Exception in thread worker-2 java.util.ConcurrentModificationException\n at java.base/java.util.ArrayList$Itr.next\n at Worker.run(Worker.java:6)",
|
||||
"files": [
|
||||
{"filename":"Worker.java","content":"public class Worker implements Runnable {\n private final Auftragsliste liste;\n public Worker(Auftragsliste liste) { this.liste = liste; }\n public void run() {\n for (String job : liste.jobs()) {\n System.out.println(job);\n }\n }\n}"},
|
||||
{"filename":"Auftragsliste.java","content":"import java.util.*;\npublic class Auftragsliste {\n private final List<String> jobs = new ArrayList<>();\n public void add(String job) { jobs.add(job); }\n public List<String> jobs() { return jobs; }\n}"}
|
||||
],
|
||||
"bugs": [{"points":200,"file":"Auftragsliste.java","line":5,"category":"sicherheit","evidence":"Die interne, veränderbare Liste wird direkt an parallele Aufrufer zurückgegeben.","fixOptions":[" public List<String> jobs() { return List.copyOf(jobs); }"," public List<String> jobs() { return new ArrayList<>(); }"," public List<String> jobs() { return null; }"," public List<String> jobs() { jobs.clear(); return jobs; }"],"fixIndex":0,"fixedLine":" public List<String> jobs() { return List.copyOf(jobs); }","explanation":"Eine unveränderliche Momentaufnahme verhindert, dass externe Aufrufer die interne Liste gleichzeitig verändern. Für echte gemeinsame Mutation wäre zusätzlich eine threadsichere Datenstruktur nötig."}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE bughunter_hunts (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
code_language VARCHAR(50) NOT NULL DEFAULT 'Java',
|
||||
difficulty VARCHAR(30) NOT NULL DEFAULT 'junior',
|
||||
exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1',
|
||||
description VARCHAR(1200),
|
||||
briefing VARCHAR(2000),
|
||||
console_output VARCHAR(4000),
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT chk_bughunter_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS'))
|
||||
);
|
||||
|
||||
CREATE TABLE bughunter_files (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content VARCHAR(8000) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE bughunter_bugs (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE,
|
||||
points INT NOT NULL DEFAULT 100,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
payload VARCHAR(8000) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bughunter_files_hunt ON bughunter_files(hunt_id);
|
||||
CREATE INDEX idx_bughunter_bugs_hunt ON bughunter_bugs(hunt_id);
|
||||
File diff suppressed because one or more lines are too long
@@ -126,6 +126,9 @@ a { color:var(--secondary); }
|
||||
.mf-card-codeescape { --accent:var(--esc-accent); --glow-s:var(--esc-glow); --glow-w:color-mix(in srgb,var(--esc-accent) 16%,transparent); background:repeating-linear-gradient(0deg,rgba(255,255,255,.015) 0 1px,transparent 1px 4px),var(--surface); }
|
||||
.mf-card-codeescape::after { content:"ACCESS"; position:absolute; right:12px; top:9px; color:var(--esc-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.6; }
|
||||
|
||||
.mf-card-bughunter { --accent:var(--bh-accent); --glow-s:var(--bh-glow); --glow-w:color-mix(in srgb,var(--bh-accent) 16%,transparent); background:linear-gradient(135deg,color-mix(in srgb,var(--bh-accent) 9%,transparent),var(--surface)); }
|
||||
.mf-card-bughunter::after { content:"CASE FILE"; position:absolute; right:12px; top:9px; color:var(--bh-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.7; }
|
||||
|
||||
/* ===== Panels / Widgets ===== */
|
||||
.mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); }
|
||||
.mf-panel.accent { border-color:var(--border-accent); box-shadow:0 0 20px var(--glow-w); }
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
const view = document.getElementById('bhView');
|
||||
let catalog = [], session, index = 0, started = 0, selectedFile = '', selectedLine = 0, selected = '', options = [], answers = [];
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const api = (url, opt) => fetch('/api/bughunter' + url, {headers:{'Content-Type':'application/json'}, ...opt}).then(async response => { const body = await response.json(); if (!response.ok) throw new Error(body.message || 'Serverfehler'); return body; });
|
||||
const post = (url, body) => api(url, {method:'POST', body:JSON.stringify(body)});
|
||||
const seconds = () => Math.max(1, Math.round((Date.now() - started) / 1000));
|
||||
const current = () => session.bugs[index];
|
||||
const state = () => answers[index];
|
||||
function loadCatalog() { api('/hunts').then(data => { catalog = data.hunts || []; renderCatalog(); }).catch(error); }
|
||||
function renderCatalog() {
|
||||
view.innerHTML = `<section class="bh-hero"><span class="bh-stamp">VERTRAULIC</span><span class="bh-kicker">DETektivbüro · DEBUGGING-FALLAKTEN</span><h1>Welche Spur verfolgst du?</h1><p>Arbeite dich durch echte Fehlerbilder: Ort, Kategorie und Fix müssen zusammenpassen. Präzision baut deine Ermittlungsserie auf.</p></section><section class="bh-grid">${catalog.map(h => `<article class="bh-case"><span class="bh-case-no">${esc(h.examScope)} · ${esc(h.difficulty).toUpperCase()}</span><h2>${esc(h.title)}</h2><p>${esc(h.description)}</p><div class="bh-tags"><span>${esc(h.language)}</span><span>${h.fileCount} Dateien</span><span>${h.bugCount} Bug${h.bugCount === 1 ? '' : 's'}</span></div><button class="bh-btn primary" data-start="${esc(h.slug)}">FALL ÖFFNEN</button></article>`).join('')}</section>`;
|
||||
view.querySelectorAll('[data-start]').forEach(button => button.addEventListener('click', () => begin(button.dataset.start)));
|
||||
}
|
||||
function begin(slug) { api('/session/' + encodeURIComponent(slug)).then(data => { if (!data.found) throw new Error('Fallakte nicht verfügbar.'); session = data; index = 0; answers = session.bugs.map(() => ({locate:null, classify:null, fix:null})); started = Date.now(); renderBriefing(); }).catch(error); }
|
||||
function renderBriefing() {
|
||||
view.innerHTML = `<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>`;
|
||||
document.getElementById('bhStart').addEventListener('click', () => { selectedFile = session.files[0]?.filename || ''; selectedLine = 0; selected = ''; options = []; renderHunt(); });
|
||||
}
|
||||
function activePhase() { const s = state(); return !s.locate?.done ? 'locate' : !s.classify?.done ? 'classify' : 'fix'; }
|
||||
function renderHunt() {
|
||||
const bug = current(), s = state(), phase = activePhase();
|
||||
const evidence = bug.evidence || 'Untersuche die Fallakte und folge den Spuren.';
|
||||
view.innerHTML = `<div class="bh-hunt-head"><div><span class="bh-kicker">AKTE ${index + 1} / ${session.bugs.length} · ${esc(session.difficulty).toUpperCase()}</span><h1>${esc(session.title)}</h1></div><div class="bh-combo">${comboText()}<small>saubere Serie</small></div></div><div class="bh-progress">${session.bugs.map((_, i) => `<i class="${i < index ? 'done' : i === index ? 'current' : ''}">${i + 1}</i>`).join('')}</div><div class="bh-layout"><section class="bh-code-area"><div class="bh-tabs">${session.files.map(file => `<button class="${file.filename === selectedFile ? 'active' : ''}" data-file="${esc(file.filename)}">${esc(file.filename)}</button>`).join('')}</div>${renderCode(phase)} </section><aside class="bh-side"><div class="bh-evidence"><span class="bh-stamp">BEWEISSTÜCK</span><p>${esc(evidence)}</p><pre>${esc(session.consoleOutput)}</pre></div>${renderAction(phase, bug, s)}</aside></div>`;
|
||||
view.querySelectorAll('[data-file]').forEach(tab => tab.addEventListener('click', () => { selectedFile = tab.dataset.file; selectedLine = 0; renderHunt(); }));
|
||||
bindAction(phase, bug);
|
||||
}
|
||||
function renderCode(phase) {
|
||||
const file = session.files.find(f => f.filename === selectedFile) || session.files[0];
|
||||
if (!file) return '<p>Keine Datei in der Akte.</p>';
|
||||
return `<div class="bh-code" aria-label="Code-Datei ${esc(file.filename)}">${file.lines.map((line, i) => { const lineNo = i + 1, marked = phase === 'locate' && selectedLine === lineNo; return `<button class="bh-line ${marked ? 'selected' : ''}" data-line="${lineNo}" ${phase === 'locate' ? '' : 'disabled'}><span>${lineNo}</span><code>${esc(line) || ' '}</code></button>`; }).join('')}</div>`;
|
||||
}
|
||||
function renderAction(phase, bug, s) {
|
||||
const labels = {locate:'1 · ORTEN', classify:'2 · KLASSIFIZIEREN', fix:'3 · FIXEN'};
|
||||
let content = '';
|
||||
if (phase === 'locate') content = `<p>Klicke die fehlerhafte Zeile im Code an.</p><p class="bh-selected">${selectedLine ? `${esc(selectedFile)}:${selectedLine}` : 'Noch keine Zeile markiert'}</p>`;
|
||||
if (phase === 'classify') content = `<p>Welche Fehlerklasse erklärt den Fund am besten?</p><div class="bh-options">${[['syntax','Syntax'],['logik','Logik'],['laufzeit','Laufzeit'],['sicherheit','Sicherheit']].map(([value,label]) => `<button data-choice="${value}" class="${selected === value ? 'selected' : ''}">${label}</button>`).join('')}</div>`;
|
||||
if (phase === 'fix') content = `<p>Wähle die Zeile, die den Fehler sauber behebt.</p><div class="bh-options">${options.map(value => `<button data-choice="${esc(value)}" class="${selected === value ? 'selected' : ''}"><code>${esc(value)}</code></button>`).join('')}</div>`;
|
||||
const attempts = s[phase]?.attempts || 0;
|
||||
return `<section class="bh-action"><span class="bh-kicker">${labels[phase]}</span><h2>${phase === 'locate' ? 'Wo liegt der Bug?' : phase === 'classify' ? 'Wie heißt das Problem?' : 'Wie wird er behoben?'}</h2>${content}<p class="bh-attempts">Versuche: ${attempts}/3</p><div id="bhFeedback" class="bh-feedback" aria-live="polite"></div><div class="bh-actions"><button class="bh-btn primary" id="bhCheck">PRÜFEN</button><button class="bh-btn" id="bhReveal">AUFLÖSEN</button></div></section>`;
|
||||
}
|
||||
function bindAction(phase, bug) {
|
||||
if (phase === 'locate') view.querySelectorAll('[data-line]').forEach(line => line.addEventListener('click', () => { selectedLine = Number(line.dataset.line); renderHunt(); }));
|
||||
view.querySelectorAll('[data-choice]').forEach(button => button.addEventListener('click', () => { selected = button.dataset.choice; view.querySelectorAll('[data-choice]').forEach(item => item.classList.remove('selected')); button.classList.add('selected'); }));
|
||||
document.getElementById('bhCheck').addEventListener('click', () => submit(phase, bug, false));
|
||||
document.getElementById('bhReveal').addEventListener('click', () => submit(phase, bug, true));
|
||||
}
|
||||
function submission(phase) { return phase === 'locate' ? {file:selectedFile, line:selectedLine} : phase === 'classify' ? {category:selected} : {choice:selected}; }
|
||||
function submit(phase, bug, reveal) {
|
||||
const sub = submission(phase); if (!reveal && ((phase === 'locate' && !sub.line) || (phase !== 'locate' && !selected))) { feedback('Triff zuerst eine Auswahl.', 'bad'); return; }
|
||||
const phaseState = state()[phase] || {attempts:0}; phaseState.attempts = Math.min(3, phaseState.attempts + 1); phaseState.submission = sub;
|
||||
post('/check', {bugId:bug.bugId, phase, reveal, submission:sub}).then(result => {
|
||||
if (!result.resolved) { state()[phase] = phaseState; feedback('Noch nicht stimmig. Lies die Beweise erneut.', 'bad'); return; }
|
||||
phaseState.revealed = reveal; phaseState.done = true; state()[phase] = phaseState;
|
||||
if (phase === 'classify') options = result.fixOptions || []; if (phase === 'fix') { showDebrief(result); return; }
|
||||
selected = ''; if (phase === 'locate') selectedLine = result.location?.line || selectedLine;
|
||||
feedback(reveal ? 'Auflösung notiert – weiter zur nächsten Phase.' : 'Spur bestätigt – weiter zur nächsten Phase.', 'ok'); setTimeout(renderHunt, 650);
|
||||
}).catch(err => feedback(err.message, 'bad'));
|
||||
}
|
||||
function showDebrief(result) {
|
||||
const bug = current(); view.innerHTML = `<section class="bh-debrief"><span class="bh-stamp">GELÖST</span><span class="bh-kicker">AKTE ${index + 1} ABGESCHLOSSEN</span><h1>Die Spur ist gesichert.</h1><p>${esc(result.explanation || 'Die Fallakte wurde aktualisiert.')}</p><div class="bh-diff"><span>− fehlerhafte Zeile</span><code>${esc(selectedFile)}:${state().locate?.submission?.line || '?'}</code><span>+ Korrektur</span><code>${esc(result.fixedLine || '')}</code></div><button class="bh-btn primary" id="bhNext">${index + 1 < session.bugs.length ? 'NÄCHSTE AKTE' : 'ERGEBNIS AUSWERTEN'}</button></section>`;
|
||||
document.getElementById('bhNext').addEventListener('click', () => { index++; selected = ''; selectedLine = 0; options = []; if (index >= session.bugs.length) finish(); else { selectedFile = session.files[0]?.filename || ''; renderHunt(); } });
|
||||
}
|
||||
function finish() { post('/finish', {slug:session.slug, durationSeconds:seconds(), items:answers.map((answer, i) => ({bugId:session.bugs[i].bugId, locate:strip(answer.locate), classify:strip(answer.classify), fix:strip(answer.fix)}))}).then(renderResult).catch(error); }
|
||||
function strip(value) { return value ? {submission:value.submission, attempts:value.attempts, revealed:!!value.revealed} : null; }
|
||||
function comboText() { let combo = 0; for (let i = 0; i < index; i++) { const a = answers[i]; const clean = ['locate','classify','fix'].every(key => a[key] && a[key].attempts === 1 && !a[key].revealed); combo = clean ? combo + 1 : 0; } return `×${combo}`; }
|
||||
function renderResult(result) { view.innerHTML = `<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 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();
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>BugHunter Fallakte</title><link rel="stylesheet" href="/css/theme.css"><script src="/js/theme.js"></script></head><body><main class="mf-container" th:if="${hunt != null}"><a class="mf-back" href="/admin/bughunter">← Fallakten</a><h1 th:text="${hunt.title}" style="margin:1rem 0;color:var(--heading)"></h1><div class="mf-grid"><form class="mf-panel" method="post" th:action="@{/admin/bughunter/{id}/files(id=${hunt.id})}"><h2>Datei hinzufügen</h2><div class="mf-field"><label class="mf-label">Dateiname</label><input class="mf-input" name="filename" required></div><div class="mf-field"><label class="mf-label">Code</label><textarea class="mf-input" rows="14" name="content" required></textarea></div><button class="mf-btn">Datei speichern</button></form><form class="mf-panel" method="post" th:action="@{/admin/bughunter/{id}/bugs(id=${hunt.id})}"><h2>Bug hinzufügen</h2><div class="mf-field"><label class="mf-label">Punkte</label><input class="mf-input" type="number" name="points" value="100"></div><div class="mf-field"><label class="mf-label">Payload JSON</label><textarea class="mf-input" rows="14" name="payload" required>{"file":"Datei.java","line":1,"category":"logik","evidence":"...","fixOptions":["..."],"fixIndex":0,"fixedLine":"...","explanation":"..."}</textarea></div><p style="color:var(--muted);font-size:.85rem">Dateiname und Zeile müssen zu einer vorhandenen Datei passen; Kategorie: syntax, logik, laufzeit oder sicherheit.</p><button class="mf-btn">Bug speichern</button></form></div><section class="mf-panel" style="margin-top:1rem"><h2>Dateien</h2><table class="mf-table"><tr><th>Datei</th><th>Zeilen</th><th></th></tr><tr th:each="f:${hunt.files}"><td th:text="${f.filename}"></td><td th:text="${#strings.arrayLength(#strings.arraySplit(f.content, '\n'))}"></td><td><form method="post" th:action="@{/admin/bughunter/files/{id}/delete(id=${f.id})}"><input type="hidden" name="huntId" th:value="${hunt.id}"><button class="mf-btn danger">Löschen</button></form></td></tr></table></section><section class="mf-panel" style="margin-top:1rem"><h2>Bugs</h2><table class="mf-table"><tr><th>Punkte</th><th>Payload</th><th></th></tr><tr th:each="b:${hunt.bugs}"><td th:text="${b.points}"></td><td><code th:text="${b.payload}"></code></td><td><form method="post" th:action="@{/admin/bughunter/bugs/{id}/delete(id=${b.id})}"><input type="hidden" name="huntId" th:value="${hunt.id}"><button class="mf-btn danger">Löschen</button></form></td></tr></table></section></main><main class="mf-container" th:unless="${hunt != null}"><p>Fallakte nicht gefunden.</p></main></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>Admin · BugHunter</title><link rel="stylesheet" href="/css/theme.css"><script src="/js/theme.js"></script></head><body><main class="mf-container"><a class="mf-back" href="/admin">← Admin</a><h1 style="margin:1rem 0;color:var(--heading)">BugHunter · Fallakten</h1><form class="mf-panel" method="post" action="/admin/bughunter/add"><div class="mf-field"><label class="mf-label">Titel</label><input class="mf-input" name="title" required></div><div class="mf-field"><label class="mf-label">Code-Sprache</label><input class="mf-input" name="language" value="Java"></div><div class="mf-field"><label class="mf-label">Stufe</label><select class="mf-select" name="difficulty"><option>junior</option><option>senior</option><option>principal</option></select></div><div class="mf-field"><label class="mf-label">Prüfungsbereich</label><select class="mf-select" name="examScope"><option>AP1</option><option>AP2_FIAE</option><option>AP2_FISI</option><option>CROSS</option></select></div><div class="mf-field"><label class="mf-label">Kurzbeschreibung</label><textarea class="mf-input" name="description"></textarea></div><div class="mf-field"><label class="mf-label">Briefing</label><textarea class="mf-input" name="briefing"></textarea></div><div class="mf-field"><label class="mf-label">Konsolen-Ausgabe</label><textarea class="mf-input" name="consoleOutput"></textarea></div><button class="mf-btn">Fallakte anlegen</button></form><table class="mf-table" style="margin-top:1rem"><tr><th>Titel</th><th>Stufe</th><th>Scope</th><th>Fälle</th><th>Status</th><th></th></tr><tr th:each="h:${hunts}"><td><a th:href="@{/admin/bughunter/{id}(id=${h.id})}" th:text="${h.title}"></a></td><td th:text="${h.difficulty}"></td><td th:text="${h.examScope}"></td><td th:text="${h.bugs.size}"></td><td th:text="${h.active?'aktiv':'inaktiv'}"></td><td><form style="display:inline" method="post" th:action="@{/admin/bughunter/{id}/toggle(id=${h.id})}"><button class="mf-btn outline">Toggle</button></form> <form style="display:inline" method="post" th:action="@{/admin/bughunter/{id}/delete(id=${h.id})}"><button class="mf-btn danger">Löschen</button></form></td></tr></table></main></body></html>
|
||||
@@ -49,6 +49,7 @@
|
||||
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
|
||||
<a th:href="@{/admin/devlab}" class="admin-card">⚗️ Dev-Lab-Aufgaben verwalten</a>
|
||||
<a th:href="@{/admin/codeescape}" class="admin-card">🚪 SystemEscape-Szenarien verwalten</a>
|
||||
<a th:href="@{/admin/bughunter}" class="admin-card">🕵️ BugHunter-Fallakten verwalten</a>
|
||||
</div>
|
||||
<p style="margin-top:1.1rem; color:var(--muted);">
|
||||
Hier entsteht noch Großes... ✨ Bleib dran!
|
||||
|
||||
@@ -0,0 +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=1"><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>INVESTIGATION BUREAU</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=1"></script></body></html>
|
||||
@@ -137,12 +137,12 @@
|
||||
['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
|
||||
['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/codeescape'],
|
||||
['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
|
||||
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
|
||||
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/bughunter'],
|
||||
['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
|
||||
['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
|
||||
];
|
||||
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
|
||||
<a class="mf-card ${t === 'SystemEscape' ? 'mf-card-codeescape' : ''}" href="${href}">
|
||||
<a class="mf-card ${t === 'SystemEscape' ? 'mf-card-codeescape' : ''} ${t === 'BugHunter' ? 'mf-card-bughunter' : ''}" href="${href}">
|
||||
<span class="ico"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${GI[ic]}</svg></span>
|
||||
<h3>${t}</h3><p>${d}</p>
|
||||
<span class="mf-play">SPIEL STARTEN <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
|
||||
Reference in New Issue
Block a user