2 Commits

Author SHA1 Message Date
AliceDarkest 55a09cb093 Doku: STYLEGUIDE.md – Designsystem/Farbtokens aus theme.css
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:09:21 +02:00
AliceDarkest 4599dd12b8 Syntax Saga: neues Spiel (server-autoritativ) + 34 Seed-Missionen
Drittes Mindflux-Spiel: interaktive IT-Geschichten, in denen man den
fehlenden Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine
Story tippt. Freitext-Eingabe, drei wählbare Stufen (Junior/Professional/
Senior), mehrere Lücken je Codeblock und mehrere gültige Antworten je Lücke
(optional case-insensitive). Prüfung und Score ausschließlich serverseitig –
die Lösungen verlassen nie das Backend (Anti-Cheat wie TechTacular/MindMatch).
Fehler-Mechanik: bis zu 3 Versuche mit Hinweis, dann Auflösung; kein Game-Over.

Inhalte DB-gestützt und im Admin pflegbar (/admin/syntaxsaga); 34 Seed-
Missionen (Java/SQL/Bash/Git/HTML/Python) aus syntaxsaga/content.json.

- Entities SyntaxSagaMission/Gap/Solution + Flyway V7__syntax_saga.sql
- SyntaxSagaService (Story ohne Lösung / serverseitige Prüfung / Score-Recompute)
- API /api/syntaxsaga (CSRF-ausgenommen) + Admin-CRUD
- Frontend syntaxsaga.html/js/css (Prefix ss-, theme.css-Tokens)
- Dashboard-Kachel aktiv (vorher "coming soon")

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:13:37 +02:00
29 changed files with 2922 additions and 2 deletions
+35
View File
@@ -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/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
Versionierung [semantisch](https://semver.org/lang/de/). 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 ## [Unreleased] – 2026-06-29
Inhaltlicher und optischer Feinschliff: echte Rechtstexte, vollständig Inhaltlicher und optischer Feinschliff: echte Rechtstexte, vollständig
+173
View File
@@ -0,0 +1,173 @@
# Mindflux – Style Guide
Verbindliche Design-Referenz für **Mindflux**. **Single Source of Truth ist
`src/main/resources/static/css/theme.css`** – diese Datei dokumentiert nur, was dort definiert
ist. Bei Änderungen immer zuerst `theme.css` anpassen und diese Datei nachziehen.
> **Grundregel:** Niemals Hex-/RGBA-Werte hart in Templates oder Spiele-CSS schreiben. **Immer**
> die Tokens (`var(--accent)`, `var(--surface)` …) verwenden, damit Dark/Light und künftige
> Palettenwechsel automatisch greifen.
Theme-Umschaltung: über das Attribut `<html data-theme="dark|light">` (gesetzt/persistiert von
`theme.js`; injiziert auch den mobilen Off-Canvas-Drawer). **Dark ist Default.**
---
## Farbkonzept – „Back to the Future"
- **Primärakzent = Feuer-Amber** (Dark `#f0a81a` / Light `#d65116`) + **Gelb-Glow** (`--accent-2`)
für Verläufe und Leuchten.
- **Zweitakzent = Flux-Blau** (`#2d9cce`) – für **Links** und das Spiel-Feedback **„richtig"**.
- **Warmer Bernstein-Glow** statt Matrix-Grün; dunkler bzw. sehr heller Grund je Theme.
- **Kein Grün** im Erfolgs-Feedback: **richtig = Blau ✓**, **falsch = Rot ✗** (Rot ist ein kühles
Crimson, damit es sich klar vom orangen Akzent abhebt – v. a. im Light-Mode).
---
## Token-Referenz (aus `theme.css`)
| Token | Dark | Light | Verwendung |
|---|---|---|---|
| `--bg` | `#0a0806` | `#fbf6ec` | Seitenhintergrund (unter `--bg-glow`) |
| `--bg-glow` | radial `rgba(240,168,26,.10)` | radial `rgba(214,81,22,.10)` | weicher Lichtschein oben |
| `--panel` | `#13100b` | `#ffffff` | Sidebar, Topbar, Footer |
| `--panel-2` | `#171209` | `#fdf7ea` | Panel-Variante |
| `--surface` | `#120f0a` | `#ffffff` | Karten / Panels / Eingaben (Grundfläche) |
| `--surface-2` | `#1b1610` | `#fbf4e6` | erhöhte Fläche, Hover, Felder |
| `--border` | `#2a2117` | `#ece1cf` | Standard-Rahmen |
| `--line` | `#221b12` | `#f1e8d8` | feine Linien, Scrollbar, Chart-Achsen |
| `--border-accent` | `rgba(240,168,26,.45)` | `rgba(214,81,22,.40)` | akzentuierte Rahmen |
| `--accent` | `#f0a81a` | `#d65116` | **Primärakzent**: Buttons, aktive Nav, Icons, Headline-Akzent |
| `--accent-2` | `#f7dc11` | `#e4801d` | Sekundär-Akzent für Verläufe (Gelb-Glow) |
| `--accent-ink` | `#1c1100` | `#ffffff` | Textfarbe **auf** Akzentflächen |
| `--secondary` | `#2d9cce` | `#1f86b8` | **Flux-Blau**: Links + „richtig" |
| `--secondary-2` | `#5cc4e8` | `#2d9cce` | helleres Blau (Hover/Variante) |
| `--text` | `#ece3d2` | `#33271c` | Fließtext |
| `--muted` | `#8c8170` | `#8a7d6c` | gedämpfter Text, Labels, Captions |
| `--heading` | `#ffffff` | `#241a12` | Überschriften |
| `--success` | `#2d9cce` | `#1f86b8` | **„richtig"** (= Flux-Blau, bewusst kein Grün) |
| `--danger` | `#ff4d6d` | `#be123c` | **„falsch"** / Fehler / Löschen (kühles Crimson) |
| `--glow-s` | `rgba(240,168,26,.55)` | `rgba(214,81,22,.40)` | **starker** Glow (Text-/Drop-Shadow auf Akzent) |
| `--glow-w` | `rgba(240,168,26,.16)` | `rgba(214,81,22,.12)` | **weicher** Ambient-Glow |
| `--g-soft` | `0 0 18px var(--glow-w)` | `0 6px 18px var(--glow-w)` | Standard-Panel-Schatten |
### Semantik kurz
- **Akzent (Amber/Orange):** alles Primäre/Interaktive. Text auf Akzent immer `--accent-ink`.
- **`--accent` + `--accent-2`** zusammen ergeben die typischen Verläufe (`linear-gradient(…,var(--accent),var(--accent-2))`).
- **Blau (`--secondary` / `--success`):** Links und Erfolg.
- **Rot (`--danger`):** Fehler, Löschen-Buttons, „falsch".
---
## Spiele-Feedback (verbindlich)
In allen Spielen (FinalFlux, MindMatch, TechTacular, Syntax Saga) gilt einheitlich:
- **richtig** → `var(--success)` (Blau) + Häkchen `✓`
- **falsch** → `var(--danger)` (Rot) + Kreuz `✗`
- aufgelöst/neutral (Syntax Saga) → `var(--secondary)`
Muster (siehe z. B. `syntaxsaga.css` / `techtacular.css`):
```css
.x-feedback.correct { color: var(--success); }
.x-feedback.correct::before { content: "\2713 "; }
.x-feedback.wrong { color: var(--danger); }
.x-feedback.wrong::before { content: "\2717 "; }
```
---
## Typografie
- **UI/Fließtext:** `"Inter","Segoe UI",system-ui,-apple-system,Roboto,sans-serif`
- **Code/Monospace:** `"JetBrains Mono","Fira Code","Consolas","Courier New",monospace`
- **Überschriften:** `font-weight:800`, Farbe `--heading`.
- **Eyebrow/Labels (`.mf-eyebrow`, Panel-`h4`):** `--accent`, `text-transform:uppercase`,
`letter-spacing:.08–.12em`, kleine Schrift.
---
## Glow, Schatten & Radien
- **Glow stark** `--glow-s`: `text-shadow`/`drop-shadow` auf leuchtenden Akzentelementen.
- **Glow weich** `--glow-w`: dezenter Schein um Panels/Buttons.
- **Panel-Schatten:** `box-shadow: var(--g-soft)`.
- **Radien (Konvention):** Badges/Buttons ~`7–9px`, Inputs/Icon-Buttons `10px`, Nav `11px`,
Panels `13–14px`, Standalone-Karten `16–18px`, Pills/Bars `99px`.
- **Spielkarten** (`.mf-card`) haben eine **abgeschrägte Ecke** via `clip-path` (`--n:18px`) statt
rundem Radius – ein bewusstes „HUD"-Detail.
---
## Layout-Shell & Responsivität
App-Seiten nutzen das Grid `.mf-shell` (definiert in `theme.css`):
```
┌── Sidebar 264px ──┬─────── Topbar (Spalte 2–3) ───────┐
│ ├──── Main 1fr ────┬─ Rightbar 332px ┤
└───────────────────┴──────────────────┴─────────────────┘
```
- `.mf-shell.no-right` → nur `264px | 1fr` (ohne rechte Spalte).
- **Breakpoints:** `≤1180px` rechte Spalte aus, 2-spaltig · `≤820px` Shell stapelt, Sidebar wird
zum **Off-Canvas-Drawer** (Hamburger + Backdrop, via `theme.js`) · `≤560px` Spielkarten-Grid 1-spaltig.
- Spielseiten (MindMatch/TechTacular/Syntax Saga) haben eigene, schlankere Shells, nutzen aber
dieselben Tokens und sind ebenfalls mobil-tauglich.
---
## Komponenten-Cheatsheet (`.mf-*` aus `theme.css`)
| Klasse | Zweck |
|---|---|
| `.mf-btn` | Primär-Button (Akzent). Varianten: `.outline`, `.ghost`, `.danger` |
| `.mf-panel` | Karte/Box; `.accent` = akzentuierter Rahmen + Glow |
| `.mf-card` / `.mf-grid` | HUD-Spielkarte (abgeschrägte Ecke) im 3er-Grid |
| `.mf-input`, `.mf-select`, `textarea.mf-input` | Formularfelder (Fokus = Akzentrahmen + Glow) |
| `.mf-field`, `.mf-label` | Formular-Zeile + Label |
| `.mf-table` | Tabellen (Kopf in Akzent, mobil horizontal scrollbar) |
| `.mf-badge` | kleines Status-Label |
| `.mf-eyebrow` | Kicker/Overline über Headlines |
| `.mf-nav` / `.mf-sidebar` | linke Navigation (aktiv = Akzent-Verlauf) |
| `.mf-bar` + `> i` | Fortschrittsbalken |
| `.mf-center` / `.mf-cardbox` | zentrierte Standalone-Karte (Login/Rechtsseiten) |
| `.mf-container`, `.mf-page-head`, `.mf-back` | einfache Inhaltsseiten + Zurück-Link |
| `.mf-footer` | globale Fußzeile |
| `.mf-theme` / `.mf-theme-float` | Dark/Light-Umschalter (Logik in `theme.js`) |
---
## Pro-Spiel-CSS
Jedes Spiel hat eine eigene CSS-Datei mit eigenem **Klassen-Prefix**, die **ausschließlich
Theme-Tokens** verwendet (keine eigenen Farbwerte):
| Spiel | Datei | Prefix |
|---|---|---|
| MindMatch | `mindmatch.css` | `mm-` |
| TechTacular | `techtacular.css` | `tt-` |
| Syntax Saga | `syntaxsaga.css` | `ss-` |
| FinalFlux | `finalflux.css` | `finalflux-` |
Neues Spiel → neue `<spiel>.css` mit eigenem Prefix anlegen, **nur** `var(--…)`-Tokens nutzen,
Feedback-Konvention (Blau/Rot) einhalten.
---
## Do / Don't
**Do**
- Farben/Schatten/Glow ausschließlich über Tokens.
- Auf Akzentflächen Text in `--accent-ink`.
- Erfolg = Blau, Fehler = Rot (nie Grün/Orange für „richtig/falsch").
- Neue Komponenten an den `.mf-*`-Bausteinen orientieren.
**Don't**
- Keine hart kodierten Hex-/RGBA-Werte in Templates oder Spiele-CSS.
- Nicht die **Legacy-Dateien** als Stil-Referenz nehmen: `achievements.css`, `progress.css`,
`settings.css`, `soziales.css` (ungenutzt) sowie alte Schrift-Deklarationen
(`'Segoe UI'`/`'Poppins'`) entsprechen **nicht** dem aktuellen System. Maßgeblich ist `theme.css`.
---
*Stand: 2026-06-30 · Quelle: `src/main/resources/static/css/theme.css` (BTTF-Palette, Dark/Light).*
@@ -40,7 +40,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf .csrf(csrf -> csrf
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. // 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(); return http.build();
@@ -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<String> 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<String> 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<String> 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);
}
}
@@ -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<String> 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";
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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<GapAnswer> 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<GapAnswer> getAnswers() { return answers; }
public void setAnswers(List<GapAnswer> 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; }
}
}
@@ -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<GapVerdict> results;
public SyntaxSagaCheckResult(boolean allCorrect, List<GapVerdict> results) {
this.allCorrect = allCorrect;
this.results = results;
}
public boolean isAllCorrect() { return allCorrect; }
public List<GapVerdict> 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; }
}
}
@@ -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<FinishGap> 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<FinishGap> getGaps() { return gaps; }
public void setGaps(List<FinishGap> 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; }
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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<Mission> missions;
private final int maxScore;
private final int totalGaps;
public SyntaxSagaStoryView(String difficulty, List<Mission> missions, int maxScore, int totalGaps) {
this.difficulty = difficulty;
this.missions = missions;
this.maxScore = maxScore;
this.totalGaps = totalGaps;
}
public String getDifficulty() { return difficulty; }
public List<Mission> 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<Gap> gaps;
public Mission(Long missionId, String title, String difficulty, String language,
String narrative, String dialog, String codeTemplate,
String successText, List<Gap> 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<Gap> 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; }
}
}
@@ -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<SyntaxSagaSolution> 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<SyntaxSagaSolution> getSolutions() { return solutions; }
public void setSolutions(List<SyntaxSagaSolution> solutions) { this.solutions = solutions; }
}
@@ -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<SyntaxSagaGap> 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<SyntaxSagaGap> getGaps() { return gaps; }
public void setGaps(List<SyntaxSagaGap> gaps) { this.gaps = gaps; }
}
@@ -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; }
}
@@ -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<SyntaxSagaGap, Long> {
long countByMissionId(Long missionId);
}
@@ -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<SyntaxSagaMission, Long> {
/** Aktive Missionen einer Schwierigkeitsstufe in Spielreihenfolge. */
List<SyntaxSagaMission> findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(String difficulty);
/** Alle aktiven Missionen in Spielreihenfolge (stufenübergreifend). */
List<SyntaxSagaMission> findAllByActiveTrueOrderBySortOrderAscIdAsc();
/** Alle Missionen (Admin-Übersicht). */
List<SyntaxSagaMission> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug);
}
@@ -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<SyntaxSagaSolution, Long> {
}
@@ -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<String> 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<SyntaxSagaMission> missions = (diff == null)
? missionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
: missionRepository.findAllByActiveTrueAndDifficultyOrderBySortOrderAscIdAsc(diff);
List<SyntaxSagaStoryView.Mission> views = new ArrayList<>();
int maxScore = 0;
int totalGaps = 0;
for (SyntaxSagaMission mission : missions) {
if (mission.getGaps().isEmpty()) {
continue;
}
List<SyntaxSagaStoryView.Gap> 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<SyntaxSagaCheckResult.GapVerdict> 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<Integer, String> 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<Long, SyntaxSagaMission> 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<SyntaxSagaMissionRow> 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<String> 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;
}
}
@@ -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);
@@ -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; }
}
+434
View File
@@ -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 -> <input>
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 = `<p class="ss-idle">${message}</p>`;
}
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 <code>-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.');
})();
+424
View File
@@ -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<String> 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": "<a {{1}}=\"/home\">Start</a>",
"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": "<form action=\"/login\" {{1}}=\"post\">\n <input {{2}}=\"password\">\n</form>",
"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 <tabelle> SET <spalte> = <wert>", "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 ( <Typ> <Variable> : <Array> )", "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 <tabelle>", "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": "<Sichtbarkeit> <Rückgabetyp> <Name>(<Parameter>)", "solutions": ["public int add(int a, int b)", "public int add(int a,int b)"] }
]
}
]
}
@@ -46,6 +46,7 @@
<a th:href="@{/admin/users}" class="admin-card">👤 Benutzer verwalten</a> <a th:href="@{/admin/users}" class="admin-card">👤 Benutzer verwalten</a>
<a th:href="@{/admin/mindmatch}" class="admin-card">🧩 MindMatch-Decks verwalten</a> <a th:href="@{/admin/mindmatch}" class="admin-card">🧩 MindMatch-Decks verwalten</a>
<a th:href="@{/admin/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a> <a th:href="@{/admin/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a>
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
</div> </div>
<p style="margin-top:1.1rem; color:var(--muted);"> <p style="margin-top:1.1rem; color:var(--muted);">
Hier entsteht noch Großes... ✨ Bleib dran! Hier entsteht noch Großes... ✨ Bleib dran!
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${mission.title} + ' – Syntax Saga'">Syntax-Saga-Mission</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.ssm { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
.ssm .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.ssm .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
.ssm .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
.ssm textarea.mf-input { font-family: monospace; resize: vertical; min-height: 4.5rem; }
.ssm textarea.prose { font-family: inherit; }
.ssm .gap {
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--secondary);
border-radius: 12px; padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
box-shadow: var(--g-soft);
}
.ssm .gap-head { display: flex; align-items: center; gap: 0.6rem; justify-content: space-between; flex-wrap: wrap; }
.ssm .pos { background: var(--glow-w); color: var(--accent); font-weight: 800; border: 1px solid var(--border-accent); border-radius: 7px; padding: 0.14rem 0.55rem; font-size: 0.85rem; }
.ssm .meta { color: var(--muted); font-size: 0.82rem; }
.ssm ul.sols { list-style: none; padding: 0; margin: 0.4rem 0 0; }
.ssm ul.sols li { color: var(--secondary); font-family: monospace; padding: 0.12rem 0; }
.ssm ul.sols li::before { content: "✓ "; }
.ssm .gap-hint { color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
.ssm .addbox { margin-top: 1.6rem; }
.ssm .hint { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.8rem; line-height: 1.5; }
.ssm .hint code { color: var(--accent); }
.ssm .empty { color: var(--danger); }
.ssm .check-row { display: flex; align-items: center; gap: 0.45rem; color: var(--accent); font-size: 0.85rem; }
@media (max-width: 600px) { .ssm .grid2 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-container" style="max-width:1040px">
<div class="mf-page-head">
<a th:href="@{/admin/syntaxsaga}" class="mf-back">⟵ Zurück zur Missionsliste</a>
</div>
<header style="margin-bottom:1.4rem">
<div class="mf-eyebrow">Syntax Saga · Mission</div>
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem" th:text="'📜 ' + ${mission.title}">Mission</h1>
</header>
<main class="ssm">
<!-- Mission bearbeiten -->
<div class="mf-panel">
<h4>Mission bearbeiten</h4>
<form th:action="@{'/admin/syntaxsaga/missions/' + ${mission.id} + '/update'}" method="post">
<div class="grid2">
<div class="field">
<label>Titel</label>
<input class="mf-input" type="text" name="title" th:value="${mission.title}" required>
</div>
<div class="field">
<label>Schwierigkeit</label>
<select class="mf-select" name="difficulty">
<option value="junior" th:selected="${mission.difficulty == 'junior'}">Junior</option>
<option value="professional" th:selected="${mission.difficulty == 'professional'}">Professional</option>
<option value="senior" th:selected="${mission.difficulty == 'senior'}">Senior</option>
</select>
</div>
</div>
<div class="field">
<label>Erzähltext / Szene</label>
<textarea class="mf-input prose" name="narrative" th:text="${mission.narrative}"></textarea>
</div>
<div class="grid2">
<div class="field">
<label>Dialog (optional)</label>
<input class="mf-input" type="text" name="dialog" th:value="${mission.dialog}">
</div>
<div class="field">
<label>Sprache (optional)</label>
<input class="mf-input" type="text" name="language" th:value="${mission.language}">
</div>
</div>
<div class="field">
<label>Code mit Lücken (<code>{{1}}</code>, <code>{{2}}</code> …)</label>
<textarea class="mf-input" name="codeTemplate" rows="6" th:text="${mission.codeTemplate}" required></textarea>
</div>
<div class="field">
<label>Erfolgstext (optional)</label>
<textarea class="mf-input prose" name="successText" th:text="${mission.successText}"></textarea>
</div>
<button type="submit" class="mf-btn">💾 Mission speichern</button>
</form>
</div>
<!-- Vorhandene Lücken -->
<h4 style="color:var(--accent); margin:1.4rem 0 0.7rem; text-transform:uppercase; letter-spacing:.08em; font-size:.8rem">Lücken</h4>
<div class="gap" th:each="g : ${mission.gaps}">
<div class="gap-head">
<span class="pos" th:text="'Lücke {{' + ${g.position} + '}}'">Lücke {{1}}</span>
<span class="meta">
<span th:text="${g.points} + ' Pkt'">100 Pkt</span> ·
<span th:text="${g.caseSensitive} ? 'Groß/Klein beachten' : 'Groß/Klein egal'">Groß/Klein</span>
</span>
<form th:action="@{'/admin/syntaxsaga/gaps/' + ${g.id} + '/delete'}" method="post"
onsubmit="return confirm('Lücke löschen?');">
<input type="hidden" name="missionId" th:value="${mission.id}">
<button type="submit" class="mf-btn danger">✕ Lücke löschen</button>
</form>
</div>
<ul class="sols">
<li th:each="s : ${g.solutions}" th:text="${s.text}">Lösung</li>
</ul>
<p class="gap-hint" th:if="${g.hint}" th:text="'Hinweis: ' + ${g.hint}">Hinweis</p>
</div>
<p th:if="${mission.gaps.isEmpty()}" class="empty">Noch keine Lücken – unten hinzufügen. Ohne Lücken erscheint die Mission nicht im Spiel.</p>
<!-- Lücke hinzufügen -->
<div class="mf-panel accent addbox">
<h4>Lücke hinzufügen</h4>
<p class="hint">Die <strong>Position</strong> muss zum Marker <code>{{n}}</code> im Code passen.
Trage die gültigen <strong>Lösungen</strong> ein – eine pro Zeile (mehrere Zeilen = mehrere akzeptierte Antworten,
z. B. <code>192.168.0.1</code> und <code>localhost</code>).</p>
<form th:action="@{'/admin/syntaxsaga/missions/' + ${mission.id} + '/gaps'}" method="post">
<div class="grid2">
<div class="field">
<label>Position ({{n}})</label>
<input class="mf-input" type="number" name="position" min="1" placeholder="1" required>
</div>
<div class="field">
<label>Punkte</label>
<input class="mf-input" type="number" name="points" min="10" max="1000" value="100">
</div>
</div>
<div class="grid2">
<div class="field">
<label>Platzhalter (optional)</label>
<input class="mf-input" type="text" name="placeholder" placeholder="z. B. Methodenname">
</div>
<div class="field">
<label>Hinweis (optional)</label>
<input class="mf-input" type="text" name="hint" placeholder="kurzer Tipp nach falschem Versuch">
</div>
</div>
<div class="field">
<label>Gültige Lösungen (eine pro Zeile)</label>
<textarea class="mf-input" name="solutions" rows="3" placeholder="main" required></textarea>
</div>
<label class="check-row" style="margin-bottom:.7rem">
<input type="checkbox" name="caseSensitive" value="true" checked> Groß-/Kleinschreibung beachten (für Java-Bezeichner; bei SQL-Keywords abwählen)
</label>
<button type="submit" class="mf-btn">➕ Lücke speichern</button>
</form>
</div>
</main>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syntax-Saga-Verwaltung – Admin</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.ssadmin { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
.ssadmin .new-mission { margin-bottom: 1.6rem; }
.ssadmin .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.ssadmin .mission {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem;
justify-content: space-between;
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
border-radius: 12px; padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
box-shadow: var(--g-soft);
}
.ssadmin .mission.inactive { border-left-color: var(--danger); opacity: 0.7; }
.ssadmin .mission h2 { color: var(--heading); margin: 0; font-size: 1.05rem; }
.ssadmin .slug { color: var(--accent); font-size: 0.78rem; font-family: monospace; }
.ssadmin .count { color: var(--muted); font-size: 0.85rem; }
.ssadmin .badge { font-size: 0.66rem; text-transform: uppercase; padding: 0.14rem 0.45rem; border-radius: 7px; border: 1px solid var(--border-accent); color: var(--secondary); border-color: var(--secondary); }
.ssadmin .badge.on { background: var(--glow-w); color: var(--accent); border-color: var(--border-accent); }
.ssadmin .badge.off { background: rgba(255,95,107,.12); color: var(--danger); border-color: var(--danger); }
.ssadmin .actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.ssadmin .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
.ssadmin .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
.ssadmin textarea.mf-input { min-height: 4.5rem; font-family: monospace; resize: vertical; }
.ssadmin .hint { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.8rem; line-height: 1.5; }
.ssadmin .hint code { color: var(--accent); }
.ssadmin .empty { color: var(--muted); }
@media (max-width: 600px) { .ssadmin .grid2 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-container" style="max-width:1040px">
<div class="mf-page-head">
<a th:href="@{/admin}" class="mf-back">⟵ Zurück zum Admin-Dashboard</a>
</div>
<header style="margin-bottom:1.4rem">
<div class="mf-eyebrow">Syntax Saga</div>
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem">📜 Syntax-Saga-Verwaltung</h1>
</header>
<main class="ssadmin">
<div class="mf-panel accent new-mission">
<h4>Neue Mission anlegen</h4>
<p class="hint">Markiere die Lücken im Code mit <code>{{1}}</code>, <code>{{2}}</code> … – die Nummer
ist die Position der Lücke. Die gültigen Lösungen trägst du nach dem Anlegen je Lücke ein.</p>
<form th:action="@{/admin/syntaxsaga/missions}" method="post">
<div class="grid2">
<div class="field">
<label>Titel</label>
<input class="mf-input" type="text" name="title" placeholder="z. B. Hello World" required>
</div>
<div class="field">
<label>Schwierigkeit</label>
<select class="mf-select" name="difficulty">
<option value="junior">Junior (eine Lücke)</option>
<option value="professional">Professional (mehrere Lücken)</option>
<option value="senior">Senior (ganze Zeile)</option>
</select>
</div>
</div>
<div class="field">
<label>Erzähltext / Szene</label>
<textarea class="mf-input" name="narrative" placeholder="Worum geht es in dieser Mission?" style="font-family:inherit"></textarea>
</div>
<div class="grid2">
<div class="field">
<label>Dialog (optional)</label>
<input class="mf-input" type="text" name="dialog" placeholder="z. B. Bitte… repariere mich…">
</div>
<div class="field">
<label>Sprache (optional)</label>
<input class="mf-input" type="text" name="language" placeholder="java / sql / bash">
</div>
</div>
<div class="field">
<label>Code mit Lücken</label>
<textarea class="mf-input" name="codeTemplate" rows="5" placeholder="public class Main {&#10; public static void {{1}}(String[] args) { ... }&#10;}" required></textarea>
</div>
<div class="field">
<label>Erfolgstext (optional)</label>
<textarea class="mf-input" name="successText" placeholder="Was passiert, wenn alles gelöst ist?" style="font-family:inherit"></textarea>
</div>
<button type="submit" class="mf-btn">➕ Mission anlegen &amp; Lücken hinzufügen</button>
</form>
</div>
<div class="mission" th:each="m : ${missions}" th:classappend="${m.active} ? '' : 'inactive'">
<div>
<h2 th:text="${m.title}">Mission</h2>
<span class="slug" th:text="'/' + ${m.slug}">/slug</span>
<span class="badge" th:text="${m.difficulty}">junior</span>
<span class="badge on" th:if="${m.active}">aktiv</span>
<span class="badge off" th:unless="${m.active}">inaktiv</span>
<span class="count" th:text="'· ' + ${m.gapCount} + ' Lücken'">· 0 Lücken</span>
</div>
<div class="actions">
<a class="mf-btn" th:href="@{'/admin/syntaxsaga/missions/' + ${m.id}}">Lücken verwalten</a>
<form th:action="@{'/admin/syntaxsaga/missions/' + ${m.id} + '/toggle'}" method="post">
<button type="submit" class="mf-btn outline" th:text="${m.active} ? 'Deaktivieren' : 'Aktivieren'">Toggle</button>
</form>
<form th:action="@{'/admin/syntaxsaga/missions/' + ${m.id} + '/delete'}" method="post"
onsubmit="return confirm('Mission inkl. aller Lücken löschen?');">
<button type="submit" class="mf-btn danger">Löschen</button>
</form>
</div>
</div>
<p th:if="${missions.isEmpty()}" class="empty">Noch keine Missionen. Lege oben die erste an.</p>
</main>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
+1 -1
View File
@@ -185,7 +185,7 @@
const GAMES = [ const GAMES = [
['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'], ['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'], ['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'], ['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'], ['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'], ['Bug Hunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syntax Saga – Mindflux</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<link rel="stylesheet" href="/css/syntaxsaga.css?v=1">
</head>
<body class="ss-body">
<div class="ss-shell">
<header class="ss-header">
<a href="/dashboard" class="ss-back">⟵ Dashboard</a>
<h1 class="ss-title">Syntax Saga</h1>
<div class="ss-header-tools">
<button type="button" class="mf-theme"></button>
<a href="/logout" class="ss-logout">Logout</a>
</div>
</header>
<div class="ss-bar">
<div class="ss-bar-group" aria-label="Schwierigkeit wählen">
<span class="ss-bar-label">Stufe:</span>
<button type="button" class="ss-diff" data-diff="junior">Junior</button>
<button type="button" class="ss-diff" data-diff="professional">Professional</button>
<button type="button" class="ss-diff" data-diff="senior">Senior</button>
</div>
<div class="ss-bar-group ss-hud" aria-live="polite">
<span class="ss-stat">Punkte <b id="ss-score">0</b></span>
<span class="ss-stat">Mission <b id="ss-mission">– / –</b></span>
<span class="ss-stat">Zeit <b id="ss-time">0:00</b></span>
<span class="ss-stat">Bestwert <b id="ss-best">–</b></span>
</div>
</div>
<main class="ss-stage">
<section id="ss-story" class="ss-story" aria-label="Syntax-Saga-Geschichte"></section>
</main>
</div>
<!-- Ergebnis-Overlay -->
<div id="ss-result" class="ss-overlay" hidden>
<div class="ss-result-card">
<h2>Saga beendet 🎉</h2>
<p id="ss-result-stars" class="ss-result-stars">★★★</p>
<p class="ss-result-score">Score: <strong id="ss-result-score">0</strong> / <span id="ss-result-max">0</span></p>
<p id="ss-result-detail" class="ss-result-detail"></p>
<p id="ss-result-high" class="ss-result-high" hidden>🏆 Neuer Bestwert!</p>
<div class="ss-result-actions">
<button type="button" id="ss-again" class="ss-btn">Nochmal</button>
<button type="button" id="ss-choose" class="ss-btn ss-btn-secondary">Andere Stufe</button>
<a href="/dashboard" class="ss-btn ss-btn-secondary">Dashboard</a>
</div>
</div>
</div>
<script src="/js/syntaxsaga.js?v=1" defer></script>
</body>
</html>