diff --git a/CHANGELOG.md b/CHANGELOG.md index 880c53a..c7f982e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ Alle nennenswerten Änderungen an **Mindflux** werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Versionierung [semantisch](https://semver.org/lang/de/). +## [Unreleased] – 2026-06-30 + +Neues Spiel **Syntax Saga**: interaktive IT-Geschichten, in denen man den fehlenden +Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine Story tippt. Server-autoritativ +nach dem TechTacular-Muster, Inhalte DB-gestützt und im Admin pflegbar. Die Dashboard-Kachel +„Syntax Saga" ist damit aktiv (vorher „coming soon"). + +### Hinzugefügt +- **Spiel: Syntax Saga (`/syntaxsaga`):** Missions-Saga in drei **wählbaren Schwierigkeitsstufen** + (Junior = eine Lücke, Professional = mehrere Lücken, Senior = ganze Zeile rekonstruieren). Jede + Mission besteht aus Erzähltext + optionalem Dialog + Codeblock mit `{{n}}`-Lücken. Eine Lücke kann + **mehrere gültige Antworten** haben (z. B. `localhost` / `192.168.0.1`) und optional + groß-/kleinschreibungs-unabhängig prüfen (z. B. SQL-Keywords). Eingabe per Freitext; **Prüfung und + Score ausschließlich serverseitig** – die Lösungen verlassen nie das Backend (Anti-Cheat wie bei + TechTacular/MindMatch). +- **Fehler-Mechanik:** pro Lücke bis zu drei Versuche mit Hinweis, danach **„Auflösung anzeigen"**; die + Geschichte läuft immer weiter (**kein Game-Over**). Punkte je nach Versuch (Erstversuch voll, danach + weniger), aufgelöste Lücke = 0 Punkte; Sterne­wertung 0–3. +- **Admin-Verwaltung (`/admin/syntaxsaga`):** Missionen, Lücken und Lösungen anlegen/bearbeiten/löschen, + aktiv/inaktiv schalten. Mitgeliefert: **34 Seed-Missionen** (16 Junior · 10 Professional · 8 Senior, + zusammen 44 Lücken) aus `syntaxsaga/content.json` (idempotenter Seeder, lädt nur in leere Tabellen). + Themen quer durch den Stack: **Java** (Schleifen, OOP/`class`/`this`, Exceptions, Collections, + Operatoren), **SQL** (`WHERE`/`ORDER BY`/`JOIN … ON`/`INSERT`/`UPDATE`/`DELETE`), **Bash** + (`cd`/`grep`/Pipe), **Git** (`commit`), **HTML** (Link/Formular) und **Python** (`print`); Senior + rekonstruiert ganze Zeilen (z. B. `public static void main(String[] args)`, + `Scanner sc = new Scanner(System.in)`) mit ein paar tolerierten Schreibvarianten. +- Spielstände laufen über die bestehende generische `game_scores`-Tabelle (Spiel-Kennung `syntax-saga`), + inkl. Highscore/„Neuer Bestwert" und Statistik im Spiel-HUD. + +### Technik +- Neue Entities `SyntaxSagaMission` / `SyntaxSagaGap` / `SyntaxSagaSolution` + Flyway-Migration + `V7__syntax_saga.sql`; server-autoritativer `SyntaxSagaService` (Story ohne Lösungen / serverseitige + Prüfung / Score-Recompute beim Abschluss); JSON-API `/api/syntaxsaga/**` (CSRF-ausgenommen). Frontend + unter `syntaxsaga.html` / `syntaxsaga.js` / `syntaxsaga.css` (Prefix `ss-`, zentrale `theme.css`-Tokens). + ## [Unreleased] – 2026-06-29 Inhaltlicher und optischer Feinschliff: echte Rechtstexte, vollständig diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index 65d2c77..d620062 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -40,7 +40,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/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/config/SyntaxSagaSeeder.java b/src/main/java/com/example/mindflux/config/SyntaxSagaSeeder.java new file mode 100644 index 0000000..1586e9c --- /dev/null +++ b/src/main/java/com/example/mindflux/config/SyntaxSagaSeeder.java @@ -0,0 +1,152 @@ +package com.example.mindflux.config; + +import com.example.mindflux.model.SyntaxSagaGap; +import com.example.mindflux.model.SyntaxSagaMission; +import com.example.mindflux.model.SyntaxSagaSolution; +import com.example.mindflux.repository.SyntaxSagaMissionRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.Set; + +/** + * Befüllt Syntax Saga einmalig (wenn leer) aus der kuratierten Inhaltsdatei + * syntaxsaga/content.json. Format: Missionen mit Story, Codetemplate (Lücken als + * {@code {{n}}}) und je Lücke einer Liste gültiger Lösungen. + */ +@Component +public class SyntaxSagaSeeder implements CommandLineRunner { + + private static final Set DIFFICULTIES = Set.of("junior", "professional", "senior"); + + private final SyntaxSagaMissionRepository missionRepository; + private final ObjectMapper objectMapper; + + public SyntaxSagaSeeder(SyntaxSagaMissionRepository missionRepository, ObjectMapper objectMapper) { + this.missionRepository = missionRepository; + this.objectMapper = objectMapper; + } + + @Override + @Transactional + public void run(String... args) { + if (missionRepository.count() > 0) { + return; // schon befüllt + } + try { + JsonNode root = objectMapper.readTree( + new ClassPathResource("syntaxsaga/content.json").getInputStream()); + JsonNode missions = root.get("missions"); + if (missions == null || !missions.isArray()) { + System.out.println("⚠️ Syntax-Saga-Seed: content.json ohne 'missions'."); + return; + } + + Set usedSlugs = new HashSet<>(); + int order = 1; + int missionCount = 0; + int gapCount = 0; + + for (JsonNode mNode : missions) { + String title = mNode.path("title").asText(""); + String code = mNode.path("code").asText(""); + JsonNode gaps = mNode.get("gaps"); + if (title.isBlank() || code.isBlank() || gaps == null || !gaps.isArray()) { + continue; + } + + SyntaxSagaMission mission = new SyntaxSagaMission( + uniqueSlug(title, usedSlugs), + trim(title, 200), + normalizeDifficulty(mNode.path("difficulty").asText("junior")), + trim(mNode.path("narrative").asText(""), 2000), + trim(textOrNull(mNode, "dialog"), 1000), + trim(textOrNull(mNode, "language"), 30), + trim(code, 2000), + trim(textOrNull(mNode, "successText"), 2000), + order, true); + + int pos = 1; + for (JsonNode gNode : gaps) { + JsonNode solutions = gNode.get("solutions"); + if (solutions == null || !solutions.isArray() || solutions.isEmpty()) { + continue; + } + SyntaxSagaGap gap = new SyntaxSagaGap( + gNode.path("position").asInt(pos), + gNode.path("points").asInt(100), + gNode.path("caseSensitive").asBoolean(true), + trim(textOrNull(gNode, "hint"), 500), + trim(textOrNull(gNode, "placeholder"), 120)); + + int sort = 0; + for (JsonNode s : solutions) { + String st = s.asText(""); + if (!st.isBlank()) { + gap.addSolution(new SyntaxSagaSolution(trim(st, 500), sort++)); + } + } + if (!gap.getSolutions().isEmpty()) { + mission.addGap(gap); + gapCount++; + } + pos++; + } + + if (!mission.getGaps().isEmpty()) { + missionRepository.save(mission); + order++; + missionCount++; + } + } + System.out.println("✅ Syntax Saga: " + missionCount + " Missionen / " + gapCount + " Lücken geladen."); + } catch (Exception e) { + System.out.println("⚠️ Syntax-Saga-Seed übersprungen: " + e.getMessage()); + } + } + + private String normalizeDifficulty(String difficulty) { + if (difficulty == null) { + return "junior"; + } + String x = difficulty.trim().toLowerCase(); + return DIFFICULTIES.contains(x) ? x : "junior"; + } + + private String textOrNull(JsonNode node, String field) { + if (!node.hasNonNull(field)) { + return null; + } + String value = node.get(field).asText(""); + return value.isBlank() ? null : value; + } + + private String uniqueSlug(String title, Set used) { + String base = title.toLowerCase() + .replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (base.isBlank()) { + base = "mission"; + } + String slug = base; + int counter = 2; + while (used.contains(slug) || missionRepository.existsBySlug(slug)) { + slug = base + "-" + counter++; + } + used.add(slug); + return slug; + } + + private String trim(String value, int max) { + if (value == null) { + return null; + } + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/main/java/com/example/mindflux/controller/AdminSyntaxSagaController.java b/src/main/java/com/example/mindflux/controller/AdminSyntaxSagaController.java new file mode 100644 index 0000000..fa0ed00 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/AdminSyntaxSagaController.java @@ -0,0 +1,102 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.model.SyntaxSagaMission; +import com.example.mindflux.service.SyntaxSagaService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** Admin-Verwaltung der Syntax-Saga-Missionen, -Lücken und -Lösungen (ROLE_ADMIN via /admin/**). */ +@Controller +@RequestMapping("/admin/syntaxsaga") +public class AdminSyntaxSagaController { + + private final SyntaxSagaService syntaxSagaService; + + public AdminSyntaxSagaController(SyntaxSagaService syntaxSagaService) { + this.syntaxSagaService = syntaxSagaService; + } + + /** Übersicht aller Missionen. */ + @GetMapping("") + public String manage(Model model) { + model.addAttribute("missions", syntaxSagaService.missionRows()); + return "admin/syntaxsaga"; + } + + /** Detailseite einer Mission mit ihren Lücken. */ + @GetMapping("/missions/{missionId}") + public String mission(@PathVariable Long missionId, Model model) { + SyntaxSagaMission mission = syntaxSagaService.missionWithGaps(missionId); + if (mission == null) { + return "redirect:/admin/syntaxsaga"; + } + model.addAttribute("mission", mission); + return "admin/syntaxsaga-mission"; + } + + @PostMapping("/missions") + public String createMission(@RequestParam String title, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String narrative, + @RequestParam(required = false) String dialog, + @RequestParam(required = false) String language, + @RequestParam String codeTemplate, + @RequestParam(required = false) String successText) { + Long id = syntaxSagaService.createMission(title, difficulty, narrative, dialog, + language, codeTemplate, successText); + return id == null ? "redirect:/admin/syntaxsaga" + : "redirect:/admin/syntaxsaga/missions/" + id; + } + + @PostMapping("/missions/{missionId}/update") + public String updateMission(@PathVariable Long missionId, + @RequestParam String title, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String narrative, + @RequestParam(required = false) String dialog, + @RequestParam(required = false) String language, + @RequestParam String codeTemplate, + @RequestParam(required = false) String successText) { + syntaxSagaService.updateMission(missionId, title, difficulty, narrative, dialog, + language, codeTemplate, successText); + return "redirect:/admin/syntaxsaga/missions/" + missionId; + } + + @PostMapping("/missions/{missionId}/gaps") + public String addGap(@PathVariable Long missionId, + @RequestParam(defaultValue = "0") int position, + @RequestParam(defaultValue = "100") int points, + @RequestParam(defaultValue = "false") boolean caseSensitive, + @RequestParam(required = false) String hint, + @RequestParam(required = false) String placeholder, + @RequestParam(required = false, defaultValue = "") String solutions) { + List parsed = Arrays.stream(solutions.split("\\r?\\n")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + syntaxSagaService.addGap(missionId, position, points, caseSensitive, hint, placeholder, parsed); + return "redirect:/admin/syntaxsaga/missions/" + missionId; + } + + @PostMapping("/gaps/{gapId}/delete") + public String deleteGap(@PathVariable Long gapId, @RequestParam Long missionId) { + syntaxSagaService.deleteGap(gapId); + return "redirect:/admin/syntaxsaga/missions/" + missionId; + } + + @PostMapping("/missions/{missionId}/delete") + public String deleteMission(@PathVariable Long missionId) { + syntaxSagaService.deleteMission(missionId); + return "redirect:/admin/syntaxsaga"; + } + + @PostMapping("/missions/{missionId}/toggle") + public String toggleMission(@PathVariable Long missionId) { + syntaxSagaService.toggleMission(missionId); + return "redirect:/admin/syntaxsaga"; + } +} diff --git a/src/main/java/com/example/mindflux/controller/SyntaxSagaApiController.java b/src/main/java/com/example/mindflux/controller/SyntaxSagaApiController.java new file mode 100644 index 0000000..2bb898e --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/SyntaxSagaApiController.java @@ -0,0 +1,41 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.dto.SyntaxSagaCheckRequest; +import com.example.mindflux.dto.SyntaxSagaCheckResult; +import com.example.mindflux.dto.SyntaxSagaFinishRequest; +import com.example.mindflux.dto.SyntaxSagaResult; +import com.example.mindflux.dto.SyntaxSagaStoryView; +import com.example.mindflux.service.SyntaxSagaService; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +/** JSON-API für Syntax Saga. Server-autoritativ: Lösungen werden nie ausgeliefert. */ +@RestController +@RequestMapping("/api/syntaxsaga") +public class SyntaxSagaApiController { + + private final SyntaxSagaService syntaxSagaService; + + public SyntaxSagaApiController(SyntaxSagaService syntaxSagaService) { + this.syntaxSagaService = syntaxSagaService; + } + + /** Missionen einer Stufe (oder stufenübergreifend) – ohne Lösungen. */ + @GetMapping("/story") + public SyntaxSagaStoryView story(@RequestParam(required = false) String difficulty) { + return syntaxSagaService.buildStory(difficulty); + } + + /** Eine Mission serverseitig prüfen; mit {@code reveal=true} auch die Auflösung. */ + @PostMapping("/check") + public SyntaxSagaCheckResult check(@RequestBody SyntaxSagaCheckRequest request) { + return syntaxSagaService.check(request); + } + + /** Durchlauf abschließen: Score wird serverseitig neu berechnet und gespeichert. */ + @PostMapping("/finish") + public SyntaxSagaResult finish(@RequestBody SyntaxSagaFinishRequest request, Principal principal) { + return syntaxSagaService.finish(principal.getName(), request); + } +} diff --git a/src/main/java/com/example/mindflux/controller/SyntaxSagaController.java b/src/main/java/com/example/mindflux/controller/SyntaxSagaController.java new file mode 100644 index 0000000..24907cd --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/SyntaxSagaController.java @@ -0,0 +1,13 @@ +package com.example.mindflux.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class SyntaxSagaController { + + @GetMapping("/syntaxsaga") + public String show() { + return "syntaxsaga"; // Lädt templates/syntaxsaga.html + } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckRequest.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckRequest.java new file mode 100644 index 0000000..1aa6ac9 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckRequest.java @@ -0,0 +1,31 @@ +package com.example.mindflux.dto; + +import java.util.List; + +/** Eine Mission prüfen: die eingetippten Lücken (+ ob die Auflösung angefordert wird). */ +public class SyntaxSagaCheckRequest { + + private Long missionId; + private boolean reveal; + private List answers; + + public Long getMissionId() { return missionId; } + public void setMissionId(Long missionId) { this.missionId = missionId; } + + public boolean isReveal() { return reveal; } + public void setReveal(boolean reveal) { this.reveal = reveal; } + + public List getAnswers() { return answers; } + public void setAnswers(List answers) { this.answers = answers; } + + public static class GapAnswer { + private int position; + private String text; + + public int getPosition() { return position; } + public void setPosition(int position) { this.position = position; } + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckResult.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckResult.java new file mode 100644 index 0000000..53a6f7b --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaCheckResult.java @@ -0,0 +1,37 @@ +package com.example.mindflux.dto; + +import java.util.List; + +/** + * Serverseitige Bewertung einer Mission (instant feedback). {@code solution} ist nur + * gefüllt, wenn die Auflösung angefordert wurde und die Lücke nicht korrekt war. + */ +public class SyntaxSagaCheckResult { + + private final boolean allCorrect; + private final List results; + + public SyntaxSagaCheckResult(boolean allCorrect, List results) { + this.allCorrect = allCorrect; + this.results = results; + } + + public boolean isAllCorrect() { return allCorrect; } + public List getResults() { return results; } + + public static class GapVerdict { + private final int position; + private final boolean correct; + private final String solution; + + public GapVerdict(int position, boolean correct, String solution) { + this.position = position; + this.correct = correct; + this.solution = solution; + } + + public int getPosition() { return position; } + public boolean isCorrect() { return correct; } + public String getSolution() { return solution; } + } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaFinishRequest.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaFinishRequest.java new file mode 100644 index 0000000..a68bea4 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaFinishRequest.java @@ -0,0 +1,46 @@ +package com.example.mindflux.dto; + +import java.util.List; + +/** + * Abschluss eines Saga-Durchlaufs: alle eingetippten Lücken über alle Missionen einer + * Stufe (+ Versuche und ob aufgelöst wurde). Der Score wird serverseitig neu berechnet. + */ +public class SyntaxSagaFinishRequest { + + private String difficulty; + private int durationSeconds; + private List gaps; + + public String getDifficulty() { return difficulty; } + public void setDifficulty(String difficulty) { this.difficulty = difficulty; } + + public int getDurationSeconds() { return durationSeconds; } + public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; } + + public List getGaps() { return gaps; } + public void setGaps(List gaps) { this.gaps = gaps; } + + public static class FinishGap { + private Long missionId; + private int position; + private String text; + private int attempts; + private boolean revealed; + + public Long getMissionId() { return missionId; } + public void setMissionId(Long missionId) { this.missionId = missionId; } + + public int getPosition() { return position; } + public void setPosition(int position) { this.position = position; } + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + + public int getAttempts() { return attempts; } + public void setAttempts(int attempts) { this.attempts = attempts; } + + public boolean isRevealed() { return revealed; } + public void setRevealed(boolean revealed) { this.revealed = revealed; } + } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaMissionRow.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaMissionRow.java new file mode 100644 index 0000000..14f3853 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaMissionRow.java @@ -0,0 +1,28 @@ +package com.example.mindflux.dto; + +/** Eine Zeile in der Admin-Missionsübersicht (ohne alle Lücken zu laden). */ +public class SyntaxSagaMissionRow { + private final Long id; + private final String slug; + private final String title; + private final String difficulty; + private final boolean active; + private final long gapCount; + + public SyntaxSagaMissionRow(Long id, String slug, String title, String difficulty, + boolean active, long gapCount) { + this.id = id; + this.slug = slug; + this.title = title; + this.difficulty = difficulty; + this.active = active; + this.gapCount = gapCount; + } + + public Long getId() { return id; } + public String getSlug() { return slug; } + public String getTitle() { return title; } + public String getDifficulty() { return difficulty; } + public boolean isActive() { return active; } + public long getGapCount() { return gapCount; } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaResult.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaResult.java new file mode 100644 index 0000000..853eb1a --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaResult.java @@ -0,0 +1,35 @@ +package com.example.mindflux.dto; + +/** Endergebnis eines Saga-Durchlaufs (Score serverseitig berechnet) + Highscore-Stand. */ +public class SyntaxSagaResult { + + private final int score; + private final int maxScore; + private final int solvedGaps; + private final int totalGaps; + private final int stars; + private final int highscore; + private final boolean newHighscore; + private final long plays; + + public SyntaxSagaResult(int score, int maxScore, int solvedGaps, int totalGaps, int stars, + int highscore, boolean newHighscore, long plays) { + this.score = score; + this.maxScore = maxScore; + this.solvedGaps = solvedGaps; + this.totalGaps = totalGaps; + this.stars = stars; + this.highscore = highscore; + this.newHighscore = newHighscore; + this.plays = plays; + } + + public int getScore() { return score; } + public int getMaxScore() { return maxScore; } + public int getSolvedGaps() { return solvedGaps; } + public int getTotalGaps() { return totalGaps; } + public int getStars() { return stars; } + public int getHighscore() { return highscore; } + public boolean isNewHighscore() { return newHighscore; } + public long getPlays() { return plays; } +} diff --git a/src/main/java/com/example/mindflux/dto/SyntaxSagaStoryView.java b/src/main/java/com/example/mindflux/dto/SyntaxSagaStoryView.java new file mode 100644 index 0000000..5ff01d6 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/SyntaxSagaStoryView.java @@ -0,0 +1,82 @@ +package com.example.mindflux.dto; + +import java.util.List; + +/** + * Die Missionen einer Schwierigkeitsstufe fürs Frontend. Bewusst OHNE Lösungen – + * geprüft und gewertet wird ausschließlich serverseitig. + */ +public class SyntaxSagaStoryView { + + private final String difficulty; + private final List missions; + private final int maxScore; + private final int totalGaps; + + public SyntaxSagaStoryView(String difficulty, List missions, int maxScore, int totalGaps) { + this.difficulty = difficulty; + this.missions = missions; + this.maxScore = maxScore; + this.totalGaps = totalGaps; + } + + public String getDifficulty() { return difficulty; } + public List getMissions() { return missions; } + public int getMaxScore() { return maxScore; } + public int getTotalGaps() { return totalGaps; } + + public static class Mission { + private final Long missionId; + private final String title; + private final String difficulty; + private final String language; + private final String narrative; + private final String dialog; + private final String codeTemplate; + private final String successText; + private final List gaps; + + public Mission(Long missionId, String title, String difficulty, String language, + String narrative, String dialog, String codeTemplate, + String successText, List gaps) { + this.missionId = missionId; + this.title = title; + this.difficulty = difficulty; + this.language = language; + this.narrative = narrative; + this.dialog = dialog; + this.codeTemplate = codeTemplate; + this.successText = successText; + this.gaps = gaps; + } + + public Long getMissionId() { return missionId; } + public String getTitle() { return title; } + public String getDifficulty() { return difficulty; } + public String getLanguage() { return language; } + public String getNarrative() { return narrative; } + public String getDialog() { return dialog; } + public String getCodeTemplate() { return codeTemplate; } + public String getSuccessText() { return successText; } + public List getGaps() { return gaps; } + } + + public static class Gap { + private final int position; + private final int points; + private final String hint; + private final String placeholder; + + public Gap(int position, int points, String hint, String placeholder) { + this.position = position; + this.points = points; + this.hint = hint; + this.placeholder = placeholder; + } + + public int getPosition() { return position; } + public int getPoints() { return points; } + public String getHint() { return hint; } + public String getPlaceholder() { return placeholder; } + } +} diff --git a/src/main/java/com/example/mindflux/model/SyntaxSagaGap.java b/src/main/java/com/example/mindflux/model/SyntaxSagaGap.java new file mode 100644 index 0000000..23d0373 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/SyntaxSagaGap.java @@ -0,0 +1,112 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Eine Lücke in einer {@link SyntaxSagaMission}. Die {@link #position} entspricht der + * Markernummer {@code {{n}}} im Codetemplate. Eine Lücke kann mehrere gültige Lösungen + * haben (z.B. {@code 192.168.0.1} oder {@code localhost}). Die Prüfung passiert + * serverseitig über {@link #matches(String)} – das Frontend kennt die Lösung nie. + */ +@Entity +@Table(name = "syntax_saga_gaps") +public class SyntaxSagaGap { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_id") + private SyntaxSagaMission mission; + + /** 1-basierte Position, = Markernummer {@code {{n}}} im Codetemplate. */ + @Column(name = "gap_position") + private int position; + + private int points = 100; + + /** Groß-/Kleinschreibung relevant? (true für Java-Bezeichner, false z.B. für SQL-Keywords). */ + @Column(name = "case_sensitive") + private boolean caseSensitive = true; + + private String hint; + + /** Kurzlabel/Platzhalter im Eingabefeld, z.B. "Methodenname". */ + private String placeholder; + + @OneToMany(mappedBy = "gap", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC, id ASC") + private List solutions = new ArrayList<>(); + + public SyntaxSagaGap() { + } + + public SyntaxSagaGap(int position, int points, boolean caseSensitive, String hint, String placeholder) { + this.position = position; + this.points = points; + this.caseSensitive = caseSensitive; + this.hint = hint; + this.placeholder = placeholder; + } + + public void addSolution(SyntaxSagaSolution solution) { + solution.setGap(this); + this.solutions.add(solution); + } + + /** Normalisiert (trim + interne Whitespace zu einem Space). */ + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " "); + } + + /** Prüft eine Eingabe serverseitig gegen alle gültigen Lösungen. */ + public boolean matches(String typed) { + if (typed == null) { + return false; + } + String t = normalize(typed); + if (t.isEmpty()) { + return false; + } + for (SyntaxSagaSolution solution : solutions) { + String sol = normalize(solution.getText()); + boolean equal = caseSensitive ? t.equals(sol) : t.equalsIgnoreCase(sol); + if (equal) { + return true; + } + } + return false; + } + + /** Kanonische Lösung (für die "Auflösung anzeigen"-Funktion). */ + public String firstSolution() { + return solutions.isEmpty() ? "" : solutions.get(0).getText(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public SyntaxSagaMission getMission() { return mission; } + public void setMission(SyntaxSagaMission mission) { this.mission = mission; } + + public int getPosition() { return position; } + public void setPosition(int position) { this.position = position; } + + public int getPoints() { return points; } + public void setPoints(int points) { this.points = points; } + + public boolean isCaseSensitive() { return caseSensitive; } + public void setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; } + + public String getHint() { return hint; } + public void setHint(String hint) { this.hint = hint; } + + public String getPlaceholder() { return placeholder; } + public void setPlaceholder(String placeholder) { this.placeholder = placeholder; } + + public List getSolutions() { return solutions; } + public void setSolutions(List solutions) { this.solutions = solutions; } +} diff --git a/src/main/java/com/example/mindflux/model/SyntaxSagaMission.java b/src/main/java/com/example/mindflux/model/SyntaxSagaMission.java new file mode 100644 index 0000000..7566ed8 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/SyntaxSagaMission.java @@ -0,0 +1,114 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Eine Syntax-Saga-Mission (ein Kapitel): Story + optionaler Dialog + Codeblock mit + * Lücken. Im {@code codeTemplate} markieren {@code {{1}}}, {@code {{2}}} … die Lücken + * (Nummer = {@link SyntaxSagaGap#getPosition() position}). Gespielt wird je + * Schwierigkeitsgrad als eigene lineare Saga. + */ +@Entity +@Table(name = "syntax_saga_missions") +public class SyntaxSagaMission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String slug; + + private String title; + + /** "junior" | "professional" | "senior". */ + private String difficulty; + + /** Erzähltext / Szene. */ + private String narrative; + + /** Optionales Zitat ("Bitte… repariere mich…"). */ + private String dialog; + + /** Sprache des Codes für Badge/Darstellung, z.B. "java", "sql", "bash". */ + @Column(name = "code_language") + private String language; + + /** Code mit {@code {{n}}}-Markern an den Lücken. */ + @Column(name = "code_template") + private String codeTemplate; + + /** Fortsetzung nach dem Lösen ("Das Terminal erwacht…"). */ + @Column(name = "success_text") + private String successText; + + @Column(name = "sort_order") + private int sortOrder; + + private boolean active = true; + + @OneToMany(mappedBy = "mission", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("position ASC, id ASC") + private List gaps = new ArrayList<>(); + + public SyntaxSagaMission() { + } + + public SyntaxSagaMission(String slug, String title, String difficulty, String narrative, + String dialog, String language, String codeTemplate, + String successText, int sortOrder, boolean active) { + this.slug = slug; + this.title = title; + this.difficulty = difficulty; + this.narrative = narrative; + this.dialog = dialog; + this.language = language; + this.codeTemplate = codeTemplate; + this.successText = successText; + this.sortOrder = sortOrder; + this.active = active; + } + + public void addGap(SyntaxSagaGap gap) { + gap.setMission(this); + this.gaps.add(gap); + } + + 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 getDifficulty() { return difficulty; } + public void setDifficulty(String difficulty) { this.difficulty = difficulty; } + + public String getNarrative() { return narrative; } + public void setNarrative(String narrative) { this.narrative = narrative; } + + public String getDialog() { return dialog; } + public void setDialog(String dialog) { this.dialog = dialog; } + + public String getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + + public String getCodeTemplate() { return codeTemplate; } + public void setCodeTemplate(String codeTemplate) { this.codeTemplate = codeTemplate; } + + public String getSuccessText() { return successText; } + public void setSuccessText(String successText) { this.successText = successText; } + + public int getSortOrder() { return sortOrder; } + public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; } + + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + + public List getGaps() { return gaps; } + public void setGaps(List gaps) { this.gaps = gaps; } +} diff --git a/src/main/java/com/example/mindflux/model/SyntaxSagaSolution.java b/src/main/java/com/example/mindflux/model/SyntaxSagaSolution.java new file mode 100644 index 0000000..353560e --- /dev/null +++ b/src/main/java/com/example/mindflux/model/SyntaxSagaSolution.java @@ -0,0 +1,43 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +/** Eine gültige Antwort zu einer {@link SyntaxSagaGap} (eine Lücke kann mehrere haben). */ +@Entity +@Table(name = "syntax_saga_solutions") +public class SyntaxSagaSolution { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "gap_id") + private SyntaxSagaGap gap; + + @Column(name = "solution_text") + private String text; + + @Column(name = "sort_order") + private int sortOrder; + + public SyntaxSagaSolution() { + } + + public SyntaxSagaSolution(String text, int sortOrder) { + this.text = text; + this.sortOrder = sortOrder; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public SyntaxSagaGap getGap() { return gap; } + public void setGap(SyntaxSagaGap gap) { this.gap = gap; } + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + + public int getSortOrder() { return sortOrder; } + public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; } +} diff --git a/src/main/java/com/example/mindflux/repository/SyntaxSagaGapRepository.java b/src/main/java/com/example/mindflux/repository/SyntaxSagaGapRepository.java new file mode 100644 index 0000000..ee79494 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/SyntaxSagaGapRepository.java @@ -0,0 +1,9 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.SyntaxSagaGap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SyntaxSagaGapRepository extends JpaRepository { + + long countByMissionId(Long missionId); +} diff --git a/src/main/java/com/example/mindflux/repository/SyntaxSagaMissionRepository.java b/src/main/java/com/example/mindflux/repository/SyntaxSagaMissionRepository.java new file mode 100644 index 0000000..2a206b8 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/SyntaxSagaMissionRepository.java @@ -0,0 +1,20 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.SyntaxSagaMission; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SyntaxSagaMissionRepository extends JpaRepository { + + /** Aktive Missionen einer Schwierigkeitsstufe in Spielreihenfolge. */ + List findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(String difficulty); + + /** Alle aktiven Missionen in Spielreihenfolge (stufenübergreifend). */ + List findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + /** Alle Missionen (Admin-Übersicht). */ + List findAllByOrderBySortOrderAscIdAsc(); + + boolean existsBySlug(String slug); +} diff --git a/src/main/java/com/example/mindflux/repository/SyntaxSagaSolutionRepository.java b/src/main/java/com/example/mindflux/repository/SyntaxSagaSolutionRepository.java new file mode 100644 index 0000000..4ce6af1 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/SyntaxSagaSolutionRepository.java @@ -0,0 +1,7 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.SyntaxSagaSolution; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SyntaxSagaSolutionRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/mindflux/service/SyntaxSagaService.java b/src/main/java/com/example/mindflux/service/SyntaxSagaService.java new file mode 100644 index 0000000..1f5f312 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/SyntaxSagaService.java @@ -0,0 +1,318 @@ +package com.example.mindflux.service; + +import com.example.mindflux.dto.*; +import com.example.mindflux.model.SyntaxSagaGap; +import com.example.mindflux.model.SyntaxSagaMission; +import com.example.mindflux.model.SyntaxSagaSolution; +import com.example.mindflux.repository.SyntaxSagaGapRepository; +import com.example.mindflux.repository.SyntaxSagaMissionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * Server-autoritative Logik für Syntax Saga: baut die Story (ohne Lösungen), prüft + * Eingaben serverseitig und berechnet den Score beim Abschluss neu. Wertung wandert + * über {@link GameScoreService} unter der Spiel-Kennung "syntax-saga" in die DB. + */ +@Service +public class SyntaxSagaService { + + private static final String GAME = "syntax-saga"; + private static final Set DIFFICULTIES = Set.of("junior", "professional", "senior"); + + private final SyntaxSagaMissionRepository missionRepository; + private final SyntaxSagaGapRepository gapRepository; + private final GameScoreService gameScoreService; + + public SyntaxSagaService(SyntaxSagaMissionRepository missionRepository, + SyntaxSagaGapRepository gapRepository, + GameScoreService gameScoreService) { + this.missionRepository = missionRepository; + this.gapRepository = gapRepository; + this.gameScoreService = gameScoreService; + } + + // ---------- Spieler ---------- + + /** Liefert die aktiven Missionen einer Stufe (oder stufenübergreifend) – ohne Lösungen. */ + @Transactional(readOnly = true) + public SyntaxSagaStoryView buildStory(String difficulty) { + String diff = normalizeOrNull(difficulty); + List missions = (diff == null) + ? missionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc() + : missionRepository.findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(diff); + + List views = new ArrayList<>(); + int maxScore = 0; + int totalGaps = 0; + + for (SyntaxSagaMission mission : missions) { + if (mission.getGaps().isEmpty()) { + continue; + } + List gapViews = new ArrayList<>(); + for (SyntaxSagaGap gap : mission.getGaps()) { + gapViews.add(new SyntaxSagaStoryView.Gap( + gap.getPosition(), gap.getPoints(), gap.getHint(), gap.getPlaceholder())); + maxScore += gap.getPoints(); + totalGaps++; + } + views.add(new SyntaxSagaStoryView.Mission( + mission.getId(), mission.getTitle(), mission.getDifficulty(), mission.getLanguage(), + mission.getNarrative(), mission.getDialog(), mission.getCodeTemplate(), + mission.getSuccessText(), gapViews)); + } + return new SyntaxSagaStoryView(diff == null ? "all" : diff, views, maxScore, totalGaps); + } + + /** Prüft die getippten Lücken einer Mission serverseitig (Frontend kennt die Lösung nicht). */ + @Transactional(readOnly = true) + public SyntaxSagaCheckResult check(SyntaxSagaCheckRequest request) { + List results = new ArrayList<>(); + if (request == null || request.getMissionId() == null) { + return new SyntaxSagaCheckResult(false, results); + } + SyntaxSagaMission mission = missionRepository.findById(request.getMissionId()).orElse(null); + if (mission == null) { + return new SyntaxSagaCheckResult(false, results); + } + + Map typedByPosition = new HashMap<>(); + if (request.getAnswers() != null) { + for (SyntaxSagaCheckRequest.GapAnswer answer : request.getAnswers()) { + typedByPosition.put(answer.getPosition(), answer.getText()); + } + } + + boolean allCorrect = true; + for (SyntaxSagaGap gap : mission.getGaps()) { + boolean correct = gap.matches(typedByPosition.get(gap.getPosition())); + if (!correct) { + allCorrect = false; + } + String solution = (request.isReveal() && !correct) ? gap.firstSolution() : null; + results.add(new SyntaxSagaCheckResult.GapVerdict(gap.getPosition(), correct, solution)); + } + return new SyntaxSagaCheckResult(allCorrect, results); + } + + /** Schließt einen Durchlauf ab: Score wird aus den tatsächlichen Eingaben recomputed und gespeichert. */ + @Transactional + public SyntaxSagaResult finish(String username, SyntaxSagaFinishRequest request) { + int score = 0; + int maxScore = 0; + int solvedGaps = 0; + int totalGaps = 0; + + Map missionCache = new HashMap<>(); + + if (request.getGaps() != null) { + for (SyntaxSagaFinishRequest.FinishGap fg : request.getGaps()) { + if (fg.getMissionId() == null) { + continue; + } + SyntaxSagaMission mission = missionCache.computeIfAbsent( + fg.getMissionId(), id -> missionRepository.findById(id).orElse(null)); + if (mission == null) { + continue; + } + SyntaxSagaGap gap = gapByPosition(mission, fg.getPosition()); + if (gap == null) { + continue; + } + totalGaps++; + maxScore += gap.getPoints(); + + boolean correct = gap.matches(fg.getText()); + if (correct && !fg.isRevealed()) { + solvedGaps++; + score += awardedPoints(gap.getPoints(), fg.getAttempts()); + } + } + } + + int stars = stars(score, maxScore); + int seconds = Math.max(request.getDurationSeconds(), 0); + String deck = normalizeOrNull(request.getDifficulty()); + ScoreResult saved = gameScoreService.recordScore( + username, GAME, deck == null ? "all" : deck, score, solvedGaps, seconds); + + return new SyntaxSagaResult(score, maxScore, solvedGaps, totalGaps, stars, + saved.getHighscore(), saved.isNewHighscore(), saved.getPlays()); + } + + private SyntaxSagaGap gapByPosition(SyntaxSagaMission mission, int position) { + for (SyntaxSagaGap gap : mission.getGaps()) { + if (gap.getPosition() == position) { + return gap; + } + } + return null; + } + + /** Volle Punkte bei Erstversuch, weniger bei mehr Versuchen. */ + private int awardedPoints(int points, int attempts) { + int a = Math.max(1, attempts); + double factor = switch (a) { + case 1 -> 1.0; + case 2 -> 0.6; + default -> 0.3; + }; + return (int) Math.round(points * factor); + } + + private int stars(int score, int maxScore) { + if (maxScore <= 0 || score <= 0) { + return 0; + } + double ratio = (double) score / maxScore; + if (ratio >= 0.8) { + return 3; + } + if (ratio >= 0.5) { + return 2; + } + return 1; + } + + // ---------- Admin ---------- + + /** Leichte Übersicht: Missionen mit Lücken-Anzahl (lädt nicht alle Lücken). */ + @Transactional(readOnly = true) + public List missionRows() { + return missionRepository.findAllByOrderBySortOrderAscIdAsc().stream() + .map(m -> new SyntaxSagaMissionRow(m.getId(), m.getSlug(), m.getTitle(), + m.getDifficulty(), m.isActive(), gapRepository.countByMissionId(m.getId()))) + .toList(); + } + + /** Eine Mission inkl. Lücken und Lösungen (für die Detailseite). */ + @Transactional(readOnly = true) + public SyntaxSagaMission missionWithGaps(Long id) { + SyntaxSagaMission mission = missionRepository.findById(id).orElse(null); + if (mission != null) { + mission.getGaps().forEach(g -> g.getSolutions().size()); // lazy init + } + return mission; + } + + @Transactional + public Long createMission(String title, String difficulty, String narrative, String dialog, + String language, String codeTemplate, String successText) { + if (title == null || title.isBlank() || codeTemplate == null || codeTemplate.isBlank()) { + return null; + } + String clean = title.trim(); + int order = (int) missionRepository.count() + 1; + SyntaxSagaMission mission = new SyntaxSagaMission( + uniqueSlug(clean), clean, normalizeDifficulty(difficulty), + narrative == null ? "" : narrative.trim(), + blankToNull(dialog), blankToNull(language), + codeTemplate.trim(), blankToNull(successText), order, true); + missionRepository.save(mission); + return mission.getId(); + } + + @Transactional + public void updateMission(Long missionId, String title, String difficulty, String narrative, + String dialog, String language, String codeTemplate, String successText) { + missionRepository.findById(missionId).ifPresent(m -> { + if (title != null && !title.isBlank()) { + m.setTitle(title.trim()); + } + m.setDifficulty(normalizeDifficulty(difficulty)); + m.setNarrative(narrative == null ? "" : narrative.trim()); + m.setDialog(blankToNull(dialog)); + m.setLanguage(blankToNull(language)); + if (codeTemplate != null && !codeTemplate.isBlank()) { + m.setCodeTemplate(codeTemplate.trim()); + } + m.setSuccessText(blankToNull(successText)); + }); + } + + @Transactional + public void addGap(Long missionId, int position, int points, boolean caseSensitive, + String hint, String placeholder, List solutions) { + if (solutions == null) { + return; + } + missionRepository.findById(missionId).ifPresent(mission -> { + int pos = position > 0 ? position : mission.getGaps().size() + 1; + SyntaxSagaGap gap = new SyntaxSagaGap( + pos, normalizePoints(points), caseSensitive, blankToNull(hint), blankToNull(placeholder)); + int sort = 0; + for (String s : solutions) { + if (s != null && !s.isBlank()) { + gap.addSolution(new SyntaxSagaSolution(s.trim(), sort++)); + } + } + if (!gap.getSolutions().isEmpty()) { + mission.addGap(gap); + missionRepository.save(mission); + } + }); + } + + @Transactional + public void deleteGap(Long gapId) { + gapRepository.deleteById(gapId); + } + + @Transactional + public void deleteMission(Long missionId) { + missionRepository.deleteById(missionId); + } + + @Transactional + public void toggleMission(Long missionId) { + missionRepository.findById(missionId).ifPresent(m -> m.setActive(!m.isActive())); + } + + // ---------- Helfer ---------- + + private int normalizePoints(int points) { + if (points <= 0) { + return 100; + } + return Math.min(1000, points); + } + + private String normalizeDifficulty(String difficulty) { + if (difficulty == null) { + return "junior"; + } + String x = difficulty.trim().toLowerCase(); + return DIFFICULTIES.contains(x) ? x : "junior"; + } + + private String normalizeOrNull(String difficulty) { + if (difficulty == null || difficulty.isBlank()) { + return null; + } + String x = difficulty.trim().toLowerCase(); + return DIFFICULTIES.contains(x) ? x : null; + } + + private String blankToNull(String value) { + return (value == null || value.isBlank()) ? null : value.trim(); + } + + 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 = "mission"; + } + String slug = base; + int counter = 2; + while (missionRepository.existsBySlug(slug)) { + slug = base + "-" + counter++; + } + return slug; + } +} diff --git a/src/main/resources/db/migration/V7__syntax_saga.sql b/src/main/resources/db/migration/V7__syntax_saga.sql new file mode 100644 index 0000000..4cb4e6a --- /dev/null +++ b/src/main/resources/db/migration/V7__syntax_saga.sql @@ -0,0 +1,38 @@ +-- Syntax Saga: interaktive IT-Geschichten mit Code-Lücken (Junior/Professional/Senior). +-- Mission -> Lücken (eine oder mehrere je Codeblock) -> Lösungen (eine Lücke kann mehrere +-- gültige Antworten haben). Inhalte DB-basiert und Admin-pflegbar; gefüllt per Seeder +-- aus syntaxsaga/content.json. Server-autoritativ: Lösungen verlassen nie das Backend. + +CREATE TABLE syntax_saga_missions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + difficulty VARCHAR(30) NOT NULL, + narrative VARCHAR(2000) NOT NULL, + dialog VARCHAR(1000), + code_language VARCHAR(30), + code_template VARCHAR(2000) NOT NULL, + success_text VARCHAR(2000), + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE syntax_saga_gaps ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + mission_id BIGINT NOT NULL REFERENCES syntax_saga_missions (id) ON DELETE CASCADE, + gap_position INT NOT NULL, + points INT NOT NULL DEFAULT 100, + case_sensitive BOOLEAN NOT NULL DEFAULT TRUE, + hint VARCHAR(500), + placeholder VARCHAR(120) +); + +CREATE TABLE syntax_saga_solutions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + gap_id BIGINT NOT NULL REFERENCES syntax_saga_gaps (id) ON DELETE CASCADE, + solution_text VARCHAR(500) NOT NULL, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_syntax_saga_gaps_mission ON syntax_saga_gaps (mission_id); +CREATE INDEX idx_syntax_saga_solutions_gap ON syntax_saga_solutions (gap_id); diff --git a/src/main/resources/static/css/syntaxsaga.css b/src/main/resources/static/css/syntaxsaga.css new file mode 100644 index 0000000..4cceba9 --- /dev/null +++ b/src/main/resources/static/css/syntaxsaga.css @@ -0,0 +1,247 @@ +/* === Syntax Saga === Interaktive IT-Geschichten mit Code-Lücken. + Farben/Tokens zentral aus theme.css (richtig = Blau, falsch = Rot, Akzent = Amber). + Anders als TechTacular ist der Inhalt scrollbar (Story + Codeblock können hoch werden). */ + +@keyframes ssFadeInUp { + 0% { opacity: 0; transform: translateY(16px); } + 100% { opacity: 1; transform: translateY(0); } +} + +body.ss-body { + min-height: 100vh; + display: flex; + flex-direction: column; + color: var(--text); +} + +.ss-shell { + flex: 1; + width: 100%; + max-width: 980px; + margin: 0 auto; + display: flex; + flex-direction: column; + padding: 0.7rem 0.9rem 1.4rem; + gap: 0.7rem; +} + +/* --- Kopf + Leiste --- */ +.ss-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1.1rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--g-soft), 0 0 8px var(--glow-w); +} +.ss-header-tools { display: flex; align-items: center; gap: 0.7rem; } +.ss-title { font-size: 1.3rem; color: var(--accent); margin: 0; text-shadow: 0 0 8px var(--glow-s); } +.ss-back, .ss-logout { text-decoration: none; font-weight: bold; } +.ss-back { color: var(--accent); } +.ss-logout { color: var(--danger); } + +.ss-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.55rem 0.85rem; +} +.ss-bar-group { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; } +.ss-bar-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; } + +.ss-diff { + padding: 0.45rem 0.9rem; + border: 1px solid var(--border-accent); + border-radius: 999px; + background: var(--surface-2); + color: var(--text); + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} +.ss-diff:hover { border-color: var(--accent); } +.ss-diff.active { + background: linear-gradient(100deg, var(--accent), var(--accent-2)); + color: var(--accent-ink); + box-shadow: 0 0 16px var(--glow-s); +} + +/* --- HUD --- */ +.ss-hud { gap: 0.4rem; } +.ss-stat { + display: inline-flex; + align-items: baseline; + gap: 0.4rem; + padding: 0.35rem 0.7rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + white-space: nowrap; +} +.ss-stat b { font-size: 1.05rem; color: var(--accent); } + +/* --- Bühne / Szene --- */ +.ss-stage { flex: 1; display: flex; flex-direction: column; } +.ss-idle { margin: auto; color: var(--muted); text-align: center; padding: 2.4rem 1rem; max-width: 540px; line-height: 1.6; } + +.ss-scene { + background: var(--surface); + border: 1px solid var(--border-accent); + border-radius: 16px; + padding: 1.3rem 1.5rem 1.5rem; + box-shadow: 0 0 24px var(--glow-w); + animation: ssFadeInUp 0.35s ease-out; +} + +.ss-scene-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem; } +.ss-mission-no { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 700; } +.ss-badge { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.16rem 0.5rem; + border-radius: 7px; + border: 1px solid var(--border-accent); +} +.ss-badge-diff { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); } +.ss-badge-lang { background: color-mix(in srgb, var(--secondary) 16%, transparent); color: var(--secondary); border-color: var(--secondary); font-family: monospace; } + +.ss-mission-title { color: var(--heading); font-size: 1.4rem; font-weight: 800; margin: 0.1rem 0 0.6rem; } +.ss-narrative { color: var(--text); line-height: 1.6; margin-bottom: 0.6rem; } +.ss-dialog { + color: var(--accent); + font-style: italic; + border-left: 3px solid var(--accent); + padding-left: 0.8rem; + margin-bottom: 0.9rem; + text-shadow: 0 0 10px var(--glow-w); +} + +/* --- Codeblock mit Lücken --- */ +.ss-code { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.1rem; + margin: 0.4rem 0 0.9rem; + overflow-x: auto; + box-shadow: inset 0 0 22px var(--glow-w); +} +.ss-code code { + font-family: "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace; + font-size: 0.95rem; + line-height: 2; + white-space: pre; + color: var(--text); +} + +.ss-gap { + font-family: inherit; + font-size: inherit; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid var(--border-accent); + border-radius: 5px; + padding: 0.05rem 0.35rem; + margin: 0 0.12rem; + min-width: 3ch; + vertical-align: baseline; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; +} +.ss-gap::placeholder { color: var(--muted); opacity: 0.7; } +.ss-gap:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); background: color-mix(in srgb, var(--accent) 16%, transparent); } +.ss-gap.correct { color: var(--success); border-color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); } +.ss-gap.wrong { color: var(--danger); border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, transparent); } +.ss-gap.revealed { color: var(--secondary); border-color: var(--secondary); background: color-mix(in srgb, var(--secondary) 14%, transparent); } +.ss-gap:disabled { cursor: default; } + +/* --- Feedback / Erfolg --- */ +.ss-feedback { min-height: 1.3rem; font-weight: 700; margin-bottom: 0.5rem; line-height: 1.5; } +.ss-feedback.correct { color: var(--success); } +.ss-feedback.correct::before { content: "\2713 "; font-weight: 800; } +.ss-feedback.wrong { color: var(--danger); } +.ss-feedback.wrong::before { content: "\2717 "; font-weight: 800; } +.ss-feedback.revealed { color: var(--secondary); } + +.ss-success { + color: var(--secondary); + background: color-mix(in srgb, var(--secondary) 10%, transparent); + border-left: 3px solid var(--secondary); + border-radius: 6px; + padding: 0.7rem 0.9rem; + margin-bottom: 0.8rem; + line-height: 1.55; +} +.ss-success[hidden] { display: none; } + +/* --- Buttons --- */ +.ss-scene-actions { display: flex; gap: 0.7rem; flex-wrap: wrap; } +.ss-btn { + padding: 0.55rem 1.2rem; + border: 0; + border-radius: 9px; + background: var(--accent); + color: var(--accent-ink); + font-weight: 800; + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 16px var(--glow-w); + transition: filter 0.2s ease, transform 0.15s ease; +} +.ss-btn:hover { filter: brightness(1.07); transform: translateY(-2px); } +.ss-btn[hidden] { display: none; } +.ss-btn-secondary { background: var(--surface-2); border: 1px solid var(--border-accent); color: var(--text); box-shadow: none; } +.ss-btn-ghost { background: transparent; border: 1px solid var(--border-accent); color: var(--accent); box-shadow: none; } +.ss-btn-ghost:hover { background: var(--surface-2); } + +/* --- Ergebnis-Overlay --- */ +.ss-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-glow), rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 1000; + padding: 1rem; +} +.ss-overlay[hidden] { display: none; } +.ss-result-card { + background: var(--surface); + border: 1px solid var(--border-accent); + border-radius: 16px; + padding: 1.6rem 1.8rem; + width: min(440px, 94vw); + text-align: center; + box-shadow: 0 0 30px var(--glow-w); + animation: ssFadeInUp 0.4s ease-out; +} +.ss-result-card h2 { color: var(--accent); margin-bottom: 0.4rem; } +.ss-result-stars { font-size: 2rem; color: var(--accent); letter-spacing: 0.2rem; margin-bottom: 0.5rem; text-shadow: 0 0 12px var(--glow-s); } +.ss-result-score { font-size: 1.4rem; margin-bottom: 0.4rem; color: var(--text); } +.ss-result-score strong { color: var(--accent); } +.ss-result-detail { color: var(--muted); margin-bottom: 0.6rem; } +.ss-result-high { color: var(--accent); font-weight: 800; margin-bottom: 1rem; } +.ss-result-high[hidden] { display: none; } +.ss-result-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; margin-top: 1rem; } + +@media (max-width: 600px) { + .ss-title { font-size: 1.15rem; } + .ss-stat { font-size: 0.62rem; padding: 0.3rem 0.5rem; } + .ss-scene { padding: 1.1rem 1rem 1.2rem; } + .ss-mission-title { font-size: 1.2rem; } + .ss-code code { font-size: 0.85rem; } +} diff --git a/src/main/resources/static/js/syntaxsaga.js b/src/main/resources/static/js/syntaxsaga.js new file mode 100644 index 0000000..fd26d94 --- /dev/null +++ b/src/main/resources/static/js/syntaxsaga.js @@ -0,0 +1,434 @@ +/* Syntax Saga – interaktive IT-Geschichten mit Code-Lücken. + Story kommt aus /api/syntaxsaga/story (ohne Lösung), Prüfung + Score serverseitig. */ +(() => { + 'use strict'; + + const GAME = 'syntax-saga'; + const MAX_ATTEMPTS = 3; + + const storyEl = document.getElementById('ss-story'); + const diffBtns = Array.from(document.querySelectorAll('.ss-diff')); + const scoreEl = document.getElementById('ss-score'); + const missionEl = document.getElementById('ss-mission'); + const timeEl = document.getElementById('ss-time'); + const bestEl = document.getElementById('ss-best'); + + const resultEl = document.getElementById('ss-result'); + const resultStars = document.getElementById('ss-result-stars'); + const resultScore = document.getElementById('ss-result-score'); + const resultMax = document.getElementById('ss-result-max'); + const resultDetail = document.getElementById('ss-result-detail'); + const resultHigh = document.getElementById('ss-result-high'); + const againBtn = document.getElementById('ss-again'); + const chooseBtn = document.getElementById('ss-choose'); + + const DIFF_LABEL = { junior: 'Junior', professional: 'Professional', senior: 'Senior' }; + + let story = null; // StoryView vom Server + let missionIndex = 0; + let finishGaps = []; // gesammelte Lücken über alle Missionen -> /finish + let score = 0; // laufender Client-Schätzwert (spiegelt die Serverformel) + let seconds = 0; + let timerId = null; + let running = false; + + let cur = null; // Laufzeitzustand der aktuellen Mission + let curInputs = {}; // position -> + let curGaps = {}; // position -> Gap-Daten (points/hint/placeholder) + + // ---------- Helfer ---------- + + function formatTime(s) { + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + } + + function startTimer() { + if (timerId) return; + timerId = setInterval(() => { seconds++; timeEl.textContent = formatTime(seconds); }, 1000); + } + function stopTimer() { clearInterval(timerId); timerId = null; } + + /** Punkte aus Versuch (Erstversuch voll, danach weniger) – identisch zur Serverformel. */ + function awarded(points, attempts) { + const a = Math.max(1, attempts); + const factor = a === 1 ? 1.0 : a === 2 ? 0.6 : 0.3; + return Math.round(points * factor); + } + + function setIdle(message) { + storyEl.innerHTML = `

${message}

`; + } + + function updateHud() { + scoreEl.textContent = String(score); + if (story && story.missions.length) { + missionEl.textContent = `${Math.min(missionIndex + 1, story.missions.length)} / ${story.missions.length}`; + } else { + missionEl.textContent = '– / –'; + } + } + + // ---------- Code-Rendering ---------- + + /** Baut -Inhalt: Textsegmente als Textknoten, {{n}} als Eingabefelder. */ + function renderCode(codeEl, template) { + codeEl.textContent = ''; + curInputs = {}; + const regex = /\{\{(\d+)\}\}/g; + let last = 0; + let m; + while ((m = regex.exec(template)) !== null) { + if (m.index > last) { + codeEl.appendChild(document.createTextNode(template.slice(last, m.index))); + } + const pos = parseInt(m[1], 10); + const gap = curGaps[pos]; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'ss-gap'; + input.dataset.pos = String(pos); + input.autocomplete = 'off'; + input.autocapitalize = 'off'; + input.spellcheck = false; + input.setAttribute('autocorrect', 'off'); + if (gap && gap.placeholder) input.placeholder = gap.placeholder; + input.size = Math.max(6, gap && gap.placeholder ? gap.placeholder.length : 8); + input.addEventListener('input', () => { + input.size = Math.max(4, input.value.length + 1); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); onCheck(false); } + }); + curInputs[pos] = input; + codeEl.appendChild(input); + last = m.index + m[0].length; + } + if (last < template.length) { + codeEl.appendChild(document.createTextNode(template.slice(last))); + } + } + + // ---------- Mission rendern ---------- + + function renderMission() { + const mission = story.missions[missionIndex]; + cur = { mission, attempts: {}, revealed: {}, solved: {}, scored: {} }; + curGaps = {}; + (mission.gaps || []).forEach((g) => { curGaps[g.position] = g; }); + + storyEl.innerHTML = ''; + const scene = document.createElement('div'); + scene.className = 'ss-scene'; + + // Kopf + const head = document.createElement('div'); + head.className = 'ss-scene-head'; + const no = document.createElement('span'); + no.className = 'ss-mission-no'; + no.textContent = `Mission ${missionIndex + 1} / ${story.missions.length}`; + head.appendChild(no); + const diff = document.createElement('span'); + diff.className = 'ss-badge ss-badge-diff'; + diff.textContent = DIFF_LABEL[mission.difficulty] || mission.difficulty; + head.appendChild(diff); + if (mission.language) { + const lang = document.createElement('span'); + lang.className = 'ss-badge ss-badge-lang'; + lang.textContent = mission.language; + head.appendChild(lang); + } + scene.appendChild(head); + + // Titel + Story + const title = document.createElement('h2'); + title.className = 'ss-mission-title'; + title.textContent = mission.title; + scene.appendChild(title); + + if (mission.narrative) { + const nar = document.createElement('p'); + nar.className = 'ss-narrative'; + nar.textContent = mission.narrative; + scene.appendChild(nar); + } + if (mission.dialog) { + const dlg = document.createElement('p'); + dlg.className = 'ss-dialog'; + dlg.textContent = `„${mission.dialog}“`; + scene.appendChild(dlg); + } + + // Codeblock mit Lücken + const pre = document.createElement('pre'); + pre.className = 'ss-code'; + const code = document.createElement('code'); + renderCode(code, mission.codeTemplate || ''); + pre.appendChild(code); + scene.appendChild(pre); + + // Feedback + const feedback = document.createElement('p'); + feedback.className = 'ss-feedback'; + scene.appendChild(feedback); + + // Erfolgstext (nach Lösen) + const success = document.createElement('p'); + success.className = 'ss-success'; + success.hidden = true; + scene.appendChild(success); + + // Aktionen + const actions = document.createElement('div'); + actions.className = 'ss-scene-actions'; + const checkBtn = document.createElement('button'); + checkBtn.type = 'button'; + checkBtn.className = 'ss-btn'; + checkBtn.textContent = 'Prüfen'; + checkBtn.addEventListener('click', () => onCheck(false)); + const revealBtn = document.createElement('button'); + revealBtn.type = 'button'; + revealBtn.className = 'ss-btn ss-btn-ghost'; + revealBtn.textContent = 'Auflösung anzeigen'; + revealBtn.hidden = true; + revealBtn.addEventListener('click', () => onCheck(true)); + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.className = 'ss-btn'; + nextBtn.textContent = missionIndex + 1 < story.missions.length ? 'Weiter' : 'Saga abschließen'; + nextBtn.hidden = true; + nextBtn.addEventListener('click', nextMission); + actions.appendChild(checkBtn); + actions.appendChild(revealBtn); + actions.appendChild(nextBtn); + scene.appendChild(actions); + + cur.els = { scene, feedback, success, checkBtn, revealBtn, nextBtn }; + + storyEl.appendChild(scene); + updateHud(); + const first = Object.values(curInputs)[0]; + if (first) first.focus(); + } + + // ---------- Prüfen / Auflösen ---------- + + async function onCheck(reveal) { + if (!running || !cur) return; + const gaps = cur.mission.gaps || []; + + if (!reveal) { + gaps.forEach((g) => { + if (!cur.solved[g.position] && !cur.revealed[g.position]) { + cur.attempts[g.position] = (cur.attempts[g.position] || 0) + 1; + } + }); + } + + const answers = gaps.map((g) => ({ + position: g.position, + text: (curInputs[g.position] && curInputs[g.position].value) || '' + })); + + let data; + try { + const res = await fetch('/api/syntaxsaga/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ missionId: cur.mission.missionId, reveal, answers }) + }); + data = await res.json(); + } catch (err) { + cur.els.feedback.textContent = 'Antwort konnte nicht geprüft werden.'; + cur.els.feedback.className = 'ss-feedback wrong'; + console.error(err); + return; + } + + applyVerdicts(data, reveal); + refreshSceneState(); + } + + function applyVerdicts(data, reveal) { + (data.results || []).forEach((v) => { + const input = curInputs[v.position]; + if (!input) return; + if (cur.solved[v.position] || cur.revealed[v.position]) return; + + if (v.correct) { + input.classList.add('correct'); + input.classList.remove('wrong'); + input.disabled = true; + cur.solved[v.position] = true; + if (!cur.scored[v.position]) { + cur.scored[v.position] = true; + const gap = curGaps[v.position]; + if (gap) { score += awarded(gap.points, cur.attempts[v.position] || 1); } + } + } else if (reveal && v.solution) { + input.value = v.solution; + input.classList.add('revealed'); + input.classList.remove('wrong'); + input.disabled = true; + cur.revealed[v.position] = true; + } else { + input.classList.add('wrong'); + } + }); + updateHud(); + } + + function refreshSceneState() { + const gaps = cur.mission.gaps || []; + const open = gaps.filter((g) => !cur.solved[g.position] && !cur.revealed[g.position]); + const fb = cur.els.feedback; + + if (open.length === 0) { + // Mission gelöst (oder aufgelöst) + cur.els.checkBtn.hidden = true; + cur.els.revealBtn.hidden = true; + cur.els.nextBtn.hidden = false; + const anyRevealed = gaps.some((g) => cur.revealed[g.position]); + fb.textContent = anyRevealed ? 'Aufgelöst – weiter geht die Geschichte.' : 'Richtig! Sauber gelöst.'; + fb.className = anyRevealed ? 'ss-feedback revealed' : 'ss-feedback correct'; + if (cur.mission.successText) { + cur.els.success.textContent = cur.mission.successText; + cur.els.success.hidden = false; + } + cur.els.nextBtn.focus(); + return; + } + + // noch offene Lücken + const hints = open.map((g) => curGaps[g.position] && curGaps[g.position].hint).filter(Boolean); + fb.className = 'ss-feedback wrong'; + fb.textContent = hints.length ? `Noch nicht ganz. Tipp: ${hints.join(' · ')}` : 'Noch nicht ganz – versuch es noch einmal.'; + + const exhausted = open.some((g) => (cur.attempts[g.position] || 0) >= MAX_ATTEMPTS); + cur.els.revealBtn.hidden = !exhausted; + if (exhausted) { + fb.textContent = hints.length + ? `Versuche aufgebraucht. Tipp: ${hints.join(' · ')} – oder zeig dir die Auflösung.` + : 'Versuche aufgebraucht – zeig dir die Auflösung.'; + } + } + + // ---------- Navigation ---------- + + function collectCurrentMission() { + (cur.mission.gaps || []).forEach((g) => { + finishGaps.push({ + missionId: cur.mission.missionId, + position: g.position, + text: (curInputs[g.position] && curInputs[g.position].value) || '', + attempts: cur.attempts[g.position] || 1, + revealed: !!cur.revealed[g.position] + }); + }); + } + + function nextMission() { + collectCurrentMission(); + missionIndex++; + if (missionIndex < story.missions.length) { + renderMission(); + } else { + finishRun(); + } + } + + async function finishRun() { + running = false; + stopTimer(); + try { + const res = await fetch('/api/syntaxsaga/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + difficulty: story.difficulty, + durationSeconds: seconds, + gaps: finishGaps + }) + }); + const data = await res.json(); + score = data.score; + updateHud(); + const stars = Math.max(0, Math.min(3, data.stars)); + resultStars.textContent = '★'.repeat(stars) + '☆'.repeat(3 - stars); + resultScore.textContent = String(data.score); + resultMax.textContent = String(data.maxScore); + resultDetail.textContent = + `${data.solvedGaps} von ${data.totalGaps} Lücken auf Anhieb · ${formatTime(seconds)}`; + resultHigh.hidden = !data.newHighscore; + bestEl.textContent = String(data.highscore); + resultEl.hidden = false; + } catch (err) { + console.error('Saga konnte nicht gespeichert werden:', err); + } + } + + // ---------- Start / Stats ---------- + + async function loadStats() { + try { + const res = await fetch(`/api/games/${GAME}/stats`); + if (!res.ok) return; + const stats = await res.json(); + bestEl.textContent = stats.highscore > 0 ? String(stats.highscore) : '–'; + } catch (err) { /* egal */ } + } + + async function startRun(difficulty) { + resultEl.hidden = true; + stopTimer(); + seconds = 0; score = 0; missionIndex = 0; finishGaps = []; + timeEl.textContent = '0:00'; + diffBtns.forEach((b) => b.classList.toggle('active', b.dataset.diff === difficulty)); + setIdle('Lade Mission …'); + + try { + const res = await fetch('/api/syntaxsaga/story?difficulty=' + encodeURIComponent(difficulty)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + story = await res.json(); + } catch (err) { + console.error('Story konnte nicht geladen werden:', err); + setIdle('Story konnte nicht geladen werden.'); + return; + } + + if (!story.missions || !story.missions.length) { + setIdle('Für diese Stufe gibt es noch keine Missionen. Ein Admin kann sie unter „Syntax Saga verwalten“ anlegen.'); + running = false; + updateHud(); + return; + } + + running = true; + renderMission(); + startTimer(); + } + + diffBtns.forEach((btn) => { + btn.addEventListener('click', () => { + if (running && finishGaps.length === 0 && missionIndex === 0 && cur && + Object.keys(cur.solved).length === 0) { + // noch nichts gespielt -> einfach wechseln + } else if (running && !window.confirm('Aktuelle Saga abbrechen und neu starten?')) { + return; + } + startRun(btn.dataset.diff); + }); + }); + + againBtn.addEventListener('click', () => startRun(story ? story.difficulty : 'junior')); + chooseBtn.addEventListener('click', () => { + resultEl.hidden = true; + running = false; + story = null; + diffBtns.forEach((b) => b.classList.remove('active')); + setIdle('Wähle oben eine Stufe, um deine Saga zu starten.'); + updateHud(); + }); + + loadStats(); + setIdle('Wähle oben eine Stufe – Junior, Professional oder Senior – um deine Saga zu starten.'); +})(); diff --git a/src/main/resources/syntaxsaga/content.json b/src/main/resources/syntaxsaga/content.json new file mode 100644 index 0000000..365feb1 --- /dev/null +++ b/src/main/resources/syntaxsaga/content.json @@ -0,0 +1,424 @@ +{ + "missions": [ + { + "difficulty": "junior", + "title": "Hello World", + "language": "java", + "narrative": "Dein erster Tag als Praktikant. Der Serverraum ist still, das Terminal reagiert nicht – es fehlt der Einstiegspunkt des Programms.", + "dialog": null, + "code": "public class Main {\n public static void {{1}}(String[] args) {\n System.out.println(\"Hello World\");\n }\n}", + "successText": "Das Terminal erwacht zum Leben und begrüßt dich: \"Hello World\".", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Methodenname", "hint": "Der Einstiegspunkt jeder Java-Anwendung.", "solutions": ["main"] } + ] + }, + { + "difficulty": "junior", + "title": "Die verschwundene Variable", + "language": "java", + "narrative": "Ein Kunde meldet einen Fehler: Auf dem Bildschirm soll die Anzahl der Leben erscheinen – doch da steht nichts.", + "dialog": null, + "code": "int lives = 3;\nSystem.out.println({{1}});", + "successText": "Die Zahl 3 leuchtet auf dem Monitor. Der Kunde atmet auf.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Variable", "hint": "Welche Variable soll ausgegeben werden?", "solutions": ["lives"] } + ] + }, + { + "difficulty": "junior", + "title": "Der flackernde Monitor", + "language": "java", + "narrative": "Der Serverraum ist dunkel. Nur ein Monitor flackert und wartet auf eine Eingabe, die er nicht lesen kann.", + "dialog": "Bitte… repariere mich…", + "code": "Scanner sc = new {{1}}(System.in);", + "successText": "Eingaben sind wieder möglich. Der Monitor beruhigt sich.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Klassenname", "hint": "Dieselbe Klasse wie links vom Gleichheitszeichen.", "solutions": ["Scanner"] } + ] + }, + { + "difficulty": "junior", + "title": "Zehn Server", + "language": "java", + "narrative": "Zehn Server müssen der Reihe nach geprüft werden – aber die Schleife, die sie durchläuft, fehlt.", + "dialog": null, + "code": "{{1}} (int i = 0; i < 10; i++) {\n check(server[i]);\n}", + "successText": "Alle zehn Server melden nacheinander: OK.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Die Zählschleife mit Start, Bedingung und Schritt.", "solutions": ["for"] } + ] + }, + { + "difficulty": "junior", + "title": "Der Virus", + "language": "java", + "narrative": "Eine Schadsoftware hat eine Bedingung verdreht. Das Achievement schaltet nicht mehr frei, wenn der Score 100 erreicht.", + "dialog": null, + "code": "if (score {{1}} 100) {\n unlockAchievement();\n}", + "successText": "Bei 100 Punkten schaltet das Achievement wieder frei. Der Virus ist neutralisiert.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "Mindestens 100 – größer oder gleich.", "solutions": [">="] } + ] + }, + { + "difficulty": "junior", + "title": "Die Datenbank", + "language": "sql", + "narrative": "Die KI hat alle Datensätze durcheinandergebracht. Du brauchst nur die volljährigen Nutzer – die Filter-Klausel fehlt.", + "dialog": null, + "code": "SELECT * FROM users\n{{1}} age > 18;", + "successText": "Nur noch die passenden Datensätze erscheinen in der Liste.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Die SQL-Klausel zum Filtern von Zeilen.", "solutions": ["WHERE"] } + ] + }, + { + "difficulty": "junior", + "title": "Netzwerk", + "language": "bash", + "narrative": "Du musst einen Server wieder erreichbar machen. Ein Ping soll zeigen, ob der lokale Rechner überhaupt antwortet.", + "dialog": null, + "code": "ping {{1}}", + "successText": "Reply… reply… reply. Die Verbindung steht wieder.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Host / IP", "hint": "Die Adresse des eigenen Rechners – als Name oder als IP.", "solutions": ["localhost", "192.168.0.1", "127.0.0.1"] } + ] + }, + { + "difficulty": "junior", + "title": "Der Dauerdienst", + "language": "java", + "narrative": "Ein Hintergrunddienst soll laufen, solange er aktiv ist. Das Schleifen-Schlüsselwort wurde gelöscht.", + "dialog": null, + "code": "{{1}} (running) {\n handleRequests();\n}", + "successText": "Der Dienst nimmt wieder Anfragen entgegen.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Kopfgesteuerte Schleife mit nur einer Bedingung.", "solutions": ["while"] } + ] + }, + { + "difficulty": "junior", + "title": "Was zurückkommt", + "language": "java", + "narrative": "Eine Methode soll die Summe zweier Zahlen liefern – doch das Ergebnis kommt nie beim Aufrufer an.", + "dialog": null, + "code": "int sum(int a, int b) {\n {{1}} a + b;\n}", + "successText": "Die Methode liefert endlich ihren Wert zurück.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Beendet die Methode und gibt einen Wert zurück.", "solutions": ["return"] } + ] + }, + { + "difficulty": "junior", + "title": "Die leere Liste", + "language": "java", + "narrative": "Eine Namensliste soll angelegt werden, aber die konkrete Implementierung der Liste fehlt.", + "dialog": null, + "code": "List namen = new {{1}}<>();", + "successText": "Die Liste ist bereit, Namen aufzunehmen.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Klasse", "hint": "Die gängigste List-Implementierung (dynamisches Array).", "solutions": ["ArrayList"] } + ] + }, + { + "difficulty": "junior", + "title": "Ins Log-Verzeichnis", + "language": "bash", + "narrative": "Um den Fehler zu finden, musst du in das Verzeichnis mit den Logdateien wechseln.", + "dialog": null, + "code": "{{1}} /var/log", + "successText": "Du stehst jetzt mitten in den Logs.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Befehl", "hint": "\"change directory\" – Verzeichnis wechseln.", "solutions": ["cd"] } + ] + }, + { + "difficulty": "junior", + "title": "Sicher im Verlauf", + "language": "bash", + "narrative": "Deine Änderung ist fertig. Sichere sie mit einer Nachricht im Git-Verlauf.", + "dialog": null, + "code": "git {{1}} -m \"fix: login\"", + "successText": "Der Stand ist im Verlauf festgehalten.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Git-Befehl", "hint": "Schreibt die vorgemerkten Änderungen in die Historie.", "solutions": ["commit"] } + ] + }, + { + "difficulty": "junior", + "title": "Der tote Link", + "language": "html", + "narrative": "Auf der Kundenseite zeigt ein Link ins Leere – das Attribut für die Zieladresse fehlt.", + "dialog": null, + "code": "Start", + "successText": "Der Link führt jetzt sauber zur Startseite.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Attribut für die Zieladresse eines Links.", "solutions": ["href"] } + ] + }, + { + "difficulty": "junior", + "title": "Sortierte Ware", + "language": "sql", + "narrative": "Die Produktliste des Shops kommt wild durcheinander. Sie soll nach Preis absteigend sortiert werden.", + "dialog": null, + "code": "SELECT * FROM produkte\n{{1}} preis DESC;", + "successText": "Das teuerste Produkt steht jetzt oben.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Klausel", "hint": "Sortiert das Ergebnis (zwei Wörter).", "solutions": ["ORDER BY"] } + ] + }, + { + "difficulty": "junior", + "title": "Das stumme Skript", + "language": "python", + "narrative": "Im Wartungsskript des Kunden soll nach dem Backup eine Meldung erscheinen – der Ausgabebefehl fehlt.", + "dialog": null, + "code": "{{1}}(\"Backup fertig\")", + "successText": "\"Backup fertig\" erscheint im Terminal.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Funktion", "hint": "Pythons Standard-Ausgabefunktion.", "solutions": ["print"] } + ] + }, + { + "difficulty": "junior", + "title": "Der Türcode", + "language": "java", + "narrative": "Die Sicherheitstür soll nur öffnen, wenn der eingegebene PIN exakt stimmt – der Vergleich fehlt.", + "dialog": null, + "code": "if (pin {{1}} 1234) {\n openDoor();\n}", + "successText": "Bei richtigem PIN öffnet die Tür – und nur dann.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "Vergleich auf Gleichheit (nicht die Zuweisung).", "solutions": ["=="] } + ] + }, + + { + "difficulty": "professional", + "title": "Die halbe Schleife", + "language": "java", + "narrative": "Ein Praktikant hat die Schleife halb fertig gelassen: Sowohl die Abbruchbedingung als auch der Zählschritt fehlen.", + "dialog": null, + "code": "for (int i = 0; i {{1}} 10; i{{2}}) {\n process(i);\n}", + "successText": "Die Schleife läuft sauber von 0 bis 9 durch.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Bedingung", "hint": "Solange i kleiner als 10 ist.", "solutions": ["<"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Schritt", "hint": "i um eins erhöhen.", "solutions": ["++"] } + ] + }, + { + "difficulty": "professional", + "title": "Fehler abfangen", + "language": "java", + "narrative": "Ein Dateizugriff lässt das Programm abstürzen. Du sollst den Fehler abfangen, statt alles crashen zu lassen.", + "dialog": null, + "code": "{{1}} {\n readFile(\"daten.txt\");\n} {{2}} (IOException e) {\n log(e);\n}", + "successText": "Der Fehler wird sauber protokolliert – kein Absturz mehr.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Block", "hint": "Der Block, der den riskanten Code umschließt.", "solutions": ["try"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Block", "hint": "Der Block, der die Exception auffängt.", "solutions": ["catch"] } + ] + }, + { + "difficulty": "professional", + "title": "Neuer Eintrag", + "language": "sql", + "narrative": "Ein neuer Nutzer soll angelegt werden, aber das INSERT-Statement ist an zwei Stellen unvollständig.", + "dialog": null, + "code": "{{1}} INTO users (name, age)\n{{2}} ('Alice', 28);", + "successText": "Alice ist jetzt in der Datenbank gespeichert.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Mit welchem Befehl fügt man eine Zeile ein?", "solutions": ["INSERT"] }, + { "position": 2, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Leitet die einzufügenden Werte ein.", "solutions": ["VALUES"] } + ] + }, + { + "difficulty": "professional", + "title": "Durch die Liste", + "language": "java", + "narrative": "Jeder Eintrag der Liste soll ausgegeben werden. In der erweiterten for-Schleife fehlen Trenner und Ausgabe.", + "dialog": null, + "code": "for (String s {{1}} liste) {\n System.out.{{2}}(s);\n}", + "successText": "Alle Einträge der Liste laufen über den Bildschirm.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Trenner", "hint": "Trennt in der for-each-Schleife Variable und Sammlung.", "solutions": [":"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Methode", "hint": "Gibt eine Zeile mit Zeilenumbruch aus.", "solutions": ["println"] } + ] + }, + { + "difficulty": "professional", + "title": "Das Fließband", + "language": "java", + "narrative": "Eine Auto-Klasse braucht ihren Bauplan. Klassen-Schlüsselwort und der Verweis aufs eigene Objekt fehlen.", + "dialog": null, + "code": "public {{1}} Auto {\n String marke;\n Auto(String marke) {\n {{2}}.marke = marke;\n }\n}", + "successText": "Vom Fließband rollt das erste Auto-Objekt.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Leitet die Definition eines Bauplans ein.", "solutions": ["class"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Referenz", "hint": "Verweis auf das aktuelle Objekt.", "solutions": ["this"] } + ] + }, + { + "difficulty": "professional", + "title": "Bestellungen verknüpfen", + "language": "sql", + "narrative": "Nutzer und ihre Bestellungen sollen zusammengeführt werden – die Verknüpfung und ihre Bedingung fehlen.", + "dialog": null, + "code": "SELECT * FROM users u\n{{1}} orders o {{2}} u.id = o.user_id;", + "successText": "Jede Bestellung steht jetzt neben ihrem Nutzer.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Verbindet zwei Tabellen.", "solutions": ["JOIN", "INNER JOIN"] }, + { "position": 2, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Leitet die Verknüpfungsbedingung ein.", "solutions": ["ON"] } + ] + }, + { + "difficulty": "professional", + "title": "Auf Fehlersuche", + "language": "bash", + "narrative": "Im riesigen Logfile sollst du nur die Fehlerzeilen sehen. Verbinde die Ausgabe mit dem Suchwerkzeug.", + "dialog": null, + "code": "cat app.log {{1}} {{2}} ERROR", + "successText": "Nur noch die ERROR-Zeilen rauschen über den Schirm.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Zeichen", "hint": "Leitet die Ausgabe an den nächsten Befehl weiter (Pipe).", "solutions": ["|"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Befehl", "hint": "Filtert Zeilen nach einem Muster.", "solutions": ["grep"] } + ] + }, + { + "difficulty": "professional", + "title": "Ungültiges Alter", + "language": "java", + "narrative": "Ein negatives Alter darf nicht durchrutschen. Bedingung und das Auslösen der Ausnahme fehlen.", + "dialog": null, + "code": "{{1}} (alter < 0) {\n {{2}} new IllegalArgumentException(\"negativ\");\n}", + "successText": "Negative Werte werden jetzt sofort abgewiesen.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Prüft eine Bedingung.", "solutions": ["if"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Wirft eine Exception aktiv aus.", "solutions": ["throw"] } + ] + }, + { + "difficulty": "professional", + "title": "Die Summe im Array", + "language": "java", + "narrative": "Alle Zahlen eines Arrays sollen aufsummiert werden. Die Längenabfrage und die Aufsummierung fehlen.", + "dialog": null, + "code": "int sum = 0;\nfor (int i = 0; i < zahlen.{{1}}; i++) {\n sum {{2}} zahlen[i];\n}", + "successText": "Die Summe steht – ohne ein einziges Element zu verpassen.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Eigenschaft", "hint": "Die Anzahl der Elemente eines Arrays.", "solutions": ["length"] }, + { "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "sum um zahlen[i] erhöhen (Kurzform).", "solutions": ["+="] } + ] + }, + { + "difficulty": "professional", + "title": "Das Login-Formular", + "language": "html", + "narrative": "Das Anmeldeformular schickt nichts ab und zeigt das Passwort im Klartext. Versandart und Feldtyp fehlen.", + "dialog": null, + "code": "
\n \n
", + "successText": "Das Formular sendet sicher per POST, das Passwort bleibt verdeckt.", + "gaps": [ + { "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Legt die HTTP-Methode des Formulars fest.", "solutions": ["method"] }, + { "position": 2, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Bestimmt die Art des Eingabefeldes.", "solutions": ["type"] } + ] + }, + + { + "difficulty": "senior", + "title": "Stille Konsole", + "language": "java", + "narrative": "Vom Logging-Modul ist nur noch eine halbe Zeile übrig. Rekonstruiere den kompletten Ausgabebefehl.", + "dialog": null, + "code": "{{1}}(\"Hallo\");", + "successText": "\"Hallo\" erscheint in der Konsole. Das Logging lebt wieder.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Anweisung", "hint": "Der vollständige Standard-Ausgabebefehl in Java (Objektkette).", "solutions": ["System.out.println"] } + ] + }, + { + "difficulty": "senior", + "title": "Der Einstiegspunkt", + "language": "java", + "narrative": "Eine Schadroutine hat die komplette Signatur der Hauptmethode gelöscht – nur Klammern blieben übrig. Stell die ganze Zeile wieder her.", + "dialog": null, + "code": "public class App {\n {{1}} {\n run();\n }\n}", + "successText": "Die App findet ihren Einstiegspunkt wieder und startet.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Signatur", "hint": "Sichtbarkeit, static, Rückgabetyp void, Name und String-Array-Parameter.", "solutions": ["public static void main(String[] args)", "static public void main(String[] args)", "public static void main(String args[])"] } + ] + }, + { + "difficulty": "senior", + "title": "Geburtstag in der DB", + "language": "sql", + "narrative": "Alice hat Geburtstag und ist jetzt 29. Schreibe die komplette UPDATE-Zeile, die ihr Alter setzt (ohne die WHERE-Bedingung).", + "dialog": null, + "code": "{{1}}\nWHERE name = 'Alice';", + "successText": "Alices Alter ist aktualisiert. Herzlichen Glückwunsch!", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": false, "placeholder": "ganze Zeile", "hint": "UPDATE SET = ", "solutions": ["UPDATE users SET age = 29"] } + ] + }, + { + "difficulty": "senior", + "title": "Der Bauplan des Roboters", + "language": "java", + "narrative": "Die Klassendeklaration des Roboters wurde gelöscht – nur der Rumpf blieb. Schreib die komplette öffentliche Klassenzeile (Klasse: Roboter).", + "dialog": null, + "code": "{{1}} {\n // Roboter-Logik\n}", + "successText": "Der Roboter hat wieder einen Bauplan und kann gebaut werden.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "Sichtbarkeit + Schlüsselwort + Name der Klasse.", "solutions": ["public class Roboter"] } + ] + }, + { + "difficulty": "senior", + "title": "Eingabe von vorn", + "language": "java", + "narrative": "Die komplette Initialisierung der Tastatureingabe wurde gelöscht. Stell die ganze Zeile wieder her (Variable: sc).", + "dialog": null, + "code": "{{1}};", + "successText": "Die Tastatureingabe ist wieder bereit.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "Deklaration + Zuweisung eines neuen Scanners auf System.in.", "solutions": ["Scanner sc = new Scanner(System.in)"] } + ] + }, + { + "difficulty": "senior", + "title": "Jede Zahl zählt", + "language": "java", + "narrative": "Die erweiterte for-Schleife über das Array 'zahlen' fehlt komplett. Rekonstruiere sie (Variable n, Typ int).", + "dialog": null, + "code": "{{1}} {\n summe += n;\n}", + "successText": "Jede Zahl des Arrays fließt in die Summe ein.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "for ( : )", "solutions": ["for (int n : zahlen)", "for(int n : zahlen)"] } + ] + }, + { + "difficulty": "senior", + "title": "Alte Logs entsorgen", + "language": "sql", + "narrative": "Uralte Logeinträge sollen gelöscht werden. Schreibe die komplette DELETE-Zeile (Tabelle: logs), die WHERE-Bedingung steht schon.", + "dialog": null, + "code": "{{1}}\nWHERE created < '2020-01-01';", + "successText": "Die alten Logs sind Geschichte – die Platte atmet auf.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": false, "placeholder": "ganze Zeile", "hint": "DELETE FROM ", "solutions": ["DELETE FROM logs"] } + ] + }, + { + "difficulty": "senior", + "title": "Die Methode zurückbauen", + "language": "java", + "narrative": "Von einer Additions-Methode ist nur der Rumpf übrig. Schreibe die komplette Signatur (public, gibt int zurück, heißt add, nimmt int a und int b).", + "dialog": null, + "code": "{{1}} {\n return a + b;\n}", + "successText": "Die Methode add ist wiederhergestellt und liefert die Summe.", + "gaps": [ + { "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Signatur", "hint": " ()", "solutions": ["public int add(int a, int b)", "public int add(int a,int b)"] } + ] + } + ] +} diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 46cebee..697aaab 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -46,6 +46,7 @@ 👤 Benutzer verwalten 🧩 MindMatch-Decks verwalten 🎯 TechTacular-Fragen verwalten + 📜 Syntax-Saga-Missionen verwalten

Hier entsteht noch Großes... ✨ Bleib dran! diff --git a/src/main/resources/templates/admin/syntaxsaga-mission.html b/src/main/resources/templates/admin/syntaxsaga-mission.html new file mode 100644 index 0000000..df3e6ff --- /dev/null +++ b/src/main/resources/templates/admin/syntaxsaga-mission.html @@ -0,0 +1,177 @@ + + + + + + Syntax-Saga-Mission + + + + + + +

+ +
+ + + +
+
Syntax Saga · Mission
+

Mission

+
+ +
+ + +
+

Mission bearbeiten

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ + +

Lücken

+ +
+
+ Lücke {{1}} + + 100 Pkt · + Groß/Klein + +
+ + +
+
+
    +
  • Lösung
  • +
+

Hinweis

+
+ +

Noch keine Lücken – unten hinzufügen. Ohne Lücken erscheint die Mission nicht im Spiel.

+ + +
+

Lücke hinzufügen

+

Die Position muss zum Marker {{n}} im Code passen. + Trage die gültigen Lösungen ein – eine pro Zeile (mehrere Zeilen = mehrere akzeptierte Antworten, + z. B. 192.168.0.1 und localhost).

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+ +
+ +
+ + + + + diff --git a/src/main/resources/templates/admin/syntaxsaga.html b/src/main/resources/templates/admin/syntaxsaga.html new file mode 100644 index 0000000..d3d0fdf --- /dev/null +++ b/src/main/resources/templates/admin/syntaxsaga.html @@ -0,0 +1,140 @@ + + + + + + Syntax-Saga-Verwaltung – Admin + + + + + + +
+ +
+ + + +
+
Syntax Saga
+

📜 Syntax-Saga-Verwaltung

+
+ +
+ +
+

Neue Mission anlegen

+

Markiere die Lücken im Code mit {{1}}, {{2}} … – die Nummer + ist die Position der Lücke. Die gültigen Lösungen trägst du nach dem Anlegen je Lücke ein.

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+

Mission

+ /slug + junior + aktiv + inaktiv + · 0 Lücken +
+
+ Lücken verwalten +
+ +
+
+ +
+
+
+ +

Noch keine Missionen. Lege oben die erste an.

+ +
+ +
+ + + + + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 173744b..36ef0b8 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -185,7 +185,7 @@ const GAMES = [ ['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'], ['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'], - ['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/comingsoon'], + ['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'], ['Code Escape Room','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'], ['Service Clash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'], ['Bug Hunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'], diff --git a/src/main/resources/templates/syntaxsaga.html b/src/main/resources/templates/syntaxsaga.html new file mode 100644 index 0000000..dc1be51 --- /dev/null +++ b/src/main/resources/templates/syntaxsaga.html @@ -0,0 +1,61 @@ + + + + + + Syntax Saga – Mindflux + + + + + + +
+
+ ⟵ Dashboard +

Syntax Saga

+
+ + Logout +
+
+ +
+
+ Stufe: + + + +
+
+ Punkte 0 + Mission – / – + Zeit 0:00 + Bestwert – +
+
+ +
+
+
+
+ + + + + + +