From 55a09cb093584f93149733f703743319e91c824f Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Tue, 30 Jun 2026 13:09:21 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Doku:=20STYLEGUIDE.md=20=E2=80=93=20Designs?= =?UTF-8?q?ystem/Farbtokens=20aus=20theme.css?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- STYLEGUIDE.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 STYLEGUIDE.md diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md new file mode 100644 index 0000000..d62b003 --- /dev/null +++ b/STYLEGUIDE.md @@ -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 `` (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 `.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).* From b88962da6ffd02f2ea721d7fe92a132c19cd36c6 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Tue, 30 Jun 2026 16:32:03 +0200 Subject: [PATCH 2/2] Dev Lab: server-autoritatives Entwicklerlabor mit 6 Mechaniken + 189 Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev Lab bündelt mehrere Disziplinen (SQL, Java/Debugging, Git, HTML, CSS, Regex, PHP, REST) in 11 Spielen mit zusammen 189 Aufgaben. Im Gegensatz zur ersten, rein clientseitigen Skizze jetzt server-autoritativ, DB-gestützt und im Admin pflegbar – mit echter Mechanik-Vielfalt statt überall Lückentext: cloze (Lücke tippen), choice (Multiple Choice), order (Bausteine zusammensetzen), spot (Bug anklicken), match (Paare zuordnen), regex (Muster schreiben mit Live-Treffer-Vorschau). Prüfung und Score ausschließlich serverseitig – die Lösungen verlassen nie das Backend; Eingabe-Platzhalter sind neutrale Labels (Lösungs-Leak behoben). Fehler-Mechanik mit Versuchen + Hinweis + Auflösung, kein Game-Over; Score über die generische game_scores-Tabelle (Kennung devlab, deck=Spiel-Slug). - Entities DevLabGame/DevLabTask (JSON-payload) + Flyway V8__devlab.sql - DevLabService mit sechs Mechanik-Checkern; API /api/devlab/** (CSRF-ausgenommen, devlab in KNOWN_GAMES) - Admin /admin/devlab (Spiele + Aufgaben pflegen); polymorphe Frontend-Engine devlabgame.js + DB-getriebener Hub devlab.js - Inhalte aus devlab/content.json (idempotenter Seeder); ersetzt die erste clientseitige Dev-Lab-Skizze (devlab.js/devlabgame.js/-game.html/-game.css neu) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 37 + .../example/mindflux/config/DevLabSeeder.java | 128 + .../mindflux/config/SecurityConfig.java | 2 +- .../controller/AdminDevLabController.java | 82 + .../controller/DevLabApiController.java | 46 + .../mindflux/controller/DevLabController.java | 21 + .../controller/GameScoreController.java | 2 +- .../mindflux/dto/DevLabCheckRequest.java | 20 + .../mindflux/dto/DevLabFinishRequest.java | 43 + .../example/mindflux/dto/DevLabGameRow.java | 31 + .../example/mindflux/dto/DevLabResult.java | 35 + .../example/mindflux/model/DevLabGame.java | 88 + .../example/mindflux/model/DevLabTask.java | 69 + .../repository/DevLabGameRepository.java | 18 + .../repository/DevLabTaskRepository.java | 9 + .../mindflux/service/DevLabService.java | 555 +++ .../resources/db/migration/V8__devlab.sql | 26 + src/main/resources/devlab/content.json | 3959 +++++++++++++++++ src/main/resources/static/css/devlab.css | 387 ++ src/main/resources/static/css/devlabgame.css | 119 + src/main/resources/static/js/devlab.js | 76 + src/main/resources/static/js/devlabgame.js | 377 ++ .../resources/templates/admin/dashboard.html | 1 + .../templates/admin/devlab-game.html | 108 + .../resources/templates/admin/devlab.html | 96 + src/main/resources/templates/dashboard.html | 2 + src/main/resources/templates/devlab-game.html | 85 + src/main/resources/templates/devlab.html | 122 + 28 files changed, 6542 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/mindflux/config/DevLabSeeder.java create mode 100644 src/main/java/com/example/mindflux/controller/AdminDevLabController.java create mode 100644 src/main/java/com/example/mindflux/controller/DevLabApiController.java create mode 100644 src/main/java/com/example/mindflux/controller/DevLabController.java create mode 100644 src/main/java/com/example/mindflux/dto/DevLabCheckRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/DevLabFinishRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/DevLabGameRow.java create mode 100644 src/main/java/com/example/mindflux/dto/DevLabResult.java create mode 100644 src/main/java/com/example/mindflux/model/DevLabGame.java create mode 100644 src/main/java/com/example/mindflux/model/DevLabTask.java create mode 100644 src/main/java/com/example/mindflux/repository/DevLabGameRepository.java create mode 100644 src/main/java/com/example/mindflux/repository/DevLabTaskRepository.java create mode 100644 src/main/java/com/example/mindflux/service/DevLabService.java create mode 100644 src/main/resources/db/migration/V8__devlab.sql create mode 100644 src/main/resources/devlab/content.json create mode 100644 src/main/resources/static/css/devlab.css create mode 100644 src/main/resources/static/css/devlabgame.css create mode 100644 src/main/resources/static/js/devlab.js create mode 100644 src/main/resources/static/js/devlabgame.js create mode 100644 src/main/resources/templates/admin/devlab-game.html create mode 100644 src/main/resources/templates/admin/devlab.html create mode 100644 src/main/resources/templates/devlab-game.html create mode 100644 src/main/resources/templates/devlab.html diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f982e..62f5948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ Alle nennenswerten Änderungen an **Mindflux** werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Versionierung [semantisch](https://semver.org/lang/de/). +## [Unreleased] – 2026-06-30 · Dev Lab + +Neues Spiel **Dev Lab**: ein Entwicklerlabor, das mehrere kleine Disziplinen bündelt +(SQL, Java/Debugging, Git, HTML, CSS, Regex, PHP, REST). Im Gegensatz zur ersten, +rein clientseitigen Skizze ist Dev Lab jetzt **server-autoritativ, DB-gestützt und im +Admin pflegbar** – mit **echter Mechanik-Vielfalt** statt überall demselben Lückentext. + +### Hinzugefügt +- **Spiel: Dev Lab (`/devlab`)** mit Hub (drei Trainingspfade: Syntax · Debugging · Praxis) + und **11 Disziplinen** (`query-builder`, `sql-detective`, `debug-hunt`, `git-mission`, + `html-architect`, `css-designer`, `css-cascade`, `flexbox-factory`, `regex-master`, + `php-request`, `api-explorer`), zusammen **189 Aufgaben** (16–21 je Spiel). +- **Sechs unterschiedliche Spielmechaniken** – passend zum Thema gemischt: + **cloze** (Code-Lücke tippen), **choice** (Multiple-Choice-Chips), **order** + (Bausteine klick-basiert zusammensetzen/sortieren), **spot** (den fehlerhaften Token + anklicken), **match** (Paare zuordnen) und **regex** (Muster schreiben mit + **Live-Treffer-Vorschau** gegen Beispiel-Strings). +- **Server-autoritativ & Anti-Cheat:** Aus dem JSON-Payload jeder Aufgabe baut der Server + eine bereinigte Sicht **ohne Lösung**; geprüft und gewertet wird ausschließlich serverseitig + (Text-/Reihenfolge-/Verhaltensvergleich). `correctIndex`/`bugIndex`/Lösungen/Pattern verlassen + nie das Backend. Score über die generische `game_scores`-Tabelle (Kennung `devlab`, `deck`=Spiel-Slug). +- **Fehler-Mechanik** wie bei Syntax Saga: bis zu drei Versuche mit Hinweis, dann „Auflösung", + kein Game-Over; Punkte nach Versuch, Sterne 0–3. +- **Admin-Verwaltung (`/admin/devlab`):** Spiele und Aufgaben (Typ + JSON-Payload) anlegen, + bearbeiten, löschen, aktiv/inaktiv schalten. Inhalte aus `devlab/content.json` (idempotenter Seeder). + +### Behoben / geändert (ggü. der ersten Dev-Lab-Skizze) +- **Platzhalter-Leak entfernt:** Die Eingabefelder zeigten vorher die Lösung als Platzhalter – + jetzt sind es neutrale Kategorie-Labels („Keyword", „Operator", „Tag" …). Geprüft wird serverseitig. +- **Echte Mechanik-Abwechslung** statt 11×Lückentext; **deutlich mehr Aufgaben** je Disziplin. + +### Technik +- Neue Entities `DevLabGame`/`DevLabTask` (JSON-`payload`) + Flyway `V8__devlab.sql`; + `DevLabService` mit sechs Mechanik-Checkern; JSON-API `/api/devlab/**` + (`catalog`/`session`/`check`/`finish`, CSRF-ausgenommen, `devlab` in `KNOWN_GAMES`). + Frontend: polymorphe Engine `devlabgame.js`, DB-getriebener Hub `devlab.js`, `devlabgame.css` (Prefix `dlg-`). + ## [Unreleased] – 2026-06-30 Neues Spiel **Syntax Saga**: interaktive IT-Geschichten, in denen man den fehlenden diff --git a/src/main/java/com/example/mindflux/config/DevLabSeeder.java b/src/main/java/com/example/mindflux/config/DevLabSeeder.java new file mode 100644 index 0000000..ac5956e --- /dev/null +++ b/src/main/java/com/example/mindflux/config/DevLabSeeder.java @@ -0,0 +1,128 @@ +package com.example.mindflux.config; + +import com.example.mindflux.model.DevLabGame; +import com.example.mindflux.model.DevLabTask; +import com.example.mindflux.repository.DevLabGameRepository; +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 das Dev Lab einmalig (wenn leer) aus devlab/content.json. Jede Aufgabe wird mit + * ihrem Typ + vollständigem JSON-Payload gespeichert; der Service liest daraus. + */ +@Component +public class DevLabSeeder implements CommandLineRunner { + + private static final int MAX_PAYLOAD = 8000; + + private final DevLabGameRepository gameRepository; + private final ObjectMapper objectMapper; + + public DevLabSeeder(DevLabGameRepository gameRepository, ObjectMapper objectMapper) { + this.gameRepository = gameRepository; + this.objectMapper = objectMapper; + } + + @Override + @Transactional + public void run(String... args) { + if (gameRepository.count() > 0) { + return; + } + try { + JsonNode root = objectMapper.readTree(new ClassPathResource("devlab/content.json").getInputStream()); + JsonNode games = root.get("games"); + if (games == null || !games.isArray()) { + System.out.println("⚠️ Dev-Lab-Seed: content.json ohne 'games'."); + return; + } + + Set used = new HashSet<>(); + int order = 1; + int gameCount = 0; + int taskCount = 0; + int skipped = 0; + + for (JsonNode gNode : games) { + String title = gNode.path("title").asText(""); + JsonNode tasks = gNode.get("tasks"); + if (title.isBlank() || tasks == null || !tasks.isArray()) { + continue; + } + String base = gNode.hasNonNull("slug") ? gNode.get("slug").asText() : title; + DevLabGame game = new DevLabGame( + uniqueSlug(base, used), + trim(title, 200), + trim(textOrNull(gNode, "language"), 50), + trim(textOrNull(gNode, "path"), 50), + trim(textOrNull(gNode, "description"), 1000), + order, true); + + int sort = 0; + for (JsonNode t : tasks) { + String type = t.path("type").asText(""); + String prompt = t.path("prompt").asText(""); + if (type.isBlank() || prompt.isBlank()) { + continue; + } + String payload = t.toString(); + if (payload.length() > MAX_PAYLOAD) { + skipped++; + continue; + } + game.addTask(new DevLabTask(type, trim(prompt, 300), t.path("points").asInt(100), sort++, payload)); + taskCount++; + } + + if (!game.getTasks().isEmpty()) { + gameRepository.save(game); + order++; + gameCount++; + } + } + System.out.println("✅ Dev Lab: " + gameCount + " Spiele / " + taskCount + " Aufgaben geladen" + + (skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "") + "."); + } catch (Exception e) { + System.out.println("⚠️ Dev-Lab-Seed übersprungen: " + e.getMessage()); + } + } + + private String uniqueSlug(String base, Set used) { + String slug = base.toLowerCase() + .replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { + slug = "game"; + } + String candidate = slug; + int counter = 2; + while (used.contains(candidate) || gameRepository.existsBySlug(candidate)) { + candidate = slug + "-" + counter++; + } + used.add(candidate); + return candidate; + } + + 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 trim(String value, int max) { + if (value == null) { + return null; + } + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index d620062..9a1b075 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -40,7 +40,7 @@ public class SecurityConfig { .csrf(csrf -> csrf // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. - .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/controller/AdminDevLabController.java b/src/main/java/com/example/mindflux/controller/AdminDevLabController.java new file mode 100644 index 0000000..83cf83e --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/AdminDevLabController.java @@ -0,0 +1,82 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.model.DevLabGame; +import com.example.mindflux.service.DevLabService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +/** Admin-Verwaltung der Dev-Lab-Spiele und -Aufgaben (ROLE_ADMIN via /admin/**). */ +@Controller +@RequestMapping("/admin/devlab") +public class AdminDevLabController { + + private final DevLabService devLabService; + + public AdminDevLabController(DevLabService devLabService) { + this.devLabService = devLabService; + } + + @GetMapping("") + public String manage(Model model) { + model.addAttribute("games", devLabService.gameRows()); + return "admin/devlab"; + } + + @GetMapping("/games/{gameId}") + public String game(@PathVariable Long gameId, Model model) { + DevLabGame game = devLabService.gameWithTasks(gameId); + if (game == null) { + return "redirect:/admin/devlab"; + } + model.addAttribute("game", game); + return "admin/devlab-game"; + } + + @PostMapping("/games") + public String createGame(@RequestParam String title, + @RequestParam(required = false) String language, + @RequestParam(required = false) String path, + @RequestParam(required = false) String description) { + Long id = devLabService.createGame(title, language, path, description); + return id == null ? "redirect:/admin/devlab" : "redirect:/admin/devlab/games/" + id; + } + + @PostMapping("/games/{gameId}/tasks") + public String addTask(@PathVariable Long gameId, + @RequestParam String type, + @RequestParam String prompt, + @RequestParam(defaultValue = "100") int points, + @RequestParam String payload) { + devLabService.addTask(gameId, type, prompt, points, payload); + return "redirect:/admin/devlab/games/" + gameId; + } + + @PostMapping("/tasks/{taskId}/update") + public String updateTask(@PathVariable Long taskId, + @RequestParam Long gameId, + @RequestParam String prompt, + @RequestParam(defaultValue = "100") int points, + @RequestParam String payload) { + devLabService.updateTask(taskId, prompt, points, payload); + return "redirect:/admin/devlab/games/" + gameId; + } + + @PostMapping("/tasks/{taskId}/delete") + public String deleteTask(@PathVariable Long taskId, @RequestParam Long gameId) { + devLabService.deleteTask(taskId); + return "redirect:/admin/devlab/games/" + gameId; + } + + @PostMapping("/games/{gameId}/delete") + public String deleteGame(@PathVariable Long gameId) { + devLabService.deleteGame(gameId); + return "redirect:/admin/devlab"; + } + + @PostMapping("/games/{gameId}/toggle") + public String toggleGame(@PathVariable Long gameId) { + devLabService.toggleGame(gameId); + return "redirect:/admin/devlab"; + } +} diff --git a/src/main/java/com/example/mindflux/controller/DevLabApiController.java b/src/main/java/com/example/mindflux/controller/DevLabApiController.java new file mode 100644 index 0000000..1671a52 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/DevLabApiController.java @@ -0,0 +1,46 @@ +package com.example.mindflux.controller; + +import com.example.mindflux.dto.DevLabCheckRequest; +import com.example.mindflux.dto.DevLabFinishRequest; +import com.example.mindflux.dto.DevLabResult; +import com.example.mindflux.service.DevLabService; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +/** JSON-API fürs Dev Lab. Server-autoritativ: Lösungen werden nie ausgeliefert. */ +@RestController +@RequestMapping("/api/devlab") +public class DevLabApiController { + + private final DevLabService devLabService; + + public DevLabApiController(DevLabService devLabService) { + this.devLabService = devLabService; + } + + /** Spiele-Katalog für den Hub (nach Pfad gruppierbar). */ + @GetMapping("/catalog") + public JsonNode catalog() { + return devLabService.buildCatalog(); + } + + /** Eine Spielrunde: Aufgaben einer Disziplin – ohne Lösungen. */ + @GetMapping("/games/{slug}/session") + public JsonNode session(@PathVariable String slug) { + return devLabService.buildSession(slug); + } + + /** Eine Aufgabe serverseitig prüfen; mit {@code reveal=true} auch die Auflösung. */ + @PostMapping("/check") + public JsonNode check(@RequestBody DevLabCheckRequest request) { + return devLabService.check(request.getTaskId(), request.isReveal(), request.getSubmission()); + } + + /** Durchlauf abschließen: Score wird serverseitig neu berechnet und gespeichert. */ + @PostMapping("/finish") + public DevLabResult finish(@RequestBody DevLabFinishRequest request, Principal principal) { + return devLabService.finish(principal.getName(), request); + } +} diff --git a/src/main/java/com/example/mindflux/controller/DevLabController.java b/src/main/java/com/example/mindflux/controller/DevLabController.java new file mode 100644 index 0000000..17fdda0 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/DevLabController.java @@ -0,0 +1,21 @@ +package com.example.mindflux.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Controller +public class DevLabController { + + @GetMapping("/devlab") + public String showDevLab() { + return "devlab"; + } + + @GetMapping("/devlab/games/{slug}") + public String showDevLabGame(@PathVariable String slug, Model model) { + model.addAttribute("gameSlug", slug); + return "devlab-game"; + } +} diff --git a/src/main/java/com/example/mindflux/controller/GameScoreController.java b/src/main/java/com/example/mindflux/controller/GameScoreController.java index 4d44b65..c236365 100644 --- a/src/main/java/com/example/mindflux/controller/GameScoreController.java +++ b/src/main/java/com/example/mindflux/controller/GameScoreController.java @@ -21,7 +21,7 @@ public class GameScoreController { /** Bekannte Spiele (entsprechen den Dashboard-Routen). */ private static final Set KNOWN_GAMES = Set.of( - "mindmatch", "techtacular", "syntax-saga", "escape-room", + "mindmatch", "techtacular", "syntax-saga", "devlab", "escape-room", "service-clash", "bug-hunter", "netzwerker"); private final GameScoreService gameScoreService; diff --git a/src/main/java/com/example/mindflux/dto/DevLabCheckRequest.java b/src/main/java/com/example/mindflux/dto/DevLabCheckRequest.java new file mode 100644 index 0000000..59dc33e --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/DevLabCheckRequest.java @@ -0,0 +1,20 @@ +package com.example.mindflux.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +/** Prüft eine Dev-Lab-Aufgabe. {@code submission} ist mechanikspezifisch (JSON). */ +public class DevLabCheckRequest { + + private Long taskId; + private boolean reveal; + private JsonNode submission; + + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + + public boolean isReveal() { return reveal; } + public void setReveal(boolean reveal) { this.reveal = reveal; } + + public JsonNode getSubmission() { return submission; } + public void setSubmission(JsonNode submission) { this.submission = submission; } +} diff --git a/src/main/java/com/example/mindflux/dto/DevLabFinishRequest.java b/src/main/java/com/example/mindflux/dto/DevLabFinishRequest.java new file mode 100644 index 0000000..1233efe --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/DevLabFinishRequest.java @@ -0,0 +1,43 @@ +package com.example.mindflux.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +/** + * Abschluss eines Dev-Lab-Durchlaufs: pro Aufgabe die letzte Eingabe + Versuche/Auflösung. + * Der Score wird serverseitig aus den Eingaben neu berechnet. + */ +public class DevLabFinishRequest { + + private String slug; + private int durationSeconds; + private List items; + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public int getDurationSeconds() { return durationSeconds; } + public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public static class Item { + private Long taskId; + private JsonNode submission; + private int attempts; + private boolean revealed; + + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + + public JsonNode getSubmission() { return submission; } + public void setSubmission(JsonNode submission) { this.submission = submission; } + + public int getAttempts() { return attempts; } + public void setAttempts(int attempts) { this.attempts = attempts; } + + public boolean isRevealed() { return revealed; } + public void setRevealed(boolean revealed) { this.revealed = revealed; } + } +} diff --git a/src/main/java/com/example/mindflux/dto/DevLabGameRow.java b/src/main/java/com/example/mindflux/dto/DevLabGameRow.java new file mode 100644 index 0000000..b79be0b --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/DevLabGameRow.java @@ -0,0 +1,31 @@ +package com.example.mindflux.dto; + +/** Eine Zeile in der Admin-Spieleübersicht (ohne alle Aufgaben zu laden). */ +public class DevLabGameRow { + private final Long id; + private final String slug; + private final String title; + private final String language; + private final String path; + private final boolean active; + private final long taskCount; + + public DevLabGameRow(Long id, String slug, String title, String language, String path, + boolean active, long taskCount) { + this.id = id; + this.slug = slug; + this.title = title; + this.language = language; + this.path = path; + this.active = active; + this.taskCount = taskCount; + } + + public Long getId() { return id; } + public String getSlug() { return slug; } + public String getTitle() { return title; } + public String getLanguage() { return language; } + public String getPath() { return path; } + public boolean isActive() { return active; } + public long getTaskCount() { return taskCount; } +} diff --git a/src/main/java/com/example/mindflux/dto/DevLabResult.java b/src/main/java/com/example/mindflux/dto/DevLabResult.java new file mode 100644 index 0000000..52bb8a7 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/DevLabResult.java @@ -0,0 +1,35 @@ +package com.example.mindflux.dto; + +/** Endergebnis eines Dev-Lab-Durchlaufs (Score serverseitig berechnet) + Highscore-Stand. */ +public class DevLabResult { + + private final int score; + private final int maxScore; + private final int solvedTasks; + private final int totalTasks; + private final int stars; + private final int highscore; + private final boolean newHighscore; + private final long plays; + + public DevLabResult(int score, int maxScore, int solvedTasks, int totalTasks, int stars, + int highscore, boolean newHighscore, long plays) { + this.score = score; + this.maxScore = maxScore; + this.solvedTasks = solvedTasks; + this.totalTasks = totalTasks; + this.stars = stars; + this.highscore = highscore; + this.newHighscore = newHighscore; + this.plays = plays; + } + + public int getScore() { return score; } + public int getMaxScore() { return maxScore; } + public int getSolvedTasks() { return solvedTasks; } + public int getTotalTasks() { return totalTasks; } + public int getStars() { return stars; } + public int getHighscore() { return highscore; } + public boolean isNewHighscore() { return newHighscore; } + public long getPlays() { return plays; } +} diff --git a/src/main/java/com/example/mindflux/model/DevLabGame.java b/src/main/java/com/example/mindflux/model/DevLabGame.java new file mode 100644 index 0000000..e4b3e8e --- /dev/null +++ b/src/main/java/com/example/mindflux/model/DevLabGame.java @@ -0,0 +1,88 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Ein Dev-Lab-Spiel (z.B. "SQL Detective"). Bündelt themengleiche Aufgaben unterschiedlicher + * Mechaniken. Inhalte DB-gestützt und im Admin pflegbar; server-autoritativ geprüft. + */ +@Entity +@Table(name = "devlab_games") +public class DevLabGame { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String slug; + + private String title; + + /** Anzeige-Sprache/Domäne, z.B. "SQL", "CSS", "Git". */ + @Column(name = "code_language") + private String language; + + /** Trainingspfad: "syntax" | "debugging" | "visual". */ + @Column(name = "lab_path") + private String path; + + private String description; + + @Column(name = "sort_order") + private int sortOrder; + + private boolean active = true; + + @OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC, id ASC") + private List tasks = new ArrayList<>(); + + public DevLabGame() { + } + + public DevLabGame(String slug, String title, String language, String path, + String description, int sortOrder, boolean active) { + this.slug = slug; + this.title = title; + this.language = language; + this.path = path; + this.description = description; + this.sortOrder = sortOrder; + this.active = active; + } + + public void addTask(DevLabTask task) { + task.setGame(this); + this.tasks.add(task); + } + + 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 getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + 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 getTasks() { return tasks; } + public void setTasks(List tasks) { this.tasks = tasks; } +} diff --git a/src/main/java/com/example/mindflux/model/DevLabTask.java b/src/main/java/com/example/mindflux/model/DevLabTask.java new file mode 100644 index 0000000..2cc79f9 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/DevLabTask.java @@ -0,0 +1,69 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +/** + * Eine Dev-Lab-Aufgabe. Der {@code type} bestimmt die Spielmechanik (cloze, choice, order, + * spot, match, regex); die mechanikspezifischen Daten inkl. Lösung stehen als JSON im + * {@code payload}. Der {@code payload} verlässt nie unverändert das Backend – der Service baut + * daraus eine bereinigte Sicht (ohne Lösung) und prüft serverseitig. + */ +@Entity +@Table(name = "devlab_tasks") +public class DevLabTask { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id") + private DevLabGame game; + + /** Mechanik: cloze | choice | order | spot | match | regex. */ + @Column(name = "task_type") + private String type; + + /** Kurzer Aufgabentitel (für Anzeige + Admin-Liste). */ + private String prompt; + + private int points = 100; + + @Column(name = "sort_order") + private int sortOrder; + + /** Vollständige Aufgabenspezifikation als JSON (inkl. Lösung). */ + private String payload; + + public DevLabTask() { + } + + public DevLabTask(String type, String prompt, int points, int sortOrder, String payload) { + this.type = type; + this.prompt = prompt; + this.points = points; + this.sortOrder = sortOrder; + this.payload = payload; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public DevLabGame getGame() { return game; } + public void setGame(DevLabGame game) { this.game = game; } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getPrompt() { return prompt; } + public void setPrompt(String prompt) { this.prompt = prompt; } + + public int getPoints() { return points; } + public void setPoints(int points) { this.points = points; } + + public int getSortOrder() { return sortOrder; } + public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; } + + public String getPayload() { return payload; } + public void setPayload(String payload) { this.payload = payload; } +} diff --git a/src/main/java/com/example/mindflux/repository/DevLabGameRepository.java b/src/main/java/com/example/mindflux/repository/DevLabGameRepository.java new file mode 100644 index 0000000..3969201 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/DevLabGameRepository.java @@ -0,0 +1,18 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.DevLabGame; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface DevLabGameRepository extends JpaRepository { + + List findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + List findAllByOrderBySortOrderAscIdAsc(); + + Optional findBySlug(String slug); + + boolean existsBySlug(String slug); +} diff --git a/src/main/java/com/example/mindflux/repository/DevLabTaskRepository.java b/src/main/java/com/example/mindflux/repository/DevLabTaskRepository.java new file mode 100644 index 0000000..f2d71ee --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/DevLabTaskRepository.java @@ -0,0 +1,9 @@ +package com.example.mindflux.repository; + +import com.example.mindflux.model.DevLabTask; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DevLabTaskRepository extends JpaRepository { + + long countByGameId(Long gameId); +} diff --git a/src/main/java/com/example/mindflux/service/DevLabService.java b/src/main/java/com/example/mindflux/service/DevLabService.java new file mode 100644 index 0000000..c81ab19 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/DevLabService.java @@ -0,0 +1,555 @@ +package com.example.mindflux.service; + +import com.example.mindflux.dto.DevLabFinishRequest; +import com.example.mindflux.dto.DevLabGameRow; +import com.example.mindflux.dto.DevLabResult; +import com.example.mindflux.dto.ScoreResult; +import com.example.mindflux.model.DevLabGame; +import com.example.mindflux.model.DevLabTask; +import com.example.mindflux.repository.DevLabGameRepository; +import com.example.mindflux.repository.DevLabTaskRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Server-autoritative Logik fürs Dev Lab. Aus dem JSON-Payload jeder Aufgabe wird eine + * bereinigte Sicht (ohne Lösung) gebaut; geprüft wird ausschließlich serverseitig per + * Text-/Reihenfolge-/Verhaltensvergleich gegen den gespeicherten Payload. Mechaniken: + * cloze, choice, order, spot, match, regex. + */ +@Service +public class DevLabService { + + private static final String GAME = "devlab"; + private static final Set KNOWN_TYPES = Set.of("cloze", "choice", "order", "spot", "match", "regex"); + private static final Set KNOWN_PATHS = Set.of("syntax", "debugging", "visual"); + + private final DevLabGameRepository gameRepository; + private final DevLabTaskRepository taskRepository; + private final GameScoreService gameScoreService; + private final ObjectMapper mapper; + private final Random random = new Random(); + + public DevLabService(DevLabGameRepository gameRepository, DevLabTaskRepository taskRepository, + GameScoreService gameScoreService, ObjectMapper mapper) { + this.gameRepository = gameRepository; + this.taskRepository = taskRepository; + this.gameScoreService = gameScoreService; + this.mapper = mapper; + } + + // ---------- Katalog (Hub) ---------- + + /** Aktive Spiele nach Trainingspfad gruppiert (für den Hub). */ + @Transactional(readOnly = true) + public JsonNode buildCatalog() { + ObjectNode root = mapper.createObjectNode(); + ArrayNode games = root.putArray("games"); + for (DevLabGame game : gameRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()) { + if (game.getTasks().isEmpty()) { + continue; + } + ObjectNode g = games.addObject(); + g.put("slug", game.getSlug()); + g.put("title", game.getTitle()); + g.put("language", game.getLanguage()); + g.put("path", game.getPath()); + g.put("description", game.getDescription()); + g.put("taskCount", game.getTasks().size()); + } + return root; + } + + // ---------- Spielrunde ---------- + + /** Eine Spielrunde: Spiel-Meta + alle aktiven Aufgaben als bereinigte Sicht (ohne Lösung). */ + @Transactional(readOnly = true) + public JsonNode buildSession(String slug) { + DevLabGame game = gameRepository.findBySlug(slug).filter(DevLabGame::isActive).orElse(null); + ObjectNode root = mapper.createObjectNode(); + if (game == null) { + root.put("found", false); + return root; + } + root.put("found", true); + root.put("slug", game.getSlug()); + root.put("title", game.getTitle()); + root.put("language", game.getLanguage()); + root.put("path", game.getPath()); + root.put("description", game.getDescription()); + + ArrayNode tasks = root.putArray("tasks"); + int maxScore = 0; + for (DevLabTask task : game.getTasks()) { + ObjectNode view = sanitize(task); + if (view == null) { + continue; + } + tasks.add(view); + maxScore += task.getPoints(); + } + root.put("maxScore", maxScore); + root.put("totalTasks", tasks.size()); + return root; + } + + /** Baut die spielerseitige Sicht einer Aufgabe – ohne Lösung. */ + private ObjectNode sanitize(DevLabTask task) { + JsonNode p = parse(task.getPayload()); + if (p == null) { + return null; + } + ObjectNode v = mapper.createObjectNode(); + v.put("taskId", task.getId()); + v.put("type", task.getType()); + v.put("prompt", task.getPrompt()); + v.put("points", task.getPoints()); + copyText(p, v, "narrative"); + copyText(p, v, "instruction"); + copyText(p, v, "question"); + copyText(p, v, "code"); + copyText(p, v, "hint"); + copyText(p, v, "successText"); + + switch (task.getType()) { + case "cloze" -> { + copyText(p, v, "codeTemplate"); + ArrayNode gaps = v.putArray("gaps"); + for (JsonNode g : array(p, "gaps")) { + ObjectNode gv = gaps.addObject(); + gv.put("position", g.path("position").asInt()); + gv.put("placeholder", g.path("placeholder").asText("")); + gv.put("hint", g.path("hint").asText("")); + } + } + case "choice" -> v.set("options", shuffledTextArray(stringList(p, "options"))); + case "order" -> v.set("items", shuffledTextArray(stringList(p, "items"))); + case "spot" -> { + ArrayNode tokens = v.putArray("tokens"); + for (JsonNode t : array(p, "tokens")) { + tokens.add(t.asText("")); + } + } + case "match" -> { + ArrayNode left = v.putArray("left"); + ArrayNode right = v.putArray("right"); + List rights = new ArrayList<>(); + for (JsonNode pair : array(p, "pairs")) { + if (pair.isArray() && pair.size() >= 2) { + left.add(pair.get(0).asText("")); + rights.add(pair.get(1).asText("")); + } + } + Collections.shuffle(rights, random); + rights.forEach(right::add); + } + case "regex" -> { + ArrayNode samples = v.putArray("samples"); + for (JsonNode s : array(p, "samples")) { + ObjectNode sv = samples.addObject(); + sv.put("text", s.path("text").asText("")); + sv.put("shouldMatch", s.path("shouldMatch").asBoolean(false)); + } + } + default -> { return null; } + } + return v; + } + + // ---------- Prüfung ---------- + + /** Prüft eine Aufgabe serverseitig; mit {@code reveal} kommt die Auflösung dazu. */ + @Transactional(readOnly = true) + public JsonNode check(Long taskId, boolean reveal, JsonNode submission) { + ObjectNode result = mapper.createObjectNode(); + DevLabTask task = taskId == null ? null : taskRepository.findById(taskId).orElse(null); + JsonNode p = task == null ? null : parse(task.getPayload()); + if (p == null) { + result.put("correct", false); + return result; + } + JsonNode sub = submission == null ? mapper.createObjectNode() : submission; + boolean correct = switch (task.getType()) { + case "cloze" -> checkCloze(p, sub, result, reveal); + case "choice" -> checkChoice(p, sub, result, reveal); + case "order" -> checkOrder(p, sub, result, reveal); + case "spot" -> checkSpot(p, sub, result, reveal); + case "match" -> checkMatch(p, sub, result, reveal); + case "regex" -> checkRegex(p, sub, result, reveal); + default -> false; + }; + result.put("correct", correct); + if (correct && p.hasNonNull("successText")) { + result.put("successText", p.get("successText").asText()); + } + return result; + } + + private boolean checkCloze(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + Map typed = new HashMap<>(); + for (JsonNode g : array(sub, "gaps")) { + typed.put(g.path("position").asInt(), g.path("text").asText("")); + } + ArrayNode detail = result.putArray("detail"); + boolean all = true; + for (JsonNode gap : array(p, "gaps")) { + int pos = gap.path("position").asInt(); + boolean caseSensitive = gap.path("caseSensitive").asBoolean(true); + boolean ok = anyEquals(stringList(gap, "solutions"), typed.get(pos), caseSensitive); + if (!ok) { + all = false; + } + ObjectNode d = detail.addObject(); + d.put("position", pos); + d.put("correct", ok); + if (reveal && !ok) { + List sols = stringList(gap, "solutions"); + d.put("solution", sols.isEmpty() ? "" : sols.get(0)); + } + } + return all; + } + + private boolean checkChoice(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + List options = stringList(p, "options"); + int correctIndex = p.path("correctIndex").asInt(-1); + String correctText = (correctIndex >= 0 && correctIndex < options.size()) ? options.get(correctIndex) : null; + boolean ok = correctText != null && textEquals(correctText, sub.path("choice").asText(""), false); + if (reveal && !ok && correctText != null) { + result.put("solution", correctText); + } + return ok; + } + + private boolean checkOrder(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + List correctOrder = stringList(p, "items"); + List given = stringList(sub, "order"); + boolean ok = given.size() == correctOrder.size(); + for (int i = 0; ok && i < correctOrder.size(); i++) { + if (!textEquals(correctOrder.get(i), given.get(i), false)) { + ok = false; + } + } + if (reveal && !ok) { + result.put("solution", String.join(" → ", correctOrder)); + } + return ok; + } + + private boolean checkSpot(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + int bugIndex = p.path("bugIndex").asInt(-1); + boolean ok = sub.path("index").asInt(-2) == bugIndex; + if (reveal && !ok) { + ObjectNode sol = result.putObject("solution"); + sol.put("index", bugIndex); + sol.put("fix", p.path("fix").asText("")); + } + return ok; + } + + private boolean checkMatch(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + Map correctPairs = new HashMap<>(); + ArrayNode solution = mapper.createArrayNode(); + for (JsonNode pair : array(p, "pairs")) { + if (pair.isArray() && pair.size() >= 2) { + correctPairs.put(normalize(pair.get(0).asText("")), normalize(pair.get(1).asText(""))); + ArrayNode pv = solution.addArray(); + pv.add(pair.get(0).asText("")); + pv.add(pair.get(1).asText("")); + } + } + int matched = 0; + for (JsonNode pair : array(sub, "pairs")) { + if (pair.isArray() && pair.size() >= 2) { + String want = correctPairs.get(normalize(pair.get(0).asText(""))); + if (want != null && want.equals(normalize(pair.get(1).asText("")))) { + matched++; + } + } + } + boolean ok = matched == correctPairs.size() && matched > 0; + if (reveal && !ok) { + result.set("solution", solution); + } + return ok; + } + + private boolean checkRegex(JsonNode p, JsonNode sub, ObjectNode result, boolean reveal) { + String patternText = sub.path("pattern").asText(""); + boolean ok = false; + if (!patternText.isBlank()) { + try { + Pattern pattern = Pattern.compile(patternText); + ok = true; + for (JsonNode s : array(p, "samples")) { + boolean shouldMatch = s.path("shouldMatch").asBoolean(false); + boolean matches = pattern.matcher(s.path("text").asText("")).matches(); + if (matches != shouldMatch) { + ok = false; + break; + } + } + } catch (RuntimeException ex) { + result.put("error", "Ungültiges Regex-Muster."); + ok = false; + } + } + if (reveal && !ok && p.hasNonNull("pattern")) { + result.put("solution", p.get("pattern").asText()); + } + return ok; + } + + // ---------- Abschluss ---------- + + /** Schließt einen Durchlauf ab: Score wird aus den Eingaben recomputed und gespeichert. */ + @Transactional + public DevLabResult finish(String username, DevLabFinishRequest request) { + int score = 0; + int maxScore = 0; + int solved = 0; + int total = 0; + + if (request.getItems() != null) { + for (DevLabFinishRequest.Item item : request.getItems()) { + if (item.getTaskId() == null) { + continue; + } + DevLabTask task = taskRepository.findById(item.getTaskId()).orElse(null); + if (task == null) { + continue; + } + total++; + maxScore += task.getPoints(); + JsonNode verdict = check(task.getId(), false, item.getSubmission()); + if (verdict.path("correct").asBoolean(false) && !item.isRevealed()) { + solved++; + score += awardedPoints(task.getPoints(), item.getAttempts()); + } + } + } + + int stars = stars(score, maxScore); + int seconds = Math.max(request.getDurationSeconds(), 0); + String deck = request.getSlug() == null ? null : request.getSlug(); + ScoreResult saved = gameScoreService.recordScore(username, GAME, deck, score, solved, seconds); + return new DevLabResult(score, maxScore, solved, total, stars, + saved.getHighscore(), saved.isNewHighscore(), saved.getPlays()); + } + + 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 ---------- + + @Transactional(readOnly = true) + public List gameRows() { + return gameRepository.findAllByOrderBySortOrderAscIdAsc().stream() + .map(g -> new DevLabGameRow(g.getId(), g.getSlug(), g.getTitle(), g.getLanguage(), + g.getPath(), g.isActive(), taskRepository.countByGameId(g.getId()))) + .toList(); + } + + @Transactional(readOnly = true) + public DevLabGame gameWithTasks(Long id) { + DevLabGame game = gameRepository.findById(id).orElse(null); + if (game != null) { + game.getTasks().size(); // lazy init + } + return game; + } + + @Transactional + public Long createGame(String title, String language, String path, String description) { + if (title == null || title.isBlank()) { + return null; + } + String clean = title.trim(); + int order = (int) gameRepository.count() + 1; + DevLabGame game = new DevLabGame(uniqueSlug(clean), clean, blankToNull(language), + normalizePath(path), blankToNull(description), order, true); + gameRepository.save(game); + return game.getId(); + } + + @Transactional + public void addTask(Long gameId, String type, String prompt, int points, String payload) { + if (type == null || prompt == null || prompt.isBlank() + || !KNOWN_TYPES.contains(type.trim()) || !isValidJson(payload)) { + return; + } + gameRepository.findById(gameId).ifPresent(game -> { + int sort = game.getTasks().size(); + game.addTask(new DevLabTask(type.trim(), cap(prompt.trim(), 300), normalizePoints(points), sort, payload.trim())); + gameRepository.save(game); + }); + } + + @Transactional + public void updateTask(Long taskId, String prompt, int points, String payload) { + taskRepository.findById(taskId).ifPresent(task -> { + if (prompt != null && !prompt.isBlank()) { + task.setPrompt(cap(prompt.trim(), 300)); + } + task.setPoints(normalizePoints(points)); + if (isValidJson(payload)) { + task.setPayload(payload.trim()); + } + }); + } + + @Transactional + public void deleteTask(Long taskId) { + taskRepository.deleteById(taskId); + } + + @Transactional + public void deleteGame(Long gameId) { + gameRepository.deleteById(gameId); + } + + @Transactional + public void toggleGame(Long gameId) { + gameRepository.findById(gameId).ifPresent(g -> g.setActive(!g.isActive())); + } + + // ---------- Helfer ---------- + + private boolean isValidJson(String json) { + if (json == null || json.isBlank()) { + return false; + } + try { + return mapper.readTree(json).isObject(); + } catch (Exception ex) { + return false; + } + } + + private String blankToNull(String value) { + return (value == null || value.isBlank()) ? null : value.trim(); + } + + private String normalizePath(String path) { + if (path == null) { + return "syntax"; + } + String p = path.trim().toLowerCase(); + return KNOWN_PATHS.contains(p) ? p : "syntax"; + } + + private int normalizePoints(int points) { + return points <= 0 ? 100 : Math.min(1000, points); + } + + private String cap(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } + + 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 = "game"; + } + String slug = base; + int counter = 2; + while (gameRepository.existsBySlug(slug)) { + slug = base + "-" + counter++; + } + return slug; + } + + private JsonNode parse(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return mapper.readTree(json); + } catch (Exception ex) { + return null; + } + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " "); + } + + private static boolean textEquals(String a, String b, boolean caseSensitive) { + String na = normalize(a); + String nb = normalize(b); + return caseSensitive ? na.equals(nb) : na.equalsIgnoreCase(nb); + } + + private static boolean anyEquals(List candidates, String value, boolean caseSensitive) { + if (value == null || normalize(value).isEmpty()) { + return false; + } + for (String candidate : candidates) { + if (textEquals(candidate, value, caseSensitive)) { + return true; + } + } + return false; + } + + private Iterable array(JsonNode node, String field) { + JsonNode arr = node == null ? null : node.get(field); + return (arr != null && arr.isArray()) ? arr : mapper.createArrayNode(); + } + + private List stringList(JsonNode node, String field) { + List out = new ArrayList<>(); + for (JsonNode item : array(node, field)) { + out.add(item.asText("")); + } + return out; + } + + private ArrayNode shuffledTextArray(List texts) { + List copy = new ArrayList<>(texts); + Collections.shuffle(copy, random); + ArrayNode arr = mapper.createArrayNode(); + copy.forEach(arr::add); + return arr; + } + + private void copyText(JsonNode source, ObjectNode target, String field) { + if (source.hasNonNull(field)) { + String value = source.get(field).asText(); + if (!value.isEmpty()) { + target.put(field, value); + } + } + } +} diff --git a/src/main/resources/db/migration/V8__devlab.sql b/src/main/resources/db/migration/V8__devlab.sql new file mode 100644 index 0000000..e43e6be --- /dev/null +++ b/src/main/resources/db/migration/V8__devlab.sql @@ -0,0 +1,26 @@ +-- Dev Lab: Entwicklerlabor mit mehreren Spielmechaniken (cloze, choice, order, spot, match, regex). +-- Spiel -> Aufgaben; je Aufgabe ein Typ + JSON-Payload (inkl. Lösung). Inhalte DB-basiert und +-- Admin-pflegbar; server-autoritativ geprüft (Lösungen verlassen nie unverändert das Backend). + +CREATE TABLE devlab_games ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + code_language VARCHAR(50), + lab_path VARCHAR(50), + description VARCHAR(1000), + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE devlab_tasks ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + game_id BIGINT NOT NULL REFERENCES devlab_games (id) ON DELETE CASCADE, + task_type VARCHAR(30) NOT NULL, + prompt VARCHAR(300) NOT NULL, + points INT NOT NULL DEFAULT 100, + sort_order INT NOT NULL DEFAULT 0, + payload VARCHAR(8000) NOT NULL +); + +CREATE INDEX idx_devlab_tasks_game ON devlab_tasks (game_id); diff --git a/src/main/resources/devlab/content.json b/src/main/resources/devlab/content.json new file mode 100644 index 0000000..a20a466 --- /dev/null +++ b/src/main/resources/devlab/content.json @@ -0,0 +1,3959 @@ +{ + "games": [ + { + "slug": "query-builder", + "title": "Query Builder", + "language": "SQL", + "path": "syntax", + "description": "Baue SQL-Abfragen Stück für Stück zusammen und lerne die Reihenfolge der Klauseln.", + "tasks": [ + { + "type": "cloze", + "prompt": "Spalten auswählen", + "points": 100, + "narrative": "Jede Abfrage beginnt mit dem richtigen Keyword.", + "codeTemplate": "{{1}} name, stadt\nFROM kunden;", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Womit wählst du Spalten aus?", + "caseSensitive": false, + "solutions": [ + "SELECT" + ] + } + ], + "hint": "Das allererste Wort jeder Lese-Abfrage.", + "successText": "Perfekt, die Spalten sind ausgewählt!" + }, + { + "type": "match", + "prompt": "Klausel und Zweck", + "points": 100, + "instruction": "Ordne jede Klausel ihrer Aufgabe zu.", + "pairs": [ + [ + "WHERE", + "filtert einzelne Zeilen" + ], + [ + "GROUP BY", + "fasst Zeilen zu Gruppen zusammen" + ], + [ + "HAVING", + "filtert ganze Gruppen" + ], + [ + "ORDER BY", + "sortiert das Ergebnis" + ] + ], + "hint": "WHERE wirkt VOR der Gruppierung, HAVING danach.", + "successText": "Alle Klauseln richtig zugeordnet!" + }, + { + "type": "choice", + "prompt": "Der Durchschnitt", + "points": 100, + "question": "Welche Aggregatfunktion liefert den Durchschnittswert?", + "options": [ + "SUM()", + "AVG()", + "COUNT()", + "MAX()" + ], + "correctIndex": 1, + "hint": "Average heißt Durchschnitt.", + "successText": "Richtig, AVG() berechnet den Mittelwert." + }, + { + "type": "cloze", + "prompt": "Zeilen filtern", + "points": 100, + "narrative": "Du suchst nur Kunden aus Leipzig.", + "codeTemplate": "SELECT name\nFROM kunden\nWHERE stadt {{1}} 'Leipzig';", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Test auf Gleichheit.", + "caseSensitive": false, + "solutions": [ + "=" + ] + } + ], + "hint": "In SQL prüft man Gleichheit mit einem einzelnen Zeichen.", + "successText": "Gut gefiltert!" + }, + { + "type": "cloze", + "prompt": "Nur Eindeutiges", + "points": 100, + "narrative": "Du willst jede Stadt nur einmal sehen.", + "codeTemplate": "SELECT {{1}} stadt\nFROM kunden;", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Entfernt doppelte Werte.", + "caseSensitive": false, + "solutions": [ + "DISTINCT" + ] + } + ], + "hint": "Das Wort steht direkt hinter SELECT.", + "successText": "Duplikate verschwunden!" + }, + { + "type": "choice", + "prompt": "Alle Zeilen zählen", + "points": 100, + "question": "Wie zählst du die Anzahl aller Zeilen einer Tabelle?", + "options": [ + "COUNT(*)", + "SUM(*)", + "TOTAL()", + "ROWS()" + ], + "correctIndex": 0, + "hint": "Der Stern steht für alle Spalten.", + "successText": "Genau, COUNT(*) zählt jede Zeile." + }, + { + "type": "cloze", + "prompt": "Gruppieren", + "points": 100, + "narrative": "Wie viele Mitarbeiter hat jede Abteilung?", + "codeTemplate": "SELECT abteilung, COUNT(*)\nFROM mitarbeiter\n{{1}} abteilung;", + "gaps": [ + { + "position": 1, + "placeholder": "Klausel", + "hint": "Fasst Zeilen je Abteilung zusammen.", + "caseSensitive": false, + "solutions": [ + "GROUP BY" + ] + } + ], + "hint": "Zwei Wörter mit Leerzeichen.", + "successText": "Sauber gruppiert!" + }, + { + "type": "order", + "prompt": "Einfache Abfrage bauen", + "points": 100, + "instruction": "Bring die Zeilen in die korrekte SQL-Reihenfolge.", + "items": [ + "SELECT name", + "FROM kunden", + "WHERE stadt = 'Berlin'", + "ORDER BY name" + ], + "hint": "Erst auswählen, dann woher, dann filtern, dann sortieren.", + "successText": "Die Abfrage steht richtig!" + }, + { + "type": "cloze", + "prompt": "Absteigend sortieren", + "points": 100, + "narrative": "Die ältesten Kunden sollen oben stehen.", + "codeTemplate": "SELECT name, alter\nFROM kunden\nORDER BY alter {{1}};", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Descending = absteigend.", + "caseSensitive": false, + "solutions": [ + "DESC" + ] + } + ], + "hint": "Drei Buchstaben für absteigend.", + "successText": "Größter Wert zuerst!" + }, + { + "type": "choice", + "prompt": "Vor der Gruppierung filtern", + "points": 100, + "question": "Welche Klausel filtert Zeilen, BEVOR gruppiert wird?", + "options": [ + "HAVING", + "WHERE", + "GROUP BY", + "ORDER BY" + ], + "correctIndex": 1, + "hint": "Diese Klausel arbeitet auf einzelnen Zeilen.", + "successText": "Richtig, WHERE filtert vor der Gruppierung." + }, + { + "type": "cloze", + "prompt": "Gruppen filtern", + "points": 100, + "narrative": "Nur Abteilungen mit mehr als 5 Leuten interessieren dich.", + "codeTemplate": "SELECT abteilung, COUNT(*)\nFROM mitarbeiter\nGROUP BY abteilung\n{{1}} COUNT(*) > 5;", + "gaps": [ + { + "position": 1, + "placeholder": "Klausel", + "hint": "Filtert nach dem Gruppieren.", + "caseSensitive": false, + "solutions": [ + "HAVING" + ] + } + ], + "hint": "WHERE geht hier nicht, weil aggregiert wird.", + "successText": "Nur große Abteilungen bleiben übrig!" + }, + { + "type": "choice", + "prompt": "Nach der Gruppierung", + "points": 100, + "question": "Welche Klausel filtert ganze Gruppen NACH GROUP BY?", + "options": [ + "WHERE", + "HAVING", + "LIMIT", + "ON" + ], + "correctIndex": 1, + "hint": "Sie kann Aggregatfunktionen wie COUNT(*) nutzen.", + "successText": "Korrekt, HAVING filtert Gruppen." + }, + { + "type": "match", + "prompt": "Aggregatfunktionen", + "points": 100, + "instruction": "Ordne jede Funktion ihrer Bedeutung zu.", + "pairs": [ + [ + "COUNT()", + "Anzahl" + ], + [ + "SUM()", + "Summe" + ], + [ + "AVG()", + "Durchschnitt" + ], + [ + "MAX()", + "Maximum" + ], + [ + "MIN()", + "Minimum" + ] + ], + "hint": "Englische Wörter helfen: count, sum, average ...", + "successText": "Alle Funktionen sitzen!" + }, + { + "type": "cloze", + "prompt": "Ergebnis begrenzen", + "points": 100, + "narrative": "Du brauchst nur die ersten 10 Treffer.", + "codeTemplate": "SELECT name\nFROM kunden\n{{1}} 10;", + "gaps": [ + { + "position": 1, + "placeholder": "Klausel", + "hint": "Begrenzt die Zeilenzahl.", + "caseSensitive": false, + "solutions": [ + "LIMIT" + ] + } + ], + "hint": "Englisch für Grenze.", + "successText": "Nur 10 Zeilen, perfekt!" + }, + { + "type": "cloze", + "prompt": "Datensatz einfügen", + "points": 100, + "narrative": "Ein neuer Kunde kommt in die Tabelle.", + "codeTemplate": "{{1}} INTO kunden (name, stadt)\n{{2}} ('Anna', 'Berlin');", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Womit fügst du ein?", + "caseSensitive": false, + "solutions": [ + "INSERT" + ] + }, + { + "position": 2, + "placeholder": "Keyword", + "hint": "Leitet die Werteliste ein.", + "caseSensitive": false, + "solutions": [ + "VALUES" + ] + } + ], + "hint": "INSERT INTO ... VALUES (...).", + "successText": "Anna ist eingetragen!" + }, + { + "type": "order", + "prompt": "Vollständige Abfrage", + "points": 100, + "instruction": "Setze die komplette Abfrage in die richtige Reihenfolge.", + "items": [ + "SELECT abteilung, COUNT(*)", + "FROM mitarbeiter", + "WHERE gehalt > 3000", + "GROUP BY abteilung", + "HAVING COUNT(*) > 2", + "ORDER BY abteilung" + ], + "hint": "SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY.", + "successText": "Die ganze Query steht korrekt!" + } + ] + }, + { + "slug": "sql-detective", + "title": "SQL Detective", + "language": "SQL", + "path": "debugging", + "description": "Löse IT-Ermittlungsfälle, indem du fehlerhafte und fehlende Teile von SQL-Abfragen aufspürst.", + "tasks": [ + { + "type": "spot", + "prompt": "Fall: Falsche Anführungszeichen", + "points": 100, + "narrative": "Die Abfrage liefert einen Fehler statt der Berliner Kunden.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "SELECT", + "*", + "FROM", + "kunden", + "WHERE", + "stadt", + "=", + "\"Berlin\"", + ";" + ], + "bugIndex": 7, + "fix": "'Berlin'", + "hint": "String-Literale brauchen in SQL einfache Anführungszeichen.", + "successText": "Gelöst! Strings stehen in einfachen Quotes." + }, + { + "type": "choice", + "prompt": "Fall: NULL-Werte finden", + "points": 100, + "question": "Welche Bedingung findet Zeilen ohne Stadt (NULL)?", + "options": [ + "stadt = NULL", + "stadt IS NULL", + "stadt == NULL", + "stadt NULL()" + ], + "correctIndex": 1, + "hint": "Mit = lässt sich NULL nicht vergleichen.", + "successText": "Richtig, NULL prüft man mit IS NULL." + }, + { + "type": "cloze", + "prompt": "Fall: Fehlendes JOIN-Schlüsselwort", + "points": 100, + "narrative": "Der Verknüpfungs-Befehl fehlt zwischen den Tabellen.", + "codeTemplate": "SELECT *\nFROM bestellungen b\nJOIN kunden k {{1}} b.kunde_id = k.id;", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Gibt die Verknüpfungsbedingung an.", + "caseSensitive": false, + "solutions": [ + "ON" + ] + } + ], + "hint": "Zwei Buchstaben.", + "successText": "Die Tabellen sind sauber verbunden!" + }, + { + "type": "spot", + "prompt": "Fall: Kaputter Operator", + "points": 100, + "narrative": "Der Vergleichsoperator existiert so nicht.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "SELECT", + "name", + "FROM", + "kunden", + "WHERE", + "alter", + "=>", + "18", + ";" + ], + "bugIndex": 6, + "fix": ">=", + "hint": "Größer-oder-gleich schreibt man andersherum.", + "successText": "Korrekt, der Operator heißt >=." + }, + { + "type": "cloze", + "prompt": "Fall: NULL-Prüfung ergänzen", + "points": 100, + "narrative": "Du suchst Kunden, bei denen keine Stadt hinterlegt ist.", + "codeTemplate": "SELECT name\nFROM kunden\nWHERE stadt {{1}} NULL;", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Nicht = verwenden!", + "caseSensitive": false, + "solutions": [ + "IS" + ] + } + ], + "hint": "Das Schlüsselwort für NULL-Vergleiche.", + "successText": "Sauber, IS NULL funktioniert." + }, + { + "type": "choice", + "prompt": "Fall: Der richtige JOIN", + "points": 100, + "question": "Welcher JOIN liefert ALLE Zeilen der linken Tabelle, auch ohne Treffer rechts?", + "options": [ + "INNER JOIN", + "LEFT JOIN", + "RIGHT JOIN", + "CROSS JOIN" + ], + "correctIndex": 1, + "hint": "Links steht zuerst.", + "successText": "Richtig, LEFT JOIN behält alle linken Zeilen." + }, + { + "type": "spot", + "prompt": "Fall: Verlorenes Komma", + "points": 100, + "narrative": "Zwischen zwei Spalten hat sich ein falsches Wort eingeschlichen.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "SELECT", + "name", + "AND", + "stadt", + "FROM", + "kunden", + ";" + ], + "bugIndex": 2, + "fix": ",", + "hint": "Spalten werden nicht mit AND getrennt.", + "successText": "Genau, Spalten trennt ein Komma." + }, + { + "type": "cloze", + "prompt": "Fall: Muster-Suche", + "points": 100, + "narrative": "Du suchst alle Namen, die mit A beginnen.", + "codeTemplate": "SELECT name\nFROM kunden\nWHERE name {{1}} 'A%';", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Für Mustervergleiche mit %.", + "caseSensitive": false, + "solutions": [ + "LIKE" + ] + } + ], + "hint": "Vier Buchstaben für Textmuster.", + "successText": "Alle A-Namen gefunden!" + }, + { + "type": "choice", + "prompt": "Fall: Nur gemeinsame Treffer", + "points": 100, + "question": "Welcher JOIN liefert nur Zeilen, die in BEIDEN Tabellen einen Treffer haben?", + "options": [ + "LEFT JOIN", + "FULL JOIN", + "INNER JOIN", + "RIGHT JOIN" + ], + "correctIndex": 2, + "hint": "Die Schnittmenge beider Tabellen.", + "successText": "Korrekt, INNER JOIN zeigt nur Übereinstimmungen." + }, + { + "type": "spot", + "prompt": "Fall: NULL falsch verglichen", + "points": 100, + "narrative": "Diese Abfrage liefert nie ein Ergebnis, obwohl es NULL-Werte gibt.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "SELECT", + "*", + "FROM", + "kunden", + "WHERE", + "stadt", + "=", + "NULL", + ";" + ], + "bugIndex": 6, + "fix": "IS", + "hint": "NULL lässt sich nicht mit = vergleichen.", + "successText": "Gelöst! IS NULL statt = NULL." + }, + { + "type": "cloze", + "prompt": "Fall: Bereich abfragen", + "points": 100, + "narrative": "Gesucht sind Produkte zwischen 10 und 20 Euro.", + "codeTemplate": "SELECT *\nFROM produkte\nWHERE preis {{1}} 10 AND 20;", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Für einen Wertebereich inklusive Grenzen.", + "caseSensitive": false, + "solutions": [ + "BETWEEN" + ] + } + ], + "hint": "Englisch für 'zwischen'.", + "successText": "Bereich korrekt eingegrenzt!" + }, + { + "type": "match", + "prompt": "Fall: JOIN-Typen einordnen", + "points": 100, + "instruction": "Ordne jedem JOIN-Typ seine Bedeutung zu.", + "pairs": [ + [ + "INNER JOIN", + "nur Treffer in beiden Tabellen" + ], + [ + "LEFT JOIN", + "alle links + passende rechts" + ], + [ + "RIGHT JOIN", + "alle rechts + passende links" + ], + [ + "FULL JOIN", + "alle Zeilen beider Tabellen" + ] + ], + "hint": "Denk an links, rechts und beide Seiten.", + "successText": "Alle JOIN-Typen aufgeklärt!" + }, + { + "type": "spot", + "prompt": "Fall: Tippfehler im Schlüsselwort", + "points": 100, + "narrative": "Ein Schlüsselwort wurde vertippt und blockiert die Abfrage.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "SELECT", + "COUNT(*)", + "FROM", + "mitarbeiter", + "GROPU BY", + "abteilung", + ";" + ], + "bugIndex": 4, + "fix": "GROUP BY", + "hint": "Lies das Gruppierungs-Keyword genau.", + "successText": "Tippfehler entlarvt: GROUP BY!" + }, + { + "type": "cloze", + "prompt": "Fall: Liste von Werten", + "points": 100, + "narrative": "Gesucht sind Kunden aus Berlin oder Leipzig.", + "codeTemplate": "SELECT *\nFROM kunden\nWHERE stadt {{1}} ('Berlin', 'Leipzig');", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Prüft, ob ein Wert in einer Liste steht.", + "caseSensitive": false, + "solutions": [ + "IN" + ] + } + ], + "hint": "Zwei Buchstaben.", + "successText": "Beide Städte erfasst!" + }, + { + "type": "choice", + "prompt": "Fall: Kreuzprodukt", + "points": 100, + "question": "Wie viele Zeilen liefert ein CROSS JOIN von 3 und 4 Zeilen?", + "options": [ + "7", + "12", + "3", + "4" + ], + "correctIndex": 1, + "hint": "Beim Kreuzprodukt wird multipliziert.", + "successText": "Richtig, 3 mal 4 = 12 Zeilen." + }, + { + "type": "cloze", + "prompt": "Fall: Eindeutige zählen", + "points": 100, + "narrative": "Wie viele verschiedene Städte kommen vor?", + "codeTemplate": "SELECT COUNT({{1}} stadt)\nFROM kunden;", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Zählt nur unterschiedliche Werte.", + "caseSensitive": false, + "solutions": [ + "DISTINCT" + ] + } + ], + "hint": "Das gleiche Wort, das Duplikate entfernt.", + "successText": "Nur eindeutige Städte gezählt!" + } + ] + }, + { + "slug": "debug-hunt", + "title": "Debug Hunt", + "language": "Java", + "path": "debugging", + "description": "Jage echte Bugs in Java-Code: falsche Typen, Vergleiche, Indizes und mehr.", + "tasks": [ + { + "type": "spot", + "prompt": "Falscher Datentyp", + "points": 100, + "narrative": "Hier wird einer int-Variablen ein Text zugewiesen.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "int", + "x", + "=", + "\"10\"", + ";" + ], + "bugIndex": 3, + "fix": "10", + "hint": "Ein int speichert Zahlen, keinen String.", + "successText": "Erwischt! 10 ohne Anführungszeichen." + }, + { + "type": "choice", + "prompt": "Was wird ausgegeben?", + "points": 100, + "question": "Was gibt System.out.println(5 + 3 + \"!\"); aus?", + "options": [ + "53!", + "8!", + "5 + 3!", + "!8" + ], + "correctIndex": 1, + "hint": "Erst werden die Zahlen addiert, dann kommt der Text.", + "successText": "Richtig, 5+3=8, also 8!" + }, + { + "type": "cloze", + "prompt": "Vergleich statt Zuweisung", + "points": 100, + "narrative": "In der Bedingung soll verglichen werden, nicht zugewiesen.", + "codeTemplate": "if (x {{1}} 5) {\n System.out.println(\"fünf\");\n}", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Doppeltes Zeichen für Gleichheit.", + "caseSensitive": true, + "solutions": [ + "==" + ] + } + ], + "hint": "Ein einzelnes = würde nur zuweisen.", + "successText": "Korrekt, == vergleicht Werte." + }, + { + "type": "spot", + "prompt": "Fehlendes Semikolon", + "points": 100, + "narrative": "Diese Zeile endet mit dem falschen Zeichen.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "int", + "y", + "=", + "5", + "," + ], + "bugIndex": 4, + "fix": ";", + "hint": "Anweisungen enden in Java nicht mit Komma.", + "successText": "Gut, eine Anweisung endet mit ;" + }, + { + "type": "cloze", + "prompt": "Null-Prüfung", + "points": 100, + "narrative": "Vor dem Zugriff soll geprüft werden, ob name nicht null ist.", + "codeTemplate": "if (name {{1}} null) {\n name.length();\n}", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Ungleich.", + "caseSensitive": true, + "solutions": [ + "!=" + ] + } + ], + "hint": "Ausrufezeichen und Gleichheitszeichen.", + "successText": "Sicher, != null verhindert den Crash." + }, + { + "type": "choice", + "prompt": "String-Vergleich", + "points": 100, + "question": "Welcher Fehler steckt in if (s == \"hallo\") für einen Inhaltsvergleich?", + "options": [ + "Vergleicht Referenzen statt Inhalt", + "Syntaxfehler", + "Nichts, ist korrekt", + "s muss großgeschrieben sein" + ], + "correctIndex": 0, + "hint": "Für String-Inhalte nutzt man equals().", + "successText": "Richtig, == vergleicht nur Referenzen." + }, + { + "type": "spot", + "prompt": "Index außerhalb der Grenzen", + "points": 100, + "narrative": "Diese Schleife greift einen Schritt zu weit zu.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "for", + "(", + "int", + "i", + "=", + "0", + ";", + "i", + "<=", + "arr.length", + ";", + "i++", + ")" + ], + "bugIndex": 8, + "fix": "<", + "hint": "Gültige Indizes gehen von 0 bis length-1.", + "successText": "Gelöst! i < arr.length verhindert den Überlauf." + }, + { + "type": "cloze", + "prompt": "Rückgabewert", + "points": 100, + "narrative": "Die Methode soll das Quadrat zurückgeben, tut es aber nicht.", + "codeTemplate": "int quadrat(int n) {\n {{1}} n * n;\n}", + "gaps": [ + { + "position": 1, + "placeholder": "Keyword", + "hint": "Liefert einen Wert zurück.", + "caseSensitive": true, + "solutions": [ + "return" + ] + } + ], + "hint": "Sechs Buchstaben, kleingeschrieben.", + "successText": "Korrekt, return gibt das Ergebnis zurück." + }, + { + "type": "choice", + "prompt": "Ganzzahl-Division", + "points": 100, + "question": "Was ergibt 10 / 3 in Java, wenn beide Werte int sind?", + "options": [ + "3.33", + "3", + "4", + "3.0" + ], + "correctIndex": 1, + "hint": "Bei int-Division wird der Rest verworfen.", + "successText": "Richtig, das Ergebnis ist 3." + }, + { + "type": "spot", + "prompt": "Falscher Variablentyp", + "points": 100, + "narrative": "Ein Wahrheitswert wird im falschen Typ gespeichert.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "int", + "flag", + "=", + "true", + ";" + ], + "bugIndex": 0, + "fix": "boolean", + "hint": "true und false brauchen einen eigenen Typ.", + "successText": "Erwischt! boolean statt int." + }, + { + "type": "cloze", + "prompt": "Typ für Wahrheitswerte", + "points": 100, + "narrative": "Welcher Datentyp speichert true oder false?", + "codeTemplate": "{{1}} aktiv = true;", + "gaps": [ + { + "position": 1, + "placeholder": "Datentyp", + "hint": "Für ja/nein-Werte.", + "caseSensitive": true, + "solutions": [ + "boolean" + ] + } + ], + "hint": "Sieben Buchstaben, kleingeschrieben.", + "successText": "Korrekt, boolean speichert Wahrheitswerte." + }, + { + "type": "choice", + "prompt": "String-Verkettung", + "points": 100, + "question": "Was gibt System.out.println(\"\" + 5 + 3); aus?", + "options": [ + "8", + "53", + "5 3", + "Fehler" + ], + "correctIndex": 1, + "hint": "Sobald ein String dabei ist, wird verkettet statt addiert.", + "successText": "Richtig, das Ergebnis ist 53." + }, + { + "type": "spot", + "prompt": "Verdrehter Operator", + "points": 100, + "narrative": "Der Ungleich-Operator wurde falsch herum geschrieben.", + "instruction": "Klick den fehlerhaften Token an.", + "tokens": [ + "if", + "(", + "a", + "=!", + "b", + ")" + ], + "bugIndex": 3, + "fix": "!=", + "hint": "Erst das Ausrufezeichen, dann das Gleichheitszeichen.", + "successText": "Gelöst! Der Operator heißt !=." + }, + { + "type": "cloze", + "prompt": "Kurzschreibweise zum Addieren", + "points": 100, + "narrative": "Der Wert von summe soll um 5 erhöht werden.", + "codeTemplate": "int summe = 0;\nsumme {{1}} 5;", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Kurzform für summe = summe + 5.", + "caseSensitive": true, + "solutions": [ + "+=" + ] + } + ], + "hint": "Plus und Gleichheitszeichen.", + "successText": "Korrekt, += addiert und weist zu." + }, + { + "type": "choice", + "prompt": "Array-Zugriff", + "points": 100, + "question": "Was passiert bei int[] a = new int[3]; a[3] = 1;?", + "options": [ + "Läuft normal durch", + "ArrayIndexOutOfBoundsException", + "NullPointerException", + "Kompilierfehler" + ], + "correctIndex": 1, + "hint": "Gültige Indizes sind 0, 1 und 2.", + "successText": "Richtig, Index 3 existiert nicht." + }, + { + "type": "cloze", + "prompt": "Schleifenbedingung", + "points": 100, + "narrative": "Die Schleife soll genau zehnmal laufen (0 bis 9).", + "codeTemplate": "for (int i = 0; i {{1}} 10; i++) {\n System.out.println(i);\n}", + "gaps": [ + { + "position": 1, + "placeholder": "Operator", + "hint": "Kleiner als, nicht kleiner-gleich.", + "caseSensitive": true, + "solutions": [ + "<" + ] + } + ], + "hint": "Mit <= würde sie elfmal laufen.", + "successText": "Korrekt, i < 10 ergibt 10 Durchläufe." + }, + { + "type": "choice", + "prompt": "Was steht in b?", + "points": 100, + "question": "Welchen Wert hat b nach boolean b = 5 > 3;?", + "options": [ + "true", + "false", + "1", + "Fehler" + ], + "correctIndex": 0, + "hint": "Ist 5 wirklich größer als 3?", + "successText": "Richtig, 5 > 3 ist true." + } + ] + }, + { + "slug": "git-mission", + "title": "Git Mission", + "language": "Git", + "path": "debugging", + "description": "Meistere typische Repo-Situationen mit den richtigen Git-Befehlen und Reihenfolgen.", + "tasks": [ + { + "type": "order", + "prompt": "Mission: Änderung veröffentlichen", + "points": 100, + "instruction": "Bring die Befehle in die richtige Reihenfolge.", + "items": [ + "git add .", + "git commit -m \"feat: Login hinzugefügt\"", + "git push" + ], + "hint": "Erst vormerken, dann festschreiben, dann hochladen.", + "successText": "Deine Änderung ist veröffentlicht!" + }, + { + "type": "cloze", + "prompt": "Befehl: Festschreiben", + "points": 100, + "narrative": "Deine vorgemerkten Änderungen sollen gespeichert werden.", + "codeTemplate": "git {{1}} -m \"fix: Bug behoben\"", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Schreibt Änderungen in die Historie.", + "caseSensitive": true, + "solutions": [ + "commit" + ] + } + ], + "hint": "Sechs Buchstaben.", + "successText": "Commit erstellt!" + }, + { + "type": "choice", + "prompt": "Mission: Historie ansehen", + "points": 100, + "question": "Welcher Befehl zeigt den Commit-Verlauf?", + "options": [ + "git status", + "git log", + "git diff", + "git show" + ], + "correctIndex": 1, + "hint": "Englisch für Protokoll.", + "successText": "Richtig, git log zeigt die Historie." + }, + { + "type": "cloze", + "prompt": "Befehl: Branch wechseln", + "points": 100, + "narrative": "Du willst zurück auf den main-Branch.", + "codeTemplate": "git {{1}} main", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Moderner Befehl zum Branch-Wechsel.", + "caseSensitive": true, + "solutions": [ + "switch", + "checkout" + ] + } + ], + "hint": "switch oder das ältere checkout.", + "successText": "Du bist jetzt auf main!" + }, + { + "type": "order", + "prompt": "Mission: Arbeit zwischenparken", + "points": 100, + "instruction": "Du musst schnell den Branch wechseln, ohne zu committen. Ordne die Schritte.", + "items": [ + "git stash", + "git switch main", + "git pull", + "git switch feature", + "git stash pop" + ], + "hint": "Erst sichern, Branch-Arbeit erledigen, dann zurückholen.", + "successText": "Deine Arbeit ist wieder da!" + }, + { + "type": "choice", + "prompt": "Mission: Lokale Änderung verwerfen", + "points": 100, + "question": "Du willst eine noch NICHT committete Änderung an datei.txt verwerfen. Welcher Befehl?", + "options": [ + "git restore datei.txt", + "git revert HEAD", + "git reset --hard origin", + "git rm datei.txt" + ], + "correctIndex": 0, + "hint": "restore stellt den letzten committeten Stand wieder her.", + "successText": "Richtig, git restore verwirft die Änderung." + }, + { + "type": "cloze", + "prompt": "Befehl: Branch zusammenführen", + "points": 100, + "narrative": "Der Feature-Branch soll in den aktuellen Branch übernommen werden.", + "codeTemplate": "git {{1}} feature/login", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Führt zwei Branches zusammen.", + "caseSensitive": true, + "solutions": [ + "merge" + ] + } + ], + "hint": "Englisch für verschmelzen.", + "successText": "Branches zusammengeführt!" + }, + { + "type": "order", + "prompt": "Mission: Neuer Feature-Branch", + "points": 100, + "instruction": "Starte ein neues Feature und veröffentliche es. Ordne die Schritte.", + "items": [ + "git switch -c feature/login", + "git add .", + "git commit -m \"Login umgesetzt\"", + "git push -u origin feature/login" + ], + "hint": "Branch anlegen, vormerken, committen, hochladen.", + "successText": "Feature-Branch steht online!" + }, + { + "type": "cloze", + "prompt": "Befehl: Einzelnen Commit holen", + "points": 100, + "narrative": "Du brauchst genau einen Commit aus einem anderen Branch.", + "codeTemplate": "git {{1}} a1b2c3d", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Pflückt einen einzelnen Commit.", + "caseSensitive": true, + "solutions": [ + "cherry-pick" + ] + } + ], + "hint": "Zwei Wörter mit Bindestrich.", + "successText": "Commit übernommen!" + }, + { + "type": "choice", + "prompt": "Mission: Gepushten Commit zurücknehmen", + "points": 100, + "question": "Welcher Befehl macht einen bereits gepushten Commit mit einem neuen Commit rückgängig?", + "options": [ + "git reset --hard", + "git revert ", + "git checkout", + "git clean" + ], + "correctIndex": 1, + "hint": "Er ändert die Historie nicht, sondern kehrt sie um.", + "successText": "Richtig, git revert ist sicher für geteilte Branches." + }, + { + "type": "cloze", + "prompt": "Befehl: Commit umkehren", + "points": 100, + "narrative": "Der letzte Commit soll durch einen Gegen-Commit rückgängig gemacht werden.", + "codeTemplate": "git {{1}} HEAD", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Erzeugt einen umkehrenden Commit.", + "caseSensitive": true, + "solutions": [ + "revert" + ] + } + ], + "hint": "Englisch für rückgängig machen.", + "successText": "Der Commit ist umgekehrt!" + }, + { + "type": "order", + "prompt": "Mission: Merge-Konflikt lösen", + "points": 100, + "instruction": "Beim Zusammenführen entsteht ein Konflikt. Ordne die Schritte zur Lösung.", + "items": [ + "git merge feature", + "git status", + "git add konflikt.txt", + "git commit" + ], + "hint": "Mergen, Konflikt prüfen, gelöste Datei vormerken, abschließen.", + "successText": "Konflikt gelöst und gemergt!" + }, + { + "type": "cloze", + "prompt": "Befehl: Arbeit zwischenspeichern", + "points": 100, + "narrative": "Du willst deine Änderungen kurz beiseitelegen, ohne zu committen.", + "codeTemplate": "git {{1}}", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Legt Änderungen auf einen Stapel.", + "caseSensitive": true, + "solutions": [ + "stash" + ] + } + ], + "hint": "Englisch für Versteck.", + "successText": "Änderungen sicher zwischengespeichert!" + }, + { + "type": "choice", + "prompt": "Mission: Remote-Stand holen", + "points": 100, + "question": "Welcher Befehl lädt Änderungen vom Remote UND führt sie zusammen?", + "options": [ + "git fetch", + "git pull", + "git push", + "git clone" + ], + "correctIndex": 1, + "hint": "fetch holt nur, dieser Befehl mergt zusätzlich.", + "successText": "Richtig, git pull holt und mergt." + }, + { + "type": "cloze", + "prompt": "Befehl: Stash zurückholen", + "points": 100, + "narrative": "Deine zwischengespeicherten Änderungen sollen zurück in den Arbeitsbereich.", + "codeTemplate": "git stash {{1}}", + "gaps": [ + { + "position": 1, + "placeholder": "Befehl", + "hint": "Holt den letzten Stash und entfernt ihn vom Stapel.", + "caseSensitive": true, + "solutions": [ + "pop" + ] + } + ], + "hint": "Drei Buchstaben.", + "successText": "Deine Änderungen sind zurück!" + }, + { + "type": "order", + "prompt": "Mission: Gepushten Commit rückgängig", + "points": 100, + "instruction": "Ein fehlerhafter Commit ist schon auf dem Remote. Ordne die sichere Lösung.", + "items": [ + "git log --oneline", + "git revert a1b2c3d", + "git push" + ], + "hint": "Hash finden, umkehren, hochladen.", + "successText": "Der fehlerhafte Commit ist neutralisiert!" + } + ] + }, + { + "slug": "html-architect", + "title": "HTML Architect", + "language": "HTML", + "path": "syntax", + "description": "Baue Seiten mit semantischem HTML: Struktur-Elemente, Attribute und sauberes Markup.", + "tasks": [ + { + "type": "cloze", + "prompt": "Kopfbereich auszeichnen", + "points": 100, + "narrative": "Oben auf der Seite stehen Logo und Titel. Welches Element umschliesst diesen Kopfbereich?", + "codeTemplate": "<{{1}}>\n

Mindflux

\n", + "gaps": [ + { + "position": 1, + "placeholder": "Tag", + "hint": "Semantischer Kopfbereich.", + "caseSensitive": false, + "solutions": [ + "header" + ] + }, + { + "position": 2, + "placeholder": "Tag", + "hint": "Gleiches Tag wie das oeffnende.", + "caseSensitive": false, + "solutions": [ + "header" + ] + } + ], + "successText": "Stark!
markiert den einleitenden Bereich." + }, + { + "type": "choice", + "prompt": "Hauptinhalt", + "points": 100, + "question": "Welches Element kennzeichnet den zentralen Hauptinhalt eines Dokuments?", + "options": [ + "
", + "", + "", + "
" + ], + "correctIndex": 0, + "hint": "Es darf pro Seite nur einmal sichtbar vorkommen.", + "successText": "Genau,
umschliesst den Hauptinhalt." + }, + { + "type": "cloze", + "prompt": "Navigation", + "points": 100, + "narrative": "Die Linkliste fuehrt durch die Seite.", + "codeTemplate": "<{{1}} aria-label=\"Hauptmenue\">\n Start\n", + "gaps": [ + { + "position": 1, + "placeholder": "Tag", + "hint": "Steht fuer Navigation.", + "caseSensitive": false, + "solutions": [ + "nav" + ] + }, + { + "position": 2, + "placeholder": "Tag", + "hint": "Gleiches Tag schliessen.", + "caseSensitive": false, + "solutions": [ + "nav" + ] + } + ], + "successText": "
", + "hint": "Welches Tag wurde geoeffnet?", + "successText": "Korrekt, oeffnendes und schliessendes Tag muessen uebereinstimmen." + }, + { + "type": "cloze", + "prompt": "Thematischer Abschnitt", + "points": 100, + "narrative": "Ein klar abgegrenztes Kapitel mit eigener Ueberschrift.", + "codeTemplate": "<{{1}}>\n

Kapitel 1

\n", + "gaps": [ + { + "position": 1, + "placeholder": "Tag", + "hint": "Thematischer Block mit Ueberschrift.", + "caseSensitive": false, + "solutions": [ + "section" + ] + }, + { + "position": 2, + "placeholder": "Tag", + "hint": "Gleiches Tag schliessen.", + "caseSensitive": false, + "solutions": [ + "section" + ] + } + ], + "successText": "
gruppiert thematisch zusammengehoerigen Inhalt." + }, + { + "type": "choice", + "prompt": "Randinhalt", + "points": 100, + "question": "Welches Element eignet sich fuer ergaenzende Randinhalte wie eine Sidebar?", + "options": [ + "