16 Commits

Author SHA1 Message Date
AliceDarkest bcd216fe60 Merge remote-tracking branch 'origin/main' into fix/login-case-and-pw-confirm
mindflux-ci / build (push) Successful in 2m20s
2026-07-12 21:47:43 +02:00
AliceDarkest 005d2d7d8c Mindflux: Sicherheit, Datenintegrität und Navigation härten 2026-07-12 21:47:37 +02:00
AliceDarkest 4f5042dc43 Sicherheit: DB-Passwort rotieren und aus Secret laden
mindflux-ci / build (push) Successful in 28s
2026-07-12 21:43:02 +02:00
AliceDarkest db6b5bbb1a Doku: Changelog-Eintrag Login/Registrierung/Konten + Dev-Lab-Icon-Fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:36:15 +02:00
AliceDarkest 762812649c Merge: Admin-Passwort-Reset + Profilbild-Fix
mindflux-ci / build (push) Successful in 27s
2026-06-30 20:12:48 +02:00
AliceDarkest 6fd8f8ecc8 Fix: Admin-Passwort-Reset (Direkt-Button je Nutzer) + Profilbild beim Bearbeiten nicht mehr nullen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:12:17 +02:00
AliceDarkest b4fbc972f2 Merge: Login case-insensitiv + Passwort-Wiederholfeld
mindflux-ci / build (push) Successful in 27s
2026-06-30 20:04:08 +02:00
AliceDarkest 21c594445f Fix: Login case-insensitiv (Registrierung speichert lowercase, Login war case-sensitiv) + Passwort-Wiederholfeld
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:03:46 +02:00
AliceDarkest 035d603504 Merge: Fix Dev-Lab Debugging-Pfad-Icon
mindflux-ci / build (push) Successful in 27s
2026-06-30 18:04:47 +02:00
AliceDarkest adb5646b5e Fix: Dev-Lab Debugging-Pfad-Icon (</>-Marker HTML-escapen) + Cache-Buster v4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:03:55 +02:00
AliceDarkest 08e5e2b089 Merge: Dev Lab – Entwicklerlabor (6 Mechaniken, 189 Aufgaben) + STYLEGUIDE.md
mindflux-ci / build (push) Successful in 26s
2026-06-30 16:32:45 +02:00
AliceDarkest b88962da6f Dev Lab: server-autoritatives Entwicklerlabor mit 6 Mechaniken + 189 Aufgaben
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) <noreply@anthropic.com>
2026-06-30 16:32:03 +02:00
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 f2db7f286b Merge: Syntax Saga – neues Spiel + 34 Seed-Missionen
mindflux-ci / build (push) Successful in 27s
2026-06-30 12:14:27 +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
AliceDarkest d255c7b4db Merge pull request 'Mobile-Fixes: Spiele scrollbar + FinalFlux-Hub Overlap/Menue' (#6) from fix/mobile-games-finalflux into main
mindflux-ci / build (push) Successful in 27s
Reviewed-on: #6
2026-06-29 18:13:28 +00:00
119 changed files with 12041 additions and 630 deletions
+7
View File
@@ -22,8 +22,15 @@ jobs:
- name: Deploy
env:
MINDFLUX_DB_PASSWORD: ${{ secrets.MINDFLUX_DB_PASSWORD }}
MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }}
MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }}
run: |
test -n "$MINDFLUX_DB_PASSWORD" || { echo "MINDFLUX_DB_PASSWORD fehlt" >&2; exit 1; }
docker exec -i -e NEW_DB_PASSWORD="$MINDFLUX_DB_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U mindflux -d mindfluxdb <<'SQL'
\getenv new_db_password NEW_DB_PASSWORD
ALTER USER mindflux WITH PASSWORD :'new_db_password';
SQL
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
+190
View File
@@ -4,6 +4,196 @@ 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-07-12 · Großer Verbesserungs-Sweep (Sicherheit, Daten, Navigation)
Weitgehende Umsetzung des Verbesserungskatalogs aus `mindfluxfuture.md` (Teil 2;
bewusst zurückgestellte Restpunkte sind dort weiterhin als offen markiert):
Sicherheit, Datenintegrität, echtes XP/Achievement-System, Navigations-Umbau,
Docker/CI-Härtung und erste Tests.
### Sicherheit
- **DB-Passwort aus dem Repo entfernt:** `docker-compose.yml` liest jetzt `${MINDFLUX_DB_PASSWORD}`
aus einem Gitea-Secret (wie die Admin-Passwörter).
Compose bricht bei einem fehlenden oder leeren Secret jetzt sofort mit einer eindeutigen
Fehlermeldung ab, statt einen App-Container mit leerem DB-Passwort zu starten.
Der Gitea-Runner gleicht das PostgreSQL-Passwort vor jedem Deployment automatisch mit dem
Secret ab; für die Rotation ist daher kein separater SSH-Zugang erforderlich.
**Produktionsrotation abgeschlossen am 2026-07-12:** Secret angelegt, DB-Rolle aktualisiert
und Mindflux über den erfolgreichen Gitea-Workflow-Lauf 43 neu gestartet (Commit `4f5042d`).
- **Unsicherer Legacy-Endpunkt `/users/save` gelöscht** (speicherte User ohne Hashing, mit frei
wählbarer Rolle) inkl. `UserViewController`, `user-form.html`, `user-success.html`.
- **Logout nur noch per POST** mit CSRF-Token (alle Templates umgestellt).
- **Login-Brute-Force-Schutz:** Nach 5 Fehlversuchen wird der Benutzername 15 Minuten gesperrt
(`LoginAttemptService`, In-Memory).
- **Registrierung gehärtet:** Passwort-Mindestlänge 8, E-Mail-Formatprüfung, case-insensitiver
Unique-Index auf `users.username` (Migration V9) schließt das Registrierungs-Race.
- **Bootstrap-Admins abgesichert:** Ein Admin wird nur noch angelegt, wenn sein Passwort-Secret
tatsächlich gesetzt ist; fehlende optionale Admin-Konfiguration wird protokolliert und sicher
übersprungen.
- Dockerfile läuft nicht mehr als root (`USER mindflux`), zweistufig mit Dependency-Cache;
Tests laufen im Image-Build mit. Compose-Healthcheck auf `/actuator/health` (Actuator neu).
### Daten & Backend
- **Migration V10:** `UNIQUE(lower(email))`, Fremdschlüssel von `game_scores`/`achievements`
auf `users(username)` mit `ON DELETE CASCADE ON UPDATE CASCADE`; verwaiste Altdaten werden
bereinigt, E-Mail-Dubletten aufgelöst (älteste Zeile behält die Adresse).
- **Echte XP/Level/Streak statt Dummy-Daten:** `ProgressService` aggregiert aus `game_scores`
(XP = Score-Summe, 1000 XP je Level, Tages-Streak mit 1 Tag Karenz); Dashboard-Sidebar zeigt
echte Werte.
- **Achievements sind live:** Katalog aus 6 Regeln (Erster Sieg, 1000er-Club, Highscore-Jäger,
Dauerbrenner, Punktesammler, Allrounder), Vergabe automatisch bei jedem gespeicherten Score;
Dummy-Daten entfernt, gesperrte Karten zeigen den Katalog.
- **User-Logik konsolidiert:** Registrierung, REST- und Admin-Anlage laufen über einen
`UserService` (eine Stelle für BCrypt, Rollen-Whitelist, Eindeutigkeit);
`AdminUserService`/`AdminUserRepository` (Duplikate) entfernt.
- **Globales Error-Handling:** eigene Fehlerseite (`error.html`) statt Whitelabel, JSON-Fehler
mit korrekten Statuscodes für alle APIs, unbekannte URLs liefern eine echte 404-Seite.
- **N+1-Queries behoben:** TechTacular-Board lädt Kategorien/Fragen/Antworten in 2 Queries
(Fetch-Join), Admin-Fragenzähler als eine GROUP-BY-Query.
- **Logging:** alle `System.out`/`printStackTrace` durch SLF4J ersetzt.
- `spring-boot-starter-validation` + `-actuator` ergänzt, redundantes `spring-security-crypto`
entfernt, Java auf 21 vereinheitlicht (pom + Dockerfile).
### Navigation & UI
- **Layout-Fragmente eingeführt** (`fragments/layout.html`): App-Sidebar, Profil-Tab-Leiste und
Footer sind jetzt gemeinsame Fragmente (erster Schritt gegen die Template-Duplikation).
- **Sidebar hat einen „Profil"-Eintrag**; der User-Block verlor den irreführenden Dropdown-Pfeil.
- **Profil-Tab-Leiste auf allen Unterseiten** (Profil · Statistiken · Erfolge · Soziales ·
Einstellungen) mit einheitlichen Namen; Rück-Links führen einheitlich zum Dashboard.
- **Einstellungen aufgeräumt:** Theme-Auswahl funktioniert jetzt wirklich (gekoppelt an
`theme.js`/localStorage); funktionslose Sprach-/Benachrichtigungs-Attrappen entfernt;
`SettingsController` in den `ProfileController` gemergt; Token-Farbe statt Hardcode.
- **Spielnamen vereinheitlicht** (englisch, zusammengeschrieben): DevLab, SyntaxSaga sowie
CodeEscape, ServiceClash, BugHunter, NetWorker für die geplanten Spiele (Keys in
`KNOWN_GAMES`/`ProgressService` mit umbenannt – noch keine Scores vorhanden).
- **DevLab erscheint jetzt auf der Statistik-Seite** (fehlte in `ProgressService.GAMES`).
- Tote Templates entfernt: `login.html`, `register.html` (Formular lebt auf der Startseite),
`AppStartCheck` u. a. Kleinkram.
### FinalFlux
- **AP2-Gerüst:** neue Sidebar-Blöcke „AP2 Anwendungsentwicklung" und „AP2 Systemintegration"
mit den ersten Themenseiten **Entwurfsmuster** (FIAE) und **Hochverfügbarkeit** (FISI) inkl.
FluxChecks – FinalFlux deckt damit den Weg zur gesamten Ausbildung ab (AP1 + AP2).
### Tests (neu – vorher 0)
- 33 Tests: Score-Formel, XP/Level/Streak-Berechnung, Registrierungsregeln, Achievement-Vergabe,
Login-Sperre sowie `@WebMvcTest` der Security-Regeln (Login-Zwang, Admin-Schutz,
gelöschter Legacy-Endpunkt). Die CI baut das Image jetzt inklusive Testlauf.
### Dokumentation
- **`mindfluxfuture.md`:** konsolidierter Verbesserungs- und Ausbaukatalog mit umgesetzten,
bewusst zurückgestellten und künftig geplanten Punkten.
- **`pw_anleitung.md`:** Betriebsanleitung für DB-Secret, automatische Runner-Rotation,
manuellen Notfallweg und Verifikation nach dem Deployment.
## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten
Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und
Konto-Verwaltung – ausgelöst durch einen Login-Bug, bei dem ein frisch
registriertes Konto sich nicht anmelden konnte.
### Behoben
- **Login scheiterte an Groß-/Kleinschreibung des Benutzernamens:** Die Registrierung
speicherte den Namen klein, der Login suchte aber **case-sensitiv** – wer sich z. B. als
„LostAtWonderland" registrierte, kam mit genau dieser Schreibweise nicht hinein. Das wirkte
wie ein Passwort-Problem, war aber der Benutzername-Abgleich (das Passwort lag korrekt
verschlüsselt in der DB). Login **und** Dublettenprüfung sind jetzt **case-insensitiv**
(`findByUsernameIgnoreCase`); Admin-Logins funktionieren unverändert in jeder Schreibweise.
- **Profilbild wurde beim Bearbeiten im Admin genullt:** Das Benutzer-Edit-Formular sendet kein
`profile_picture`, wodurch ein Speichern das Bild löschte. Beim Bearbeiten bleibt das
bestehende Profilbild jetzt erhalten.
- **Dev-Lab-Pfad-Icon „Debugging & Problemlösung" war leer:** Das Symbol `</>` wurde per
`innerHTML` als (kaputtes) HTML interpretiert statt als Text gerendert. Der Marker wird jetzt
escaped (`esc(p.marker)`, Cache-Buster `v4`); alle drei Trainingspfade zeigen ihr Icon
(`{}` · `</>` · `[]`).
### Geändert / Hinzugefügt
- **Registrierung behält die Schreibweise des Benutzernamens** (vorher klein) und trimmt nur noch.
- **Passwort-Wiederholung bei der Registrierung:** neues Feld „Passwort wiederholen" mit Client-
**und** Server-Prüfung; bei Abweichung wird der Account nicht angelegt (Schutz vor Tippfehlern,
da die Passworteingabe verdeckt ist). Eigene Fehlermeldung, die das Signup-Panel direkt öffnet.
- **Admin-Passwort-Reset (ohne E-Mail):** In der Benutzerverwaltung lässt sich pro Nutzer direkt
ein neues Passwort setzen („PW zurücksetzen"); das Bearbeiten-Formular setzt ein eingegebenes
Passwort ebenfalls neu (leer = unverändert). Dient als Konto-Recovery (Phase 1).
### Hinweise / offen
- **„Passwort vergessen" als E-Mail-Self-Service (Phase 2)** ist noch offen: Der Server hat aktuell
keinen Mailversand. Geplant über ein SMTP-Relay (z. B. Brevo/Mailjet, EU/DSGVO) bzw. ein
vorhandenes Postfach, sobald SMTP-Zugangsdaten vorliegen – Reset dann per zeitlich begrenztem
Token-Link. (Ein eigener Mailserver ist wegen Zustellbarkeit/Reputation nicht vorgesehen.)
## [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
Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine Story tippt. Server-autoritativ
nach dem TechTacular-Muster, Inhalte DB-gestützt und im Admin pflegbar. Die Dashboard-Kachel
„Syntax Saga" ist damit aktiv (vorher „coming soon").
### Hinzugefügt
- **Spiel: Syntax Saga (`/syntaxsaga`):** Missions-Saga in drei **wählbaren Schwierigkeitsstufen**
(Junior = eine Lücke, Professional = mehrere Lücken, Senior = ganze Zeile rekonstruieren). Jede
Mission besteht aus Erzähltext + optionalem Dialog + Codeblock mit `{{n}}`-Lücken. Eine Lücke kann
**mehrere gültige Antworten** haben (z. B. `localhost` / `192.168.0.1`) und optional
groß-/kleinschreibungs-unabhängig prüfen (z. B. SQL-Keywords). Eingabe per Freitext; **Prüfung und
Score ausschließlich serverseitig** – die Lösungen verlassen nie das Backend (Anti-Cheat wie bei
TechTacular/MindMatch).
- **Fehler-Mechanik:** pro Lücke bis zu drei Versuche mit Hinweis, danach **„Auflösung anzeigen"**; die
Geschichte läuft immer weiter (**kein Game-Over**). Punkte je nach Versuch (Erstversuch voll, danach
weniger), aufgelöste Lücke = 0 Punkte; Sterne­wertung 0–3.
- **Admin-Verwaltung (`/admin/syntaxsaga`):** Missionen, Lücken und Lösungen anlegen/bearbeiten/löschen,
aktiv/inaktiv schalten. Mitgeliefert: **34 Seed-Missionen** (16 Junior · 10 Professional · 8 Senior,
zusammen 44 Lücken) aus `syntaxsaga/content.json` (idempotenter Seeder, lädt nur in leere Tabellen).
Themen quer durch den Stack: **Java** (Schleifen, OOP/`class`/`this`, Exceptions, Collections,
Operatoren), **SQL** (`WHERE`/`ORDER BY`/`JOIN … ON`/`INSERT`/`UPDATE`/`DELETE`), **Bash**
(`cd`/`grep`/Pipe), **Git** (`commit`), **HTML** (Link/Formular) und **Python** (`print`); Senior
rekonstruiert ganze Zeilen (z. B. `public static void main(String[] args)`,
`Scanner sc = new Scanner(System.in)`) mit ein paar tolerierten Schreibvarianten.
- Spielstände laufen über die bestehende generische `game_scores`-Tabelle (Spiel-Kennung `syntax-saga`),
inkl. Highscore/„Neuer Bestwert" und Statistik im Spiel-HUD.
### Technik
- Neue Entities `SyntaxSagaMission` / `SyntaxSagaGap` / `SyntaxSagaSolution` + Flyway-Migration
`V7__syntax_saga.sql`; server-autoritativer `SyntaxSagaService` (Story ohne Lösungen / serverseitige
Prüfung / Score-Recompute beim Abschluss); JSON-API `/api/syntaxsaga/**` (CSRF-ausgenommen). Frontend
unter `syntaxsaga.html` / `syntaxsaga.js` / `syntaxsaga.css` (Prefix `ss-`, zentrale `theme.css`-Tokens).
## [Unreleased] – 2026-06-29
Inhaltlicher und optischer Feinschliff: echte Rechtstexte, vollständig
+19 -2
View File
@@ -3,17 +3,34 @@ FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
# Dependency-Layer cachen: erst pom.xml auflösen, dann erst den Quellcode kopieren.
COPY pom.xml .
RUN mvn -q dependency:go-offline
RUN mvn clean package -DskipTests
COPY src ./src
# Tests laufen im Build mit – ein roter Test stoppt das Image (CI-Teststufe inklusive).
RUN mvn -q package
# ---- Runtime Stage ----
FROM eclipse-temurin:21-jre
# curl für den Container-Healthcheck (siehe docker-compose.yml)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Nicht als root laufen; Upload-Verzeichnis für den App-User beschreibbar machen.
RUN useradd --system --home /app --shell /usr/sbin/nologin mindflux \
&& mkdir -p /app/uploads \
&& chown -R mindflux:mindflux /app
COPY --from=build /app/target/*.jar app.jar
USER mindflux
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]
+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).*
+9 -2
View File
@@ -5,10 +5,10 @@ services:
restart: unless-stopped
environment:
# --- Datenbank: unverändert wie im bisherigen Deploy ---
# --- Datenbank: Passwort kommt aus Gitea-Secret (siehe Workflow), nie ins Repo ---
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb
SPRING_DATASOURCE_USERNAME: mindflux
SPRING_DATASOURCE_PASSWORD: mindflux_pass
SPRING_DATASOURCE_PASSWORD: "${MINDFLUX_DB_PASSWORD:?MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein}"
# --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) ---
SERVER_PORT: "8080"
@@ -23,6 +23,13 @@ services:
MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me"
MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}"
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
volumes:
- mindflux_uploads:/app/uploads
+506
View File
@@ -0,0 +1,506 @@
# Mindflux – Detailpläne für die 4 geplanten Spiele + Verbesserungsplan
## Kontext
Das Dashboard von Mindflux (`mindflux2`, Spring Boot 3.4.4 / Java 17 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier davon sind nur Platzhalter-Karten, die auf `/comingsoon` (`dummy.html`) zeigen und **keinerlei Mechanik, Controller, Tabellen oder Inhalte** besitzen:
1. **Code Escape Room** – „Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever."
2. **Service Clash** – „Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!"
3. **Bug Hunter** – „Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs."
4. **Netzwerker – Das Router-Rätselspiel** – „Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen."
Die Infrastruktur wartet bereits auf sie: Die Spiel-Keys `escape-room`, `service-clash`, `bug-hunter`, `netzwerker` stehen schon in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`, Zeile 23–25) und in `ProgressService.GAMES` (Zeile 23–26); die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Ziel dieses Plans: detaillierte, umsetzungsreife Designs für alle vier Spiele nach dem etablierten Architektur-Rezept der vorhandenen Spiele (MindMatch, TechTacular, Syntax Saga, Dev Lab) sowie ein priorisierter Verbesserungs-/Optimierungskatalog für den Rest der Plattform.
**Zielgruppe & inhaltlicher Anspruch: die GESAMTE Fachinformatiker-Ausbildung.** Nicht nur AP1 – abgedeckt werden muss die komplette Ausbildung bis einschließlich **AP2** (beide Fachrichtungen: Anwendungsentwicklung/FIAE und Systemintegration/FISI). Konsequenzen für alle Spiele und Inhalte:
- **Schwierigkeitsstufen = Ausbildungsprogression.** Die in allen vier Designs vorhandenen Stufen (Junior/Senior/Principal, leicht/mittel/schwer, Welten 1–3, Schicht 1–3) werden inhaltlich gemappt: untere Stufen ≈ 1. Lehrjahr/AP1-Stoff, obere Stufen ≈ AP2-Niveau. Kein Schema-Umbau nötig – die `difficulty`-Felder existieren in allen Datenmodellen bereits; es ist eine **Content-Kuratierungs-Regel**.
- **AP2-Content pro Spiel (Startrichtung):** CodeEscape → Szenarien zu Verschlüsselung/Zertifikaten, Virtualisierung/Container, Backup-Strategien; BugHunter → Principal-Fälle auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Lücken); ServiceClash → Schichten mit Change-Management, Monitoring/Eskalationsketten, Security-Incidents (FISI-AP2); NetWorker → Welten zu VLAN, statischem Routing über mehrere Hops, Firewall-Regeln und (Ausbau) IPv6/Subnetting-Transfer.
- **Content-Tagging:** Beim Kuratieren jedes Inhalts (Seeder-JSONs, Admin-Eingaben) wird vermerkt, ob er AP1-, AP2/FIAE- oder AP2/FISI-relevant ist – zunächst als Konvention im Titel/`description` (z. B. Präfix „[AP2]"), später optional als eigenes Feld + Filter in den Lobbys („Zeige nur AP2-Stoff").
- **FinalFlux erweitern:** Das bestehende Lernmodul ist rein AP1-fokussiert (28 Seiten + FluxChecks). Die Struktur (statische Themenseiten + Frage-JSONs) trägt problemlos einen **AP2-Ast** – als eigener Sidebar-Block „AP2 Anwendungsentwicklung" / „AP2 Systemintegration" (in Teil 2 als Punkt S10 aufgenommen).
- Die vorhandenen FinalFlux-Inhalte (IP-Berechnung, VLAN, OSI, Wirtschaft) bleiben die AP1-Basis, auf der die Spiele aufsetzen.
**Deliverable:** Dieser Plan liegt als `mindflux2/mindfluxfuture.md` im Repo (neben `STYLEGUIDE.md`/`CHANGELOG.md`).
---
## Namensschema: einheitlich, englisch, zusammengeschrieben
> ✅ **Umgesetzt am 2026-07-12**: Anzeigenamen und Spiel-Keys sind auf das Schema umgestellt (Dashboard-`GAMES`-Array, `KNOWN_GAMES`, `ProgressService`).
Vorgabe: Alle Spielnamen folgen dem MindMatch-Muster – **ein Wort, englisch, CamelCase**. „Netzwerker – Das Router-Rätselspiel" passt nicht ins Schema und wird umbenannt. Neue Namenstabelle (Beschreibungen bleiben deutsch):
| Bisher | Neu (Anzeigename) | Route | Spiel-Key (DB/API) |
|--------|-------------------|-------|--------------------|
| MindMatch | **MindMatch** ✓ | `/mindmatch` | `mindmatch` |
| TechTacular | **TechTacular** ✓ | `/techtacular` | `techtacular` |
| Dev Lab | **DevLab** | `/devlab` | `devlab` |
| Syntax Saga | **SyntaxSaga** | `/syntaxsaga` | `syntax-saga` (Key behalten – es existieren bereits Scores) |
| FinalFlux | **FinalFlux** ✓ | `/finalflux` | – |
| Code Escape Room | **CodeEscape** | `/codeescape` | `codeescape` |
| Service Clash | **ServiceClash** | `/serviceclash` | `serviceclash` |
| Bug Hunter | **BugHunter** | `/bughunter` | `bughunter` |
| Netzwerker – Das Router-Rätselspiel | **NetWorker** | `/networker` | `networker` |
Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben (`/mindmatch`, `/syntaxsaga` …) – die neuen Routen folgen dem. Die vier reservierten Keys (`escape-room`, `service-clash`, `bug-hunter`, `netzwerker`) haben **noch keine Scores in der DB** und werden daher jetzt kostenlos auf das einheitliche Schema umbenannt (`codeescape`, `serviceclash`, `bughunter`, `networker`) – zu ändern in `GameScoreController.KNOWN_GAMES`, `ProgressService.GAMES` und den jeweiligen Service-`GAME`-Konstanten. Nur `syntax-saga` behält seinen Key (Bestandsdaten), Anzeigename wird trotzdem `SyntaxSaga`. Anzeigenamen anpassen in: `dashboard.html`-`GAMES`-Array, `ProgressService.GAMES`, Seitentiteln/Templates der neuen Spiele. _Alle nachfolgenden Abschnitte verwenden bereits die neuen Namen; wo im Text noch V9–V12-Design-Details Alt-Keys nennen, gilt die Tabelle hier._
---
## Visuelle Identität: vier Spiele, vier Welten
> ☐ **Noch offen** – wird mit dem Bau der Spiele umgesetzt; auch die Spiel-Akzent-Tokens in `theme.css` sind noch nicht angelegt.
Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr deutlich** voneinander unterscheiden – nicht nur mechanisch, sondern auf den ersten Blick. Dazu zwei Maßnahmen:
**1. Kontrollierte Styleguide-Erweiterung – Spiel-Akzente:** `theme.css` bekommt pro Spiel ein Akzent-Token-Paar (z. B. `--esc-accent`, `--bh-accent`, `--sc-accent`, `--nw-accent`, je mit Dark-/Light-Wert). Jedes Spiel-CSS überschreibt im Seiten-Root lokal `--accent`/`--glow-*` mit seinem Spiel-Akzent. Die Grundregeln bleiben: Erfolg = blaues ✓, Fehler = rotes ✗, nie grün als Erfolgsfarbe; beide Themes müssen funktionieren.
**2. Eigenes visuelles Vokabular pro Spiel** (Layout, Illustration, Animation, Typografie-Akzente):
| Spiel | Welt-Metapher | Farbstimmung | Prägende visuelle Elemente |
|-------|---------------|--------------|---------------------------|
| **CodeEscape** | Dunkle Facility bei Nacht | Bernstein/Orange auf Tiefschwarz | CRT-Terminals mit Scanline-Overlay, physisches Keypad mit Tasten-Feedback, Raum-„Bühnenbild" als SVG-Illustration (Racks, Türen, flackernde Neonröhre), Fragment-Chips wie Hardware-Token, Alarm-Puls bei Zeitablauf, Tür-Öffnungs-Animation zwischen Räumen |
| **BugHunter** | Noir-Detektivbüro | Entsättigt + Crimson-Akzente | Fallakten-Optik (Aktendeckel, Büroklammer, „VERTRAULICH"-Stempel), IDE-Look mit Zeilennummern-Gutter und Tab-Reitern, roter Ermittlungsfaden zum markierten Bug, „GELÖST"-Stempel-Animation im Debriefing, Schreibmaschinen-Typo für Briefings |
| **ServiceClash** | Hektischer Leitstand/Cockpit | Kühles Cyan + Warnrot | Kanban-artige Ticket-Queue mit einfliegenden Karten, große SLA-Ring-Gauges und Gesundheits-Meter, laufender Event-Ticker unten, Wellen-Countdown-Banner, komisch überzeichnete Ticket-Icons (brennender Drucker 🔥), Screen-Shake bei Major Incidents |
| **NetWorker** | Technische Blaupause | Blueprint-Blau auf Millimeterpapier | Raster-Hintergrund wie Konstruktionszeichnung, Geräte als Schematic-Glyphen, Gummiband-Kabelzug, leuchtende Paket-Punkte, die den echten Trace entlangfliegen und am Fehler-Hop rot zerplatzen, Diagnose-Callouts wie Plan-Anmerkungen |
Dazu je Spiel ein eigenes Karten-Design auf dem Dashboard (Hover-Animation im jeweiligen Spiel-Akzent), damit die Unterscheidbarkeit schon in der Übersicht beginnt. Die Interaktionsformen sind ohnehin grundverschieden: Modal-Rätsel + Keypad (CodeEscape), klickbarer Code-Viewer (BugHunter), Echtzeit-Queue (ServiceClash), frei bespielbare SVG-Canvas (NetWorker).
---
## Das gemeinsame Architektur-Rezept (gilt für alle 4 Spiele)
Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: **Dev Lab**, `DevLabService.java` + `devlabgame.js`):
1. **Flyway-Migration** `db/migration/V9__…` ff. (V8 ist die letzte; `ddl-auto=validate` ⇒ Migration ist Pflicht). SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes.
2. **Entities** in `model/` (Parent mit `@OneToMany(cascade=ALL, orphanRemoval=true)`, Lösungs-Logik nur serverseitig auf der Entity).
3. **Repository** in `repository/` (`findAllByActiveTrueOrderBySortOrderAscIdAsc()`, `findBySlug`, `countBy…Id`).
4. **Service** in `service/` mit Konstante `GAME = "<key>"`, injiziert `GameScoreService`; `@Transactional(readOnly=true)` für Build/Check, `@Transactional` für `finish`. **Score wird ausschließlich serverseitig berechnet** (`gameScoreService.recordScore(user, GAME, deck, score, moves, seconds)`); Client sendet nur Roh-Antworten.
5. **Punkte-Konventionen der Plattform:** 1. Versuch volle Punkte, 2. Versuch ×0.6, danach ×0.3, aufgedeckte Lösung = 0; Sterne: Quote ≥0.8 → 3, ≥0.5 → 2, sonst 1; `MAX_ATTEMPTS = 3`.
6. **DTOs** in `dto/`: lösungsfreie View-DTOs, Request-DTOs, `*Result` (score, maxScore, solved, total, stars, highscore, newHighscore, plays).
7. **Controller:** `@Controller` (`GET /<spiel>` → Thymeleaf-View) + `@RestController @RequestMapping("/api/<spiel>")` mit `GET /session|board`, `POST /check` (mit `reveal`-Flag), `POST /finish`; `Principal` für den Usernamen.
8. **Seeder** (`CommandLineRunner` in `config/`, Guard `count() > 0`, liest `resources/<spiel>/content.json`).
9. **Admin-CRUD** `@Controller @RequestMapping("/admin/<spiel>")` mit Redirect-after-Post + Karte in `templates/admin/dashboard.html`.
10. **Frontend:** Template in `templates/`, eigenes CSS mit eindeutigem Präfix (nur `theme.css`-Tokens, Erfolg = blaues ✓, Fehler = rotes ✗, **nie grün**), JS als IIFE in `static/js/`, Highscore via `GET /api/games/<key>/stats`, Cache-Busting `?v=N`.
11. **Integration:** Route in `SecurityConfig`-CSRF-Ignore-Liste aufnehmen, Dashboard-Karte in `dashboard.html` von `/comingsoon` auf die echte Route umstellen. (`KNOWN_GAMES` + `ProgressService` enthalten die Keys bereits.)
---
## Teil 1: Spieldesigns
### Spiel 1: Code Escape Room (Key: `escape-room`, Migration V9)
**Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** = ein Durchlauf mit 3 Räumen in fester Reihenfolge; jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment** (Ziffern/Hex-Chunk in einer Inventar-Leiste); die Fragmente ergeben zusammen den Türcode zum nächsten Raum. Countdown erzeugt Zeitdruck; ein 2-stufiges Hinweis-System tauscht Punkte gegen Hilfe.
**Lernziele (AP1 → AP2):** Zahlensysteme (Bin/Hex/Dez), Ports & Protokolle, Subnetting, TCP-Handshake/Boot-Reihenfolgen, RAID, Hardware, SQL/Shell-Grundlagen – als Rätsel verpackt statt als Quiz. Schwere Szenarien tragen AP2-Stoff (Verschlüsselung/Zertifikate, Virtualisierung/Container, Backup-Strategien).
**Spielablauf:**
1. **Lobby:** Szenario-Karten (Titel, Thema, Schwierigkeit, Zeitlimit, persönliche Bestzeit).
2. **Raum-Ansicht:** Rätsel-Karten im Terminal-Look, Fragment-Inventar, Tür-Keypad. Rätsel öffnen sich im Modal.
3. **Rätsel-Versuch:** `MAX_ATTEMPTS = 3` wie Dev Lab; danach „Auflösen"-Button. Bei richtig **oder** aufgedeckt liefert der Server das Code-Fragment – Aufdecken blockiert nie den Fortschritt, es nullt nur die Punkte (Plattform-Konvention).
4. **Tür:** Spieler tippt den zusammengesetzten Code; Server validiert (`POST /door`). Falsch → rotes Blinken, keine Punktstrafe (die laufende Uhr ist die Strafe).
5. **Flucht/Ergebnis:** `POST /finish` rechnet alles serverseitig nach; Ergebnis-Screen mit Score-Aufschlüsselung (Rätselpunkte, Türboni, Hinweis-Abzug, Zeitbonus), Sternen, Highscore. „Aufgeben"-Button beendet früher (kein Zeitbonus, `escaped=false`).
**Punkteformel (serverseitig in `/finish` nachgerechnet):**
- Pro Rätsel: `punkte × versuchsfaktor × hinweisfaktor` (Versuch: 1.0/0.6/0.3, aufgedeckt 0; Hinweis: keiner ×1.0, Stufe 1 ×0.8, Stufe 2 ×0.6). Hinweis-Nutzung wird **serverseitig in der HttpSession** protokolliert (der `/hint`-Endpoint merkt sich die höchste gelieferte Stufe pro Rätsel) – der Client kann sie nicht unterschlagen. _(Einzige bewusste Abweichung vom sonst zustandslosen Muster.)_
- Pro geöffneter Tür: pauschal **+50**.
- Sterne aus `baseScore/maxScore` (≥0.8→3, ≥0.5→2). **Zeitbonus danach**, nur bei Flucht: `max(0, round(500 × (zeitlimit − sekunden) / zeitlimit))` – die MindMatch-Idee, auf 15-Minuten-Läufe skaliert. Timer auf 0 bricht nicht ab: HUD wechselt in „ALARM"-Zustand (pulsierendes `var(--danger)`), der Bonus ist nur weg.
- `recordScore(user, "escape-room", szenarioSlug, baseScore + zeitbonus, gelösteRätsel, sekunden)`.
**Datenmodell (`V9__escape_room.sql`):** 3 Tabellen im V8-Stil:
- `escape_scenarios` (id, slug UNIQUE, title, difficulty, description, time_limit_seconds INT DEFAULT 900, sort_order, active)
- `escape_rooms` (id, scenario_id FK CASCADE, title, intro, door_prompt, **door_code VARCHAR(50)**, success_text, sort_order)
- `escape_puzzles` (id, room_id FK CASCADE, puzzle_type VARCHAR(30), prompt, points INT DEFAULT 100, **code_fragment VARCHAR(20)**, sort_order, payload VARCHAR(8000))
- Indizes auf beide FKs. `code_fragment`/`door_code` als Spalten (nicht im Payload), damit Check/Door sie ohne JSON-Parsing haben und der Admin sie auf einen Blick sieht. Hinweise liegen im Payload als `"hints": [...]`.
**Backend:** Entities `EscapeScenario`/`EscapeRoom`/`EscapePuzzle` (Struktur wie `DevLabGame`/`DevLabTask`, LAZY, `addRoom`/`addPuzzle`-Helper). Repositories mit den Standard-Derived-Queries. `EscapeRoomService` mit `KNOWN_TYPES = Set.of("code","choice","order","match")`:
- `buildCatalog()` / `buildSession(slug)` – Szenario mit **allen** Räumen, Rätsel per `sanitize()` lösungsfrei (keine Lösungen, Fragmente, Hints, Türcodes). Raum-Sperre ist Client-Gate, abgesichert durch Server-Türvalidierung.
- `check(puzzleId, reveal, submission)` – Typ `code`: neuer trivialer Checker via `anyEquals(payload.solutions, answer, caseSensitive)`; **`choice`/`order`/`match`: Checker 1:1 aus `DevLabService` kopieren.** Bei `correct || reveal` kommt `"fragment"` in die Antwort.
- `openDoor(roomId, code)`, `hint(session, puzzleId, level)`, `finish(...)` wie oben; plus Admin-Methoden nach Dev-Lab-Vorbild.
**API (`/api/escape-room`):** `GET /scenarios`, `GET /session/{slug}`, `POST /check` (`{puzzleId, reveal, submission:{answer}}` → `{correct, fragment, successText}`), `POST /door` (`{roomId, code}` → `{open, successText}`), `POST /hint` (`{puzzleId, level}` → `{text, level}`), `POST /finish` (`{slug, durationSeconds, escaped, items:[{puzzleId, submission, attempts, revealed}], doors:[{roomId, code}]}` → `{score, baseScore, maxScore, timeBonus, solvedPuzzles, roomsOpened, escaped, stars, highscore, newHighscore, plays}`).
**Frontend:** `templates/escape-room.html`, `static/js/escaperoom.js` (IIFE-State-Machine `LOBBY → INTRO → ROOM(i) → PUZZLE_MODAL → DOOR_TRY → TRANSITION → … → RESULT`), `static/css/escaperoom.css` mit Präfix `esc-`. **Die `choice`/`order`/`match`-Renderer aus `devlabgame.js` (Vertrag `{render, collect, lock, mark}`) portieren**, nur ein `code`-Renderer (Terminal-Artefakt + Textfeld) kommt neu dazu. Layout: HUD oben (Raumtitel, Raum-Punkte ● ● ○, Timer-Chip), Mitte Rätselkarten, unten Fragment-Chips + Keypad-Button. Nur Theme-Tokens, Erfolg blau ✓, Fehler rot ✗.
**Content & Seeder:** `EscapeRoomSeeder` liest `resources/escaperoom/content.json`. Startumfang: **3 Szenarien** (leicht/mittel/schwer) × 3 Räume × 2–3 Rätsel ≈ 22–25 Rätsel. Themen: „Der verlassene Serverraum" (Netzwerk/Zahlensysteme), „Lockdown im Rechenzentrum" (Hardware/RAID/USV), „Das gehackte Büro" (Security/Passwörter/Verschlüsselung). Beispiel-Rätsel (ausgearbeitet im Design): PIN = Dezimalwert von `0b101001` → „41", SSH-Port-Choice, Dienst↔Port-Matching (Türcode „427"), TCP-Handshake-Reihenfolge, nutzbare Hosts im /26-Netz → „62" (Tresorcode „3E62").
**Admin:** `AdminEscapeRoomController` (`/admin/escape-room`), 3 Drilldown-Templates (Szenario-Liste → Szenario mit Räumen → Raum mit Rätseln; Rätsel-Payload als JSON-Textarea wie Dev Lab).
**Aufwand:** MVP ≈ 5,5 PT (Migration/Entities 0,5 · Service 1,5 · API 0,5 · Frontend 2 · Content 0,5 · Admin+Integration 0,5). Ausbau 1 (+1,5 PT): Bestzeiten in der Lobby, „Perfekter Lauf"-Badge. Ausbau 2 (+1–2 PT): Rätseltypen `regex`/`cloze` (Checker existieren wortgleich in `DevLabService`), 2 weitere Szenarien.
---
### Spiel 2: Bug Hunter (Key: `bug-hunter`, Migration V10)
**Konzept:** Der Spieler ist Debugging-Detektiv und arbeitet **Fallakten** ab. Ein Fall („Hunt") ist ein Mini-Projekt aus 2–4 Dateien, das sich falsch verhält; Beweismittel sind simulierte Konsolen-Ausgabe / Stacktrace / Ticket-Text. Pro Bug eine Drei-Phasen-Schleife: **ORTEN** (fehlerhafte Zeile im Tab-Code-Viewer anklicken) → **KLASSIFIZIEREN** (Syntax / Logik / Laufzeit / Sicherheit) → **FIXEN** (korrigierte Zeile aus 4 Kandidaten wählen). Saubere Bugs bauen eine **Combo-Serie** auf. Stufen: Junior / Senior / Principal.
**Abgrenzung zu Dev Lab:** Dev Labs `spot`-Typ ist „finde die eine falsche Zeile". Bug Hunter ist bewusst reicher: Multi-Datei-Navigation, beweisgetriebenes Denken (Stacktrace lesen → Zeile zuordnen), explizite Fehler-Taxonomie (Prüfungsthema!) und Fix-Auswahl – das vollständige Debugging-Denkmodell. Junior-Fälle decken AP1-Stoff ab; Senior/Principal-Fälle gehen auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Sicherheitslücken).
**Punkteformel (serverseitig in `/finish`):**
- Phasengewichte pro Bug: Orten 40 %, Klassifizieren 20 %, Fixen 40 %. Phasen-Award = `gewicht × punkte × versuchsfaktor` (1.0/0.6/0.3, aufgedeckt 0).
- Sterne aus `baseScore/maxScore` (Genauigkeitsmaß, ohne Combo).
- **Combo:** Ein Bug ist _sauber_, wenn alle 3 Phasen im 1. Versuch ohne Aufdecken gelingen. Serienposition n bringt `(min(n,4) − 1) × 10 % × punkte` Bonus (2. saubere in Folge +10 %, … 4.+ +30 %); Bonus wird **nach** der Sternberechnung addiert. Kein Timer – Bug Hunter belohnt Präzision, Zeitdruck gehört dem Escape Room.
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bug-hunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
**Datenmodell (`V10__bug_hunter.sql`):**
- `bughunter_hunts` (id, slug UNIQUE, title, code_language, difficulty DEFAULT 'junior', description, briefing VARCHAR(2000), console_output VARCHAR(4000), sort_order, active)
- `bughunter_files` (id, hunt_id FK CASCADE, filename, content VARCHAR(8000), sort_order) – eigene Tabelle, damit der Code-Viewer-Inhalt von allen Bugs geteilt wird und im Admin als Textarea editierbar ist
- `bughunter_bugs` (id, hunt_id FK CASCADE, points DEFAULT 100, sort_order, payload VARCHAR(8000)) – Payload: `{"file","line","category","evidence","fixOptions":[4],"fixIndex","explanation"}`
- Indizes auf beide FKs.
**Backend:** Entities/Repos nach Dev-Lab-Muster. `BugHunterService` mit `CATEGORIES = Set.of("syntax","logik","laufzeit","sicherheit")`, Gewichten `0.4/0.2/0.4`:
- `buildSession(slug)`: Briefing + `consoleOutput` + Dateien als `[{filename, lines:[…]}]` (**serverseitig in Zeilen gesplittet**, damit Zeilenindizes kanonisch sind) + Bugs sanitisiert: nur `{bugId, points, evidence}` – **keine Zeile, keine Kategorie, keine Fix-Optionen**.
- `checkPhase(bugId, phase, reveal, submission)`: `locate` vergleicht file+line, liefert bei Erfolg/Aufdecken `location` zurück; `classify` vergleicht die Kategorie und liefert bei Auflösung erst die **gemischten `fixOptions`** (Phasen-Gate: der Fix-Text würde sonst die Position verraten); `fix` prüft die Wahl (Logik = Dev Labs `checkChoice`) und liefert `fixedLine` + `explanation` fürs Debriefing.
- `finish(...)`: prüft alle Phasen aus den Roh-Antworten neu, Gewichte + Versuchsfaktoren, Streak/Combo, Sterne, `recordScore`.
**API (`/api/bug-hunter`):** `GET /hunts`, `GET /session/{slug}`, `POST /check` (`{bugId, phase, reveal, submission}`), `POST /finish` (`{slug, durationSeconds, items:[{bugId, locate:{file,line,attempts,revealed}, classify:{category,…}, fix:{choice,…}}]}` → `{score, baseScore, maxScore, comboBonus, maxCombo, bugsSolved, totalBugs, stars, highscore, newHighscore, plays}`).
**Frontend:** `templates/bug-hunter.html`, `static/js/bughunter.js` (State-Machine `LOBBY → BRIEFING → HUNT {pro Bug: LOCATE → CLASSIFY → FIX → DEBRIEF} → RESULT`), `static/css/bughunter.css` Präfix `bh-`. Layout: **links 60 %** Datei-Tabs + Monospace-Code-Viewer mit Zeilennummern-Gutter (Zeilenklick nur in LOCATE aktiv); **rechts 40 %** Beweis-Karte (Terminal-Style `consoleOutput`, Briefing, Bug-Hinweis), Aktions-Panel (wechselt pro Phase; Kategorie-/Fix-Buttons nutzen das `choice`-Renderer-Muster aus `devlabgame.js`), Combo-Chip + Fortschritt („Bug 2/3"). Debriefing tauscht die Bug-Zeile sichtbar gegen `fixedLine` (blaues Diff-Highlight).
**Content & Seeder:** `BugHunterSeeder` liest `resources/bughunter/content.json`; der komplette Bug-Knoten wird wie bei `DevLabSeeder` per `toString()` zum Payload. Startumfang: **6 Fälle** – 3 Junior (1 Datei, 1–2 Bugs: Off-by-one, `=` vs. `==`, fehlendes `break`), 2 Senior (2 Dateien: NPE via Stacktrace, SQL-Injection, Integer-Division), 1 Principal (3 Dateien, Symptom in Datei A, Ursache in Datei B) ≈ 13–15 Bugs. Drei Beispiel-Fälle sind im Design voll ausgearbeitet („Fall #1: Die falsche Rechnung" – Off-by-one in Java; „Fall #2: Das offene Login" – SQL-Injection in PHP; „Fall #3: Der Absturz um Mitternacht" – NPE mit Stacktrace-Lesen), inkl. Code, Fix-Optionen und Erklärungen.
**Admin:** `AdminBugHunterController` (`/admin/bug-hunter`), 2 Templates (Fall-Liste + Fall-Detail mit Datei-Textareas und Bug-Payload-JSON). Service validiert beim Speichern, dass `file` im Fall existiert und `line` im Zeilenbereich liegt.
**Aufwand:** MVP ≈ 4,5 PT. Ausbau 1 (+1 PT): Freitext-Fix statt Optionen (`anyEquals` mit Whitespace-Normalisierung wie Dev-Lab-`cloze`), „Sauber gelöst"-Badge. Ausbau 2 (+1 PT): „Blitzjagd"-Tagesmodus (3 zufällige Junior-Bugs, 90-s-Soft-Timer).
---
### Spiel 3: Service Clash (Key: `service-clash`, Migration V11)
**Konzept: „Deine Schicht am Service Desk."** Der Spieler ist Junior-Admin im First-Level-Support. Tickets treffen auf einer Zeitleiste ein; jedes muss **eingeordnet** (Incident vs. Service Request), **priorisiert** (Hoch/Mittel/Niedrig anhand von Impact-×-Dringlichkeit-Hinweisen im Tickettext) und **gelöst** werden (richtige Erstmaßnahme aus 3–4 Optionen) – bevor sein SLA-Timer abläuft. Unbehandelte Tickets beschädigen die „Systemgesundheit"; bei 0 endet die Schicht vorzeitig.
**Lernziele (AP1 → AP2):** Incident-/Service-Request-Klassifikation (ITIL-light, explizites IHK-Thema), Prioritätsbewertung, korrekte Erstmaßnahmen (Troubleshooting-Reihenfolge, Eskalationsdisziplin, Phishing erkennen statt „lösen"). Höhere Schichten bringen FISI-AP2-Stoff: Change Management, Monitoring-/Eskalationsketten, Security-Incident-Behandlung, Backup-/Restore-Entscheidungen.
**Spielablauf:** Eine **Schicht** = ein Level mit 3 Wellen (Vormittag/Mittag/Nachmittag), je 60–90 Echtzeit-Sekunden mit steigender Spawn-Rate; Gesamtspielzeit 3–5 Minuten, zwischen Wellen 5 s Verschnaufpause. Pro Ticket im Detail-Panel:
1. **Einordnung** [Incident]/[Service Request] – Single-Shot, falsch = 0 Punkte für diesen Anteil, richtige Antwort wird sofort gezeigt (Lerneffekt). 20 % der Ticket-Basispunkte.
2. **Priorität** [Hoch]/[Mittel]/[Niedrig] – Single-Shot. 30 %.
3. **Lösung** – 3–4 Aktionen (servergemischt), Plattform-Versuchsleiter 1.0/0.6/0.3; nach dem 3. Fehlgriff Auflösung + Erklärung, Ticket zählt als „eskaliert" (0 für diesen Anteil). 50 %.
**SLA & Gesundheit:** `slaSeconds` je Priorität. In SLA gelöst → ×1.0, verspätet → ×0.5, nie gelöst → 0 + Gesundheitsschaden. Start 100; SLA-Bruch −10 (Hoch: −20), ungelöst am Schichtende −15. Endbonus: `gesundheit × 2` (max. 200).
**Punkteformel (nur serverseitig):**
```
ticketPunkte = B × (0.2·einordnungOk + 0.3·prioOk + 0.5·lösungsLeiter) × slaFaktor
score = Σ ticketPunkte + gesundheit×2 (B: Routine 60, Standard 100, Major Incident 150)
maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
```
**Der Server-autoritäts-Trick für Echtzeit (Kernidee):** Runden bleiben zustandslos –
- `GET /session/{slug}`: Server zieht einen **Seed**, generiert daraus deterministisch den Schichtplan (Ticket-Auswahl aus dem Pool, Spawn-Zeitpunkte, Options-Mischung; Payloads sanitisiert ohne Lösungsfelder) und liefert Plan + Seed.
- Jeder Klick geht an `POST /check` (sofortiges ✓/✗-Feedback; Lösungen verlassen den Server nie).
- `POST /finish` sendet `{slug, seed, decisions[], durationSeconds}`; der Server **regeneriert die Schicht aus dem Seed und re-simuliert das Entscheidungslog** (Plausibilitätschecks: Zeitstempel ≥ Spawn-Offset, ≤ Schichtdauer, monoton pro Ticket) und berechnet Gesundheit, SLA-Faktoren und Score neu. Härtungs-Ausbau: HMAC-signierter Seed.
**Datenmodell (`V11__service_clash.sql`):**
- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active)
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt).
**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild.
**API (`/api/service-clash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`.
**Frontend:** `templates/service-clash.html`, `static/js/serviceclash.js` (State-Machine `SHIFT_SELECT → BRIEFING → RUNNING ⇄ WAVE_BREAK → DEBRIEF`; **ein** `setInterval(250 ms)`-Tick: fällige Tickets spawnen, SLA-Ringe aktualisieren), `static/css/service-clash.css` Präfix `sc-`. Layout: Topbar (Schicht-Uhr, Wellen-Pips, Gesundheits-Meter, „Gelöst 4/8"), links die Ticket-Queue (Karten mit SLA-Ring, pulsierender roter Rand bei Überfälligkeit), Mitte das aktive Ticket mit 3-Stufen-Flow, unten ein Event-Log („14:02 Ticket ‚ERP down' eingegangen"). SLA-Ringe: `var(--accent)`, unter 25 % `var(--danger)`.
**Schwierigkeitskurve:** Schicht 1 „Erster Arbeitstag" (8 Tickets, großzügige SLAs, Prio-Stufe deaktiviert), Schicht 2 „Normalbetrieb" (12 Tickets, Prio aktiv, erster Phishing-Köder), Schicht 3 „Patch Day Chaos" (16 Tickets, knappe SLAs, Major Incident mit Folgetickets in Welle 3). **Replay-Wert:** Ticket-Pool größer als `spawnCount` → jede Runde ein anderer Mix (Seed).
**Content & Seeder:** `ServiceClashSeeder` liest `resources/service-clash/content.json`. Startumfang: **3 Schichten × 18–20 Ticket-Vorlagen ≈ 55 Tickets.** Vier Beispiel-Tickets sind voll ausgearbeitet (Drucker-Queue/Incident-mittel, Azubi-Onboarding/Service-Request-niedrig, ERP-Totalausfall/Major-Incident-hoch mit Sofort-Eskalation, Phishing-Mail mit verdächtiger Absenderdomain).
**Admin:** `AdminServiceClashController` (`/admin/service-clash`), Schicht-Liste + Detailseite (Schicht-Formular inkl. Settings-JSON, Ticket-Tabelle, Payload-JSON-Textarea mit Schema-Hinweis; Speichern validiert `correctIndex` gegen `options`).
**Aufwand:** MVP ≈ 7,5 PT (Service inkl. Seed-Plan + Re-Simulation 2 · Frontend 3 · Content 1 · Admin 1 · Rest 0,5+0,5). Ausbau (+3 PT): Major-Incident-Folgeketten, Combo-Serien, HMAC-Seed, Endlosmodus „Überstunden".
---
### Spiel 4: Netzwerker – Das Router-Rätselspiel (Key: `netzwerker`, Migration V12)
**Konzept:** Netzwerk-Puzzle in der Tradition von Schaltkreis-Spielen. Jedes Level zeigt eine **SVG-Topologie** (PCs, Switch, Router, Server, Cloud). Geräte haben fehlende oder kaputte Konfiguration (IP, Maske, Gateway, Kabel, statische Routen). Ziel: alle **Ping-Ziele** erfüllen („PC1 muss SRV1 erreichen", später auch negativ: „Gast-PC darf den Server NICHT erreichen"). „Pings testen" ruft einen **serverseitigen Netzwerk-Simulator**, der jeden Ping Hop-für-Hop auflöst und Pass/Fail plus didaktische Diagnose liefert („Das Paket verlässt PC1 nicht: Laut Maske liegt das Ziel im eigenen Netz – stimmt die Maske?").
**Lernziele (AP1-Kernstoff → AP2, passt zu vorhandenem FinalFlux-Content IP-Berechnung/VLAN):** IPv4-Adressierung, Subnetzmasken/CIDR, Netz-/Broadcast-Adressen, Gateway-Logik, doppelte IPs, statische Routen; auf AP2-Niveau (FISI): VLAN-Segmentierung mit Negativ-Zielen, Routing über mehrere Hops, Firewall-Regeln, im Ausbau IPv6.
**Spielablauf:** Session = eine **Welt** aus 4–6 Leveln in Folge (Dev-Lab-Session-Form). Fünf Level-Typen bei einheitlichem Datenmodell: `assign` (leere IP/Maske/Gateway-Felder ausfüllen), `cable` (fehlende Verbindungen ziehen), `fix` (1–3 eingebaute Fehler finden: falsche Maske, Gateway außerhalb des Subnetzes, doppelte IP, Host auf Netzadresse), `route` (statische Routen-Zeilen: Zielnetz-CIDR + Next Hop), `vlan` (Ausbau, mit Negativ-Zielen).
**Versuche & Punkte:** Jeder „Pings testen"-Klick mit mindestens einem roten Ziel verbraucht einen Versuch (1.0/0.6/0.3, `MAX_ATTEMPTS=3`, danach „Lösung anzeigen" = 0 Punkte). **Bewertet wird ausschließlich per Simulation, nie per Exact-Match** – viele Konfigurationen sind gültig (jede freie Host-IP funktioniert); die Musterlösung dient nur dem Aufdecken. Levelpunkte 100–200; Sterne am Weltende nach Konvention; `/finish` re-simuliert die final eingereichten Konfigurationen serverseitig. `recordScore(user, "netzwerker", weltSlug, score, gelöste, sekunden)`.
**Datenmodell (`V12__netzwerker.sql`):**
- `netzwerker_worlds` (id, slug UNIQUE, title, description, difficulty INT, sort_order, active)
- `netzwerker_levels` (id, world_id FK CASCADE, title, level_type VARCHAR(30) /* assign|cable|fix|route|vlan */, points INT DEFAULT 100, sort_order, payload VARCHAR(8000) /* JSON-Topologie inkl. Musterlösung */) + FK-Index
**Der Simulator – `service/NetSimulator.java` (Herzstück, pures Java ohne Spring, voll unit-testbar):**
- Records: `Net(nodes, links, routes, vlans)`, `NodeCfg`, `IfaceCfg(ipLong, prefix, gatewayLong)`, `Route(destNet, prefix, nextHop)`.
- `ping(net, from, to)`: (1) Validierung: unparsebare IP/Maske, Host-Teil 0/alles-1 → `network_address`/`broadcast_address`, doppelte IP im L2-Segment → `duplicate_ip`. (2) Gleiches Subnetz aus Quellsicht (`(ip & maske) == (ziel & maske)`) → L2-BFS über Links durch Switches (nie durch Router; VLAN-gefiltert). (3) Anderes Subnetz → Gateway nötig: gesetzt? im Quell-Subnetz? L2-erreichbar? Dann leitet der Router weiter: Ziel in direkt verbundenem Subnetz → zustellen; sonst statische Routen (Longest-Prefix-Match) → Next Hop → Rekursion mit TTL 8 (`routing_loop`-Schutz). Kein Treffer → `no_route`.
- Fehlergründe werden im Service auf didaktische deutsche Meldungen gemappt.
**`NetzwerkerService`:** `buildCatalog()`, `buildSession(slug)` (Level per `sanitizeLevel()`: Werte editierbarer Felder, zu ziehende Links, Routen-Lösungszeilen und `solution`-Block entfernt; gesperrte Werte, Positionen, Ziele, Hints gehen mit), `check(levelId, reveal, config)` (`mergeSubmission()` legt eingereichte Werte **nur über editierbare Felder** – Manipulation gesperrter Felder wird ignoriert; alle Ziele durch `NetSimulator.ping`; Antwort mit Trace/Diagnose pro Ziel), `finish(...)`, Admin-CRUD **plus Qualitäts-Gate beim Speichern: die hinterlegte Musterlösung wird durch den Simulator geschickt – Speichern wird abgelehnt, wenn sie nicht alle Ziele erfüllt.**
**API (`/api/netzwerker`):** `GET /catalog`, `GET /session/{slug}`, `POST /check` (`{levelId, reveal, config:{ifaces[], links[], routes[], vlans[]}}` → `{allPassed, validation[], goals:[{id, passed, trace[] | failAt, reason, message}]}`; Masken-Eingabe akzeptiert `24`, `/24` oder `255.255.255.0` – Server normalisiert), `POST /finish` → Dev-Lab-Result-Form.
**Frontend:** `templates/netzwerker.html`, `static/js/netzwerker.js` (State-Machine `WORLD_SELECT → LEVEL_INTRO → EDITING ⇄ CHECKING → LEVEL_DONE → SUMMARY`), `static/css/netzwerker.css` Präfix `nw-`. **SVG-Ansatz:** ein `<svg viewBox="0 0 1000 620">`, Geräte-Glyphen als `<symbol>`-Defs im Strichstil der Dashboard-Icons, Positionen aus dem Payload; Links als `<path>`, Ports als Kreise an der Gerätekante. **Die Paket-Animation ist die Belohnung:** bei Erfolg wandert ein leuchtender Punkt entlang des tatsächlich vom Server gelieferten `trace`-Pfads (`requestAnimationFrame`); bei Fehlschlag stirbt er exakt am `failAt`-Hop mit rotem Burst. Kabelmodus: Port A klicken → Gummiband-Linie → Port B; gezogenes Kabel per Klick löschen. Layout: Canvas links/Mitte, rechts Ziel-Liste (mit Diagnosetexten unter roten Zielen) + Konfigurations-Panel des gewählten Geräts, Footer mit „Pings testen" / „Lösung anzeigen" / Hinweis-Toggle.
**Schwierigkeitskurve (Welten):** 1. „Ein Netz für alle" (Same-Subnet-Basics), 2. „Über den Router" (Gateways, zwei Subnetze, erstes Negativ-Ziel), 3. „Routen & Fehlersuche" (zwei Router, statische Routen, Duplikat-IP-Jagd), 4. (Ausbau) „VLAN-Werkstatt".
**Content & Seeder:** `NetzwerkerSeeder` liest `resources/netzwerker/content.json`. Startumfang: **3 Welten × 5 Level = 15 Level.** Drei Beispiel-Level voll ausgearbeitet: „Erste Verbindung" (assign: PC1 passend zu 192.168.10.20/24 konfigurieren), „Die falsche Maske" (fix: /25-vs-/24-Konflikt, .200 liegt außerhalb des ersten /25-Teilnetzes), „Tor zum Serverraum" (assign: IP+Maske+Gateway über Router zwischen 192.168.1.0/24 und 192.168.2.0/24).
**Admin:** `AdminNetzwerkerController` (`/admin/netzwerker`), Welt-Liste + Detailseite mit Level-Payload-JSON-Textarea (eingeklapptes Schema-Cheatsheet) und dem Musterlösungs-Validierungs-Gate.
**Aufwand:** MVP ≈ 9,5 PT (**NetSimulator + Unit-Tests zuerst: 2,5** · Migration/Entities/Service/API 1,5 · SVG-Frontend 3,5 · Content 1 · Admin 1). Ausbau (+4,5 PT): VLAN-Welt, DHCP-Modus, Sandbox, visueller Level-Editor.
---
### Empfohlene Baureihenfolge & Gesamtaufwand
0. **Vorarbeiten:** Namensschema umsetzen (Dashboard-Namen, Keys, `devlab` in `ProgressService` = W1) · K2 (Legacy-Endpunkt löschen) und W8 (N+1-Muster) beheben, damit die neuen Services das saubere Muster übernehmen · Spiel-Akzent-Tokens in `theme.css` anlegen. ≈ 1 PT — **✅ erledigt bis auf die Spiel-Akzent-Tokens.**
1. **CodeEscape (V9)** – validiert die Wiederverwendung der DevLab-Checker/-Renderer mit minimalem Risiko. ≈ 5,5 PT
2. **BugHunter (V10)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 1. ≈ 4,5 PT
3. **NetWorker (V12)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
4. **ServiceClash (V11)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT
**Gesamt-MVP aller vier Spiele: ≈ 28 Personentage.** Der Navigations-Umbau (Abschnitt 2.3) und die restlichen Teil-2-Punkte können parallel oder dazwischen erfolgen; empfohlen ist der Sidebar-/Tab-Umbau **vor** dem vierten Spiel, damit alle neuen Seiten die Fragmente nutzen.
---
## Teil 2: Verbesserungen & Optimierungen am Bestand
> ✅ **Umgesetzt am 2026-07-12** (K1–K3, W1–W8, N1–N6, S1–S8, S10 – Details im CHANGELOG). Offen bleiben: S9/i18n (bewusst zurückgestellt), S3 (Regel für die neuen Spiele) sowie die schrittweise Fragment-Migration der restlichen Spiel-/Admin-Templates. **K1 vollständig abgeschlossen:** Gitea-Secret angelegt, Produktionspasswort rotiert und Runner-Abgleich erfolgreich deployt (Workflow 43, Commit `4f5042d`). Das Verfahren und der manuelle Notfallweg stehen in [`pw_anleitung.md`](pw_anleitung.md).
### 2.1 Kritisch (vor/mit dem nächsten Release beheben)
| # | Problem | Ort | Maßnahme |
|---|---------|-----|----------|
| K1 ✅ | **DB-Passwort im Repo** (`SPRING_DATASOURCE_PASSWORD: mindflux_pass`) | `docker-compose.yml:11` | In Gitea-Secret auslagern (wie die Admin-Passwörter bereits); Passwort rotieren. |
| K2 ✅ | **Unsicherer Legacy-Endpunkt `/users/save`**: speichert User ohne BCrypt-Hashing, ohne Validierung, mit frei wählbarer Rolle (Mass Assignment) – für jeden eingeloggten Nutzer erreichbar | `UserViewController.java:25–29` | `UserViewController` + `user-form.html` + `user-success.html` ersatzlos löschen (toter Legacy-Code). |
| K3 ✅ | **Keine Tests** – `src/test` existiert nicht, obwohl `spring-boot-starter-test` deklariert ist | `pom.xml:88–93` | Mindestens: Unit-Tests für Score-Berechnungen (`GameScoreService`, `SyntaxSagaService`, `DevLabService.check`-Dispatcher) und einen `@WebMvcTest` für die Security-Regeln (`/admin/**` nur ADMIN). Die Netzwerk-Simulation des neuen Netzwerker-Spiels unbedingt testgetrieben bauen. |
### 2.2 Wichtig (zeitnah)
| # | Problem | Ort | Maßnahme |
|---|---------|-----|----------|
| W1 ✅ | **Dev Lab fehlt auf der Statistik-Seite** – Scores werden geschrieben, aber nie angezeigt | `ProgressService.GAMES` (Zeile 18–27) | `GAMES.put("devlab", "Dev Lab");` ergänzen (Reihenfolge wie Dashboard). |
| W2 ✅ | **XP/Level/Streak sind Dummy-Daten** (`getOverallProgress` hardcoded 432/2400/Level 5) | `ProgressService.java:35–44` | Echte Aggregation aus `game_scores`: XP = Summe aller Scores (oder gedeckelt pro Spiel), Level per Schwellen-Tabelle, richtigen Antworten aus `moves` ableiten. Gleiches Fundament später für Achievements nutzen. |
| W3 ✅ | **Achievements sind Dummy-Daten**, nicht mit `game_scores` verdrahtet | `AchievementServiceImpl` | Nach `recordScore()` einen Achievement-Check auslösen (z. B. „Erster Sieg", „3 Sterne in Syntax Saga", „5 Spiele an einem Tag"). Ereignisgesteuert im `GameScoreService` (ein Hook-Punkt statt in jedem Spiel). |
| W4 ✅ | **Kein globales Error-Handling** – Admin-Controller werfen `IllegalArgumentException` → Whitelabel-500; APIs liefern leere DTOs statt 404 | alle Controller | Ein `@ControllerAdvice` (HTML: eigene `error.html`) + `@RestControllerAdvice` (JSON: `{error, message}` mit korrekten Statuscodes). `ResponseStatusException` in Services nutzen. |
| W5 ✅ | **Keine Bean Validation** – kein `spring-boot-starter-validation`, keine `@Valid`/`@NotBlank`/`@Size`; keine Passwort-Mindestlänge, kein E-Mail-Formatcheck | `pom.xml`, `RegisterController.java:34–42`, `User.java` | Starter ergänzen; DTO-Validierung für Registrierung (Passwort ≥ 8 Zeichen, E-Mail-Format) und die neuen Spiel-APIs. |
| W6 ✅ | **Registrierungs-Race + tote Templates**: App prüft Username case-insensitiv, DB-UNIQUE ist case-sensitiv → Race mit unbehandeltem 500; `register.html` (ohne `confirmPassword`-Feld) und `login.html` sind tot/kaputt | `RegisterController.java:40`, `V1__initial_schema.sql` | Funktionalen Index `UNIQUE (lower(username))` per Migration; `DataIntegrityViolationException` abfangen → Fehlermeldung; tote Templates `register.html`/`login.html` löschen. |
| W7 ✅ | **`System.out.println` / `printStackTrace` statt Logging** (~20 Stellen, u. a. `ProfileController.java:144`) | Seeder, `AdminSetup`, `ProfileController`, `AppStartCheck` | Auf SLF4J (`LoggerFactory.getLogger`) umstellen; `e.printStackTrace()` durch `log.error(..., e)` ersetzen. |
| W8 ✅ | **N+1-Queries**: `TechtacularService.buildBoard` lädt Fragen+Antworten lazy pro Kategorie; `categoryRows` macht ein `count` pro Kategorie | `TechtacularService.java:46–73, 133–137` | `@EntityGraph`/`JOIN FETCH` für Board-Aufbau; Zeilen-Counts als eine `GROUP BY`-Query. Bei Seed-Datenmengen unkritisch, aber das Muster wird sonst in die 4 neuen Spiele kopiert – **daher vor deren Bau beheben bzw. in den neuen Services von Anfang an richtig machen.** |
### 2.3 Navigation & Profil-Bereich (vom Nutzer gemeldet – bestätigt)
Der Verdacht „Profilbutton fehlt / Einstellungen nicht im Profil / irgendwas ist schief" trifft zu. Befund:
| # | Befund | Ort |
|---|--------|-----|
| N1 ✅ | **Kein „Profil"-Eintrag in der Dashboard-Sidebar-Navigation.** Das Profil ist nur über den kleinen User-Block unten in der Sidebar erreichbar (`<a class="mf-profile" href="/profile">`), der nicht als Button erkennbar ist – und dessen Chevron-Pfeil (▼) ein Dropdown suggeriert, das nicht existiert. | `dashboard.html:72–82` |
| N2 ✅ | **Zwei konkurrierende Navigationssysteme:** Die Sidebar führt zu Knowledge Core / Statistiken / Achievements / Einstellungen; die Profilseite hat eine **eigene Tab-Leiste** (Profil / Fortschritt / Erfolge / Soziales / Einstellungen). Benennungen inkonsistent („Statistiken" vs. „Fortschritt", „Achievements" vs. „Erfolge"), Mengen inkonsistent (Knowledge Core fehlt in den Tabs, Soziales fehlt in der Sidebar). | `dashboard.html:45–70` vs. `profile.html:35–41` |
| N3 ✅ | **Die Tab-Leiste existiert nur auf `profile.html`.** Klickt man einen „Tab" (z. B. Einstellungen), landet man auf einer Seite **ohne** Tabs – die Tab-Metapher bricht sofort. `settings.html` hat nur „⟵ Zurück zum Profil", auch wenn man von der Dashboard-Sidebar kam (asymmetrische Navigation). | Grep `mf-tab-nav`: nur `profile.html` (+ eine Admin-Seite) |
| N4 ✅ | **`/profile/settings` gehört nicht zum Profil-Code:** Die URL suggeriert Profil-Zugehörigkeit, wird aber von einem separaten `SettingsController` bedient; die Passwort-Änderung dagegen liegt im `ProfileController` (`/profile/change-password`) und redirectet nach `settings`. Zuständigkeiten sind verschränkt. | `SettingsController.java`, `ProfileController.java:154–177` |
| N5 ✅ | **`settings.html` ist zur Hälfte Attrappe:** Theme-Select (Hinweistext verweist auf den echten Umschalter), Sprach-Select und Benachrichtigungs-Checkboxen haben **keine Funktion/kein Formular**. Real sind nur Passwort ändern und Profil löschen. Zudem hartkodierte Farbe `#c0392b` statt `var(--danger)` (Token-Regel-Verstoß). | `settings.html:40–70, 37` |
| N6 ✅ | **Sidebar-Widgets sind Dummy-Daten:** LEVEL 12 / 3250/5000 XP / „STREAK 7 Tage" sind hart ins Template geschrieben – deckt sich mit W2 (XP/Level-Dummies im `ProgressService`). | `dashboard.html:84–91` |
**Lösungsansatz (ein zusammenhängender Umbau, verzahnt mit S1/Layout-Fragmenten):**
1. **Ein Navigationsmodell:** Die Sidebar wird als Thymeleaf-Fragment extrahiert und um einen expliziten Eintrag **„Profil" → `/profile`** ergänzt; der User-Block bleibt als Shortcut, verliert aber den irreführenden Chevron (oder bekommt ein echtes Dropdown mit Profil/Einstellungen/Logout).
2. **Profil-Bereich konsolidieren:** Die Tab-Leiste wird ebenfalls Fragment und erscheint **auf allen** Profil-Unterseiten (Profil, Statistiken, Erfolge, Soziales, Einstellungen) mit einheitlichen Namen (Empfehlung: „Statistiken" und „Erfolge" überall). Knowledge Core entweder in die Tabs aufnehmen oder bewusst nur in der Sidebar führen – einmal entscheiden.
3. **Controller aufräumen:** `SettingsController` in den `ProfileController` mergen (oder Settings vollständig auslagern inkl. Passwort-Endpunkt) – eine Zuständigkeit pro Bereich.
4. **Attrappen entfernen oder verdrahten:** Theme-Select an `theme.js` anbinden (Auswahl persistiert in `localStorage`, ersetzt den Hinweistext); Sprach-Select und Benachrichtigungen **ausbauen**, bis es die Funktionen gibt. `#c0392b` → `var(--danger)`.
5. N6 wird durch W2 (echte XP/Level aus `game_scores`) gelöst; bis dahin Widgets ausblenden statt Fantasiewerte zeigen.
### 2.4 Sinnvoll (mittelfristig)
| # | Thema | Maßnahme |
|---|-------|----------|
| S1 ✅ teilweise | **Template-Duplikation** – 33 Templates ohne ein einziges `th:fragment`; jede Seite dupliziert `<head>`, Sidebar, Footer | `templates/fragments/layout.html` mit `head`, `sidebar`, `footer`-Fragmenten einführen; schrittweise umstellen (zuerst bei den 4 neuen Spielen verwenden, dann Bestand migrieren). |
| S2 ✅ | **4 parallele User-Anlage-Pfade** mit inkonsistenten Regeln (`RegisterController`, REST-`UserController`, `AdminUserController`, `UserViewController`) | Nach Löschung von K2: Restlogik in einen `UserService` konsolidieren (ein Ort für Encoding, Rollen, Uniqueness). |
| S3 ☐ | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Akzeptables Risiko (nur eigener Highscore), aber bei den neuen Spielen von Anfang an minimieren: Server generiert Runden-Daten mit IDs, `finish` validiert nur bekannte IDs, Zeit-Plausibilitätscheck (z. B. `durationSeconds ≥ 1`, Obergrenzen für `pairs`/`moves`). |
| S4 ✅ | **Infrastruktur**: kein Actuator/Healthcheck, Dockerfile läuft als root, kein Dependency-Layer-Caching, CI ohne Tests, Java-Versionskonflikt (pom: 17, Dockerfile: 21) | `spring-boot-starter-actuator` (+ `HEALTHCHECK` in compose), `USER`-Directive, Dockerfile zweistufig mit `dependency:go-offline`-Layer, Test-Stage in `.gitea/workflows/mindflux.yml`, Java-Version angleichen (empfohlen: beide auf 21). |
| S5 ✅ | **Datenmodell**: `game_scores`/`achievements` referenzieren `username` ohne FK; `email` ohne UNIQUE | FK auf `users(username)` + `UNIQUE(email)` per Migration ergänzen (vorher Dubletten bereinigen). |
| S6 ✅ | **Logout per GET** (`SecurityConfig.java:36`) – kleiner Logout-CSRF-Vektor | Auf POST-Logout mit CSRF-Token umstellen (kleines Formular im Sidebar-Footer). |
| S7 ✅ | **Kein Brute-Force-Schutz** auf `/login` | Einfacher In-Memory-Rate-Limiter (z. B. Bucket pro IP/Username) oder Verzögerung nach Fehlversuchen. |
| S8 ✅ | **Toter Code**: `AppStartCheck`, `ProfileController.getFileExtension`, Legacy-CSS (`auth.css`, …) | Aufräumen (niedrige Priorität, aber billig). |
| S9 ☐ | **i18n**: alles hart deutsch | Nur angehen, falls Mehrsprachigkeit je geplant ist – sonst bewusst so lassen (Zielgruppe ist deutsch). |
| S10 ✅ | **FinalFlux auf die gesamte Ausbildung erweitern (AP2)** – aktuell rein AP1-fokussiert | Eigener AP2-Ast mit Sidebar-Blöcken „AP2 Anwendungsentwicklung" und „AP2 Systemintegration"; gleiche Struktur wie bisher (statische Themenseiten unter `static/finalflux/` + FluxCheck-Frage-JSONs). Startthemen FIAE: Entwurfsmuster, Testverfahren, SQL-Vertiefung, Softwarequalität; FISI: Netzwerksicherheit, Monitoring, Hochverfügbarkeit, Automatisierung. |
---
## Teil 3: FluxNet – Community & Soziales
> Ersetzt den `/soziales`-Platzhalter („Straßen? Wo wir hingehen, brauchen wir keine Straßen!"). Ziel: User können sich befreunden, intern miteinander schreiben, gemeinsam lernen und Achievements vergleichen – **vollständig selbst gehostet, ohne einen einzigen externen Dienst**, und DSGVO-konform von Grund auf.
### 3.1 Funktionsumfang
| Funktion | Beschreibung |
|----------|--------------|
| **Freundschaften** | Anfrage per Username-Suche senden → Gegenseite bestätigt/lehnt ab. Nur bestätigte Freunde sehen einander im FluxNet. |
| **Interner Messenger** | 1:1-Direktnachrichten zwischen bestätigten Freunden. Ungelesen-Badge am Glocken-Icon der Topbar. Kein Gruppenchat in Phase 1. |
| **Blockieren** | Blockiert = keine Anfragen, keine Nachrichten, keine Profileinsicht – in beide Richtungen serverseitig geprüft. Bestehende Freundschaft wird getrennt. Der Blockierte erhält **keine** Benachrichtigung (still). Entblocken jederzeit über die Einstellungen. |
| **Achievements vergleichen** | Freundesprofil zeigt den Achievement-Katalog nebeneinander (meine/seine freigeschalteten) und die Spiel-Highscores im Vergleich – nur, was die Privacy-Einstellungen des anderen erlauben. |
| **Gemeinsam lernen** | Phase 2: **Lern-Duell** – einen Freund zu Spiel + Deck herausfordern, beide spielen binnen Frist, der Server vergleicht die echten `game_scores`-Ergebnisse. Phase 3: gemeinsame Lernräume (identisches Board für beide über die Seed-Mechanik aus dem ServiceClash-Design). |
### 3.2 Datenmodell (nächste freie Flyway-Migrationen)
Alle Tabellen mit FK auf `users(username)` und `ON DELETE CASCADE ON UPDATE CASCADE` (Muster aus V10) – damit greift das Löschkonzept automatisch.
```sql
CREATE TABLE privacy_settings (
username VARCHAR(100) PRIMARY KEY REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
community_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- DSGVO: Opt-in!
profile_visibility VARCHAR(20) NOT NULL DEFAULT 'FRIENDS', -- FRIENDS | ALL
show_achievements BOOLEAN NOT NULL DEFAULT TRUE,
show_stats BOOLEAN NOT NULL DEFAULT TRUE,
allow_requests BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE friendships (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
requester VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
addressee VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING | ACCEPTED
created_at TIMESTAMP NOT NULL,
responded_at TIMESTAMP,
CONSTRAINT uq_friend_pair UNIQUE (requester, addressee),
CONSTRAINT chk_no_self CHECK (requester <> addressee)
);
CREATE TABLE user_blocks (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
blocker VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
blocked VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMP NOT NULL,
CONSTRAINT uq_block_pair UNIQUE (blocker, blocked)
);
CREATE TABLE messages (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sender VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
recipient VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
body VARCHAR(2000) NOT NULL,
sent_at TIMESTAMP NOT NULL,
read_at TIMESTAMP,
reported BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_messages_unread ON messages (recipient, read_at);
CREATE INDEX idx_messages_pair ON messages (sender, recipient, sent_at);
CREATE TABLE challenges (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
challenger VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
challenged VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
game VARCHAR(50) NOT NULL,
deck VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN | DONE | EXPIRED | DECLINED
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);
```
Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = requester); Abfragen prüfen beide Richtungen (`(requester=?1 AND addressee=?2) OR (requester=?2 AND addressee=?1)`).
### 3.3 Backend (bewährtes Schichtmuster)
- **Services:** `FriendshipService` (anfragen/annehmen/ablehnen/entfernen), `MessageService` (senden, Verlauf paginiert laden, als gelesen markieren, eigene Nachricht löschen, melden), `BlockService` (blocken/entblocken, `isBlockedEitherWay(a, b)`), `PrivacyService` (Opt-in, Sichtbarkeiten).
- **Zentrale Guard-Methode**, die JEDE soziale Aktion durchläuft: Community aktiviert (beide Seiten)? Block in irgendeiner Richtung? Freundschaft bestätigt (für Nachrichten/Profile)? → sonst 403/404, ohne dem Angreifer zu verraten, ob der Zielnutzer existiert oder blockt.
- **Controller:** `SozialesController` (View `/soziales`, Freundesprofil `/soziales/profil/{username}`) + `@RestController /api/social/**` (Endpunkte: `GET /friends`, `POST /friends/request|accept|decline|remove`, `GET /messages/{friend}?after=`, `POST /messages`, `POST /messages/{id}/report`, `DELETE /messages/{id}`, `GET /unread-count`, `POST /block`, `DELETE /block/{username}`, `GET/PUT /privacy`). Route in die CSRF-Ignore-Liste der `SecurityConfig` aufnehmen (Muster der Spiel-APIs).
- **Moderation:** gemeldete Nachrichten landen in einer Admin-Ansicht (`/admin/fluxnet`) – der Admin sieht **ausschließlich gemeldete** Nachrichten (Verhältnismäßigkeit), kann löschen und Nutzer verwarnen/sperren.
### 3.4 Messenger-Transport: voll intern, keine externen Dienste
- **Phase 1 – Polling:** offener Chat pollt alle 5 s `GET /api/social/messages/{friend}?after=<letzteId>`, das Ungelesen-Badge alle 30 s `GET /api/social/unread-count`. Passt exakt zur zustandslosen Vanilla-JS-Architektur, braucht null neue Infrastruktur und skaliert für die Zielgruppe locker.
- **Phase 2 – Server-Sent Events:** `GET /api/social/stream` mit Spring `SseEmitter` (im `MessageService` wird bei `send()` an offene Emitter des Empfängers publiziert). Weiterhin 100 % selbst gehostet. nginx: `proxy_buffering off;` + langes `proxy_read_timeout` für den Stream-Pfad.
- **Bewusst NICHT:** WebSocket/STOMP (Overkill für 1:1-Chat) und Web-Push/FCM/Service-Worker-Push – Letzteres liefe zwingend über die Push-Infrastruktur der Browserhersteller (Google/Mozilla/Apple) und wäre damit ein externer Dienst.
### 3.5 DSGVO-Konzept
| Prinzip | Umsetzung |
|---------|-----------|
| **Opt-in (Art. 6, 25)** | `community_enabled = FALSE` als Default. Beim ersten Besuch von `/soziales`: Aktivierungsdialog mit klarem Hinweistext (welche Daten sichtbar werden, an wen). Ohne Aktivierung ist der Nutzer für andere unsichtbar und unauffindbar. Deaktivierung jederzeit in den Einstellungen (versteckt Profil, pausiert Eingang von Anfragen/Nachrichten). |
| **Datenminimierung (Art. 5)** | Nur Username-Referenzen und Nachrichtentexte – keine neuen Pflicht-Personendaten, keine Telefonnummern, keine Kontakte-Uploads. |
| **Privacy by Default (Art. 25)** | Sichtbarkeit ab Werk „nur Freunde"; Nachrichten nur zwischen bestätigten Freunden (verhindert nebenbei Spam). |
| **Löschung (Art. 17)** | Konto-Löschung kaskadiert per FK automatisch über Freundschaften, Blocks, Nachrichten und Challenges – nichts bleibt zurück. Einzelne eigene Nachrichten kann der Nutzer selbst löschen. |
| **Auskunft/Portabilität (Art. 15/20)** | Ausbau Phase 3: „Meine Daten exportieren"-Button (JSON-Dump der eigenen Profil-, Freundes-, Nachrichten- und Score-Daten). |
| **Vertraulichkeit (Art. 32)** | Transport via TLS (nginx). Speicherung serverseitig im Klartext in der eigenen Postgres – bewusste, dokumentierte Entscheidung (Moderationsfähigkeit); keine Weitergabe an Dritte, kein externer Dienst, keine Analytik. |
| **Transparenz (Art. 13)** | `datenschutz.html` um den Abschnitt FluxNet erweitern (Zwecke, Speicherdauer, Empfängerkreis „nur befreundete Nutzer", Moderationsregel); `nutzungsbedingungen.html` um Verhaltensregeln + Melde-/Sperrprozess. |
| **Betroffenenschutz** | Blockieren ist still (kein „X hat dich blockiert"); Melden einer Nachricht legt nur DIESE Nachricht dem Admin vor. |
### 3.6 Frontend
- `soziales.html` wird zum **FluxNet-Hub** (der Profil-Tab „Soziales" existiert schon): links Freundesliste (mit Online-Reihenfolge nach letzter Aktivität) + offene Anfragen + Username-Suche; rechts Chat-Panel des gewählten Freundes (Verlauf, Eingabefeld, „Nachricht melden/löschen" im Kontextmenü).
- Freundesprofil: Avatar, Level, Streak, Achievement-Vergleich (zwei Spalten, gemeinsame freigeschaltete hervorgehoben), Highscore-Tabelle je Spiel, Buttons „Herausfordern" / „Blockieren".
- Neue Dateien: `static/js/fluxnet.js` (IIFE, Polling-Logik, Render-Funktionen), `static/css/fluxnet.css` (Präfix `fn-`, nur Theme-Tokens, Erfolg blau ✓ / Fehler rot ✗). Ungelesen-Badge am Glocken-Icon der Dashboard-Topbar (zusammen mit dem FluxTimer-Popover aus Teil 4).
### 3.7 Phasen & Aufwand
1. **Phase 1 (≈ 7 PT):** Migrationen, Privacy-Opt-in, Freundschaften, Blockieren, Polling-Messenger, Ungelesen-Badge, Rechtstexte, Tests (Guard-Logik!).
2. **Phase 2 (≈ 4 PT):** SSE-Stream, Lern-Duelle, Freundesprofil mit Achievement-/Statistik-Vergleich, Melden + Admin-Moderationsansicht.
3. **Phase 3 (≈ 4 PT):** gemeinsame Lernräume (Seed-Boards), Datenexport (Art. 15/20), Feinschliff (Emoji-Reaktionen, Streak-Anstupsen).
---
## Teil 4: FluxTimer – Der Pausen-Lerntimer
> Opt-in-Erinnerungstimer im Look der Zeitschaltkreise des DeLorean: Nach frei wählbarer Zeit erinnert Mindflux ans Aufstehen, Trinken, Essen – oder was immer man einträgt. Standardmäßig **deaktiviert**, jederzeit abschaltbar, komplett frei einstellbar.
### 4.1 Funktionsumfang
- **Aktivieren/Deaktivieren** per Toggle (Einstellungen + Schnellzugriff über das Glocken-Icon der Dashboard-Topbar).
- **Intervall frei wählbar:** freie Minuteneingabe, sinnvolle Grenzen 5–240 Minuten (Default-Vorschlag 45).
- **Erinnerungsarten wählbar** (Mehrfachauswahl, Rotation): Aufstehen 🧍 · Trinken 💧 · Essen 🍎 · Augenpause 👀 · **eigener Text** (Freitextfeld).
- **Snooze** (+5 Minuten) und „Pause erledigt“ (startet das Intervall neu).
- **Probelauf-Button** in den Einstellungen (löst die Erinnerung sofort testweise aus).
- Optional zuschaltbar: Browser-Benachrichtigung (mit Permission-Abfrage) und dezenter Sound.
### 4.2 Technik: rein clientseitig, kein externer Dienst, DSGVO-minimal
- **`static/js/fluxtimer.js`** (IIFE), über das Layout-Fragment auf allen eingeloggten Seiten eingebunden.
- **Zustand in `localStorage`:** `fluxtimer = { enabled, intervalMinutes, reminders: ["aufstehen","trinken",…], customText, sound, notify, nextFireAt, lastFiredAt }`. Beim Seitenwechsel/Reload wird die Restzeit aus `nextFireAt` weitergeführt – der Timer „überlebt“ Navigation, ohne dass der Server irgendetwas weiß. **Kein Personenbezug, keine Serverdaten, nichts einwilligungspflichtig** (localStorage-Nutzung wird in der Datenschutzerklärung als technisch notwendig/funktional erwähnt).
- **Auslösung dreistufig:** (1) immer das In-App-Overlay (siehe 4.3); (2) optional lokale Browser-`Notification` – wird **lokal im Tab erzeugt**, kein Push-Server beteiligt; (3) optional kurzer Sound (Web Audio, eingebettet, kein CDN).
- **Bewusst kein Service-Worker-Web-Push:** Echte Push-Nachrichten bei geschlossenem Tab liefen zwingend über die Push-Gateways der Browserhersteller – das wäre ein externer Dienst und für einen Pausen-Timer unnötig (wenn kein Mindflux-Tab offen ist, lernt man gerade nicht in Mindflux).
- **Ausbau (optional, +0,5 PT):** `user_settings`-Tabelle für geräteübergreifende Einstellungen; localStorage bleibt der schnelle Cache.
### 4.3 BTTF-Optik („Zeitschaltkreise“)
- **Panel im Zeitschaltkreis-Look:** drei Anzeigezeilen in Segmentanzeigen-Typografie (Monospace + Glow):
- `NÄCHSTE PAUSE` – rot/bernsteinfarbene Anzeige (Countdown hh:mm:ss)
- `JETZT` – Akzentfarbe des Themes (laufende Uhrzeit)
- `LETZTE PAUSE` – gedimmt (wann zuletzt pausiert wurde)
- **Beim Auslösen:** kurzer Flux-Blitz (CSS-Animation über dem Overlay), 88-mph-Anspielung und Sprüche im BTTF-Ton, rotierend je Erinnerungsart – z. B. „Großer Scott! Zeit, etwas zu trinken.“ · „88 mph erreicht – kurz aufstehen, McFly!“ · „Dein Fluxkompensator braucht einen Snack.“
- **Styleguide-konform:** alle Farben über Theme-Tokens (die Rot-/Bernsteintöne als neue `--fluxtimer-*`-Tokens in `theme.css`, Dark/Light getestet); Grün bleibt tabu als Erfolgsfarbe. CSS in `static/css/fluxtimer.css` (Präfix `ft-`), Cache-Busting `?v=1`.
### 4.4 UI-Einstiegspunkte & Dateien
| Ort | Änderung |
|-----|----------|
| `settings.html` | Neue Sektion „FluxTimer“: Toggle, Minuten-Eingabe, Erinnerungs-Checkboxen, Freitext, Benachrichtigung/Sound-Optionen, Probelauf-Button |
| `dashboard.html` (Topbar) | Das bislang funktionslose Glocken-Icon öffnet ein FluxTimer-Popover: Status, Countdown, Schnell-Toggle, Link zu den Einstellungen |
| `templates/fragments/layout.html` | Script-Einbindung, damit der Timer auf allen eingeloggten Seiten weiterläuft |
| NEU `static/js/fluxtimer.js`, `static/css/fluxtimer.css` | Timer-Logik + Zeitschaltkreis-Optik |
### 4.5 Aufwand
≈ 1,5–2 PT (JS-Logik + Settings-UI + Popover + CSS/Animationen), +0,5 PT für die optionale serverseitige Persistenz.
---
## Schlüsseldateien als Bauplan-Referenz
| Datei | Rolle |
|-------|------|
| `src/main/java/com/example/mindflux/service/DevLabService.java` | Struktureller Bauplan für alle vier Services: `sanitize`/`check*`/`finish`/`awardedPoints`/`stars` + Text-Normalisierungs-Helfer (Checker für `choice`/`order`/`match`/`regex`/`cloze` teils 1:1 kopierbar) |
| `src/main/resources/static/js/devlabgame.js` | Renderer-Vertrag `{render, collect, lock, mark}`, Versuchsleiter-Frontend-Muster |
| `src/main/resources/db/migration/V8__devlab.sql` | SQL-Stilvorlage für V9–V12 |
| `src/main/java/com/example/mindflux/config/SecurityConfig.java` (Zeile 43) | CSRF-Ignore-Liste: `/api/codeescape/**`, `/api/bughunter/**`, `/api/serviceclash/**`, `/api/networker/**` ergänzen |
| `src/main/resources/templates/dashboard.html` (Zeilen 186–196) | `GAMES`-Array: Namen aufs neue Schema umstellen (DevLab, SyntaxSaga, CodeEscape, ServiceClash, BugHunter, NetWorker) und die vier `'/comingsoon'`-Hrefs gegen echte Routen tauschen (Icons `lock`/`sword`/`bug`/`network` existieren bereits) |
| `GameScoreController.KNOWN_GAMES` + `ProgressService.GAMES` | Vier reservierte Keys aufs neue Schema umbenennen (`codeescape`, `serviceclash`, `bughunter`, `networker`), `devlab` in `ProgressService` ergänzen (W1) |
| `src/main/java/com/example/mindflux/service/GameScoreService.java` | `recordScore(...)`-Anschlusspunkt aller Spiele |
| `STYLEGUIDE.md`, `CHANGELOG.md` | Design-Regeln (blau ✓ / rot ✗, nie grün; Theme-Tokens; `?v=N`) und Pflege der Historie |
## Verifikation
**Pro Spiel (nach Implementierung):**
1. `mvn test` – insbesondere `NetSimulatorTest` (Same-Subnet, Gateway, Longest-Prefix-Routen, Loop-Guard, Netz-/Broadcast-Adresse, Duplikat-IP) und `ServiceClashServiceTest` (Seed-Determinismus: gleicher Seed ⇒ identischer Plan; Re-Simulations-Scoring).
2. App starten (`docker-compose` oder lokal, Port 8282), einloggen und den vollen Spielfluss durchspielen: Lobby → Runde → absichtlich falsche Antworten (Versuchsleiter 1.0/0.6/0.3 prüfen) → Aufdecken (0 Punkte) → Abschluss → Score/Sterne/Highscore-Anzeige.
3. Server-Autorität prüfen: `POST /finish` mit manipulierten Daten (fremde IDs, unmögliche Zeitstempel) darf keinen überhöhten Score ergeben; View-DTOs im Netzwerk-Tab dürfen keine Lösungsfelder enthalten.
4. Highscore-Chip via `GET /api/games/<key>/stats`; Eintrag erscheint auf `/progress`.
5. Admin-CRUD: Anlegen/Ändern/Löschen inkl. Payload-Validierung (Netzwerker: Musterlösungs-Gate; Bug Hunter: `file`/`line`-Konsistenz; Service Clash: `correctIndex` in `options`).
6. Dark/Light-Theme umschalten und mobile Breite prüfen (Theme-Tokens, kein Grün).
**Namensschema & visuelle Differenzierung:** Dashboard zeigt alle neun Spiele mit einheitlichen CamelCase-Namen; jede der vier neuen Spielseiten hat ihren eigenen Akzent und ihr eigenes visuelles Vokabular – nebeneinander geöffnet müssen sie sofort unterscheidbar sein (Dark und Light prüfen).
**Navigation (Abschnitt 2.3):** Von jeder Profil-Unterseite aus sind alle anderen per Tab-Leiste erreichbar; „Profil" ist als Sidebar-Eintrag vorhanden; `settings.html` enthält keine funktionslosen Bedienelemente mehr; kein irreführender Chevron ohne Dropdown.
**Für die Verbesserungen (Teil 2):** Nach K1/K2: Neustart via docker-compose mit Secret-basiertem Passwort; `/users/save` liefert 404. Nach W1: DevLab erscheint auf `/progress`. Nach W4: bewusst kaputte Requests → saubere Fehlerseite/JSON-Fehler statt Whitelabel-500. Nach W6: doppelte Registrierung (Groß-/Kleinschreibung) liefert Fehlermeldung statt 500.
+17 -4
View File
@@ -19,7 +19,8 @@
<description>Mindflux – Lernplattform zur Prüfungsvorbereitung</description>
<properties>
<java.version>17</java.version>
<!-- Angeglichen an die Dockerfile-Laufzeit (Temurin 21) -->
<java.version>21</java.version>
</properties>
<dependencies>
@@ -53,10 +54,16 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Passwort-Verschlüsselung (BCrypt) -->
<!-- Bean Validation (@Valid, @NotBlank, @Email ...) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- PostgreSQL Datenbanktreiber -->
@@ -91,6 +98,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
+89
View File
@@ -0,0 +1,89 @@
# Anleitung: DB-Passwort rotieren & Gitea-Secret anlegen
> **Status 2026-07-12:** In Produktion abgeschlossen. Das Secret ist angelegt, die DB-Rolle
> wurde rotiert und Workflow 43 erfolgreich ausgeführt. Diese Anleitung bleibt als Betriebs-
> und Notfalldokumentation erhalten.
**Warum?** Das alte Datenbank-Passwort `mindflux_pass` stand im Klartext in `docker-compose.yml` – und bleibt über die Git-Historie für immer einsehbar. Es gilt daher als **kompromittiert** und muss ersetzt werden.
Die Technik ist bereits vorbereitet:
- `docker-compose.yml` liest das Passwort jetzt aus `${MINDFLUX_DB_PASSWORD}` (kein Klartext mehr im Repo).
- Der Gitea-Workflow (`.gitea/workflows/mindflux.yml`) reicht das Secret `MINDFLUX_DB_PASSWORD` beim Deploy durch – genau wie die Admin-Passwörter.
**Wichtig:** Bis das Secret angelegt ist, bricht das nächste Deployment mit der Meldung
`MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein` ab. So wird kein App-Container mit
einem leeren Datenbank-Passwort gestartet. Die folgenden 5 Schritte dauern ca. 5 Minuten.
---
## Schritt 1: Starkes Passwort erzeugen
PowerShell (Windows):
```powershell
-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
```
Oder auf dem Server (Linux):
```bash
openssl rand -base64 24
```
Das Ergebnis kopieren – es wird in Schritt 2 **und** Schritt 3 gebraucht. Nirgendwo einchecken, nicht in Dateien im Repo speichern.
## Schritt 2: Gitea-Secret anlegen
1. Gitea öffnen → das **mindflux-Repository** aufrufen.
2. **Einstellungen** → **Actions** → **Secrets** (derselbe Ort, an dem schon `MINDFLUX_ADMIN_PASSWORD` und `MINDFLUX_ADMIN2_PASSWORD` liegen).
3. **„Neues Secret hinzufügen"**:
- **Name:** `MINDFLUX_DB_PASSWORD` (exakt so – der Workflow referenziert diesen Namen)
- **Wert:** das neue Passwort aus Schritt 1
4. Speichern.
## Schritt 3: Passwort in der Produktions-Datenbank ändern
Der Gitea-Workflow gleicht das Passwort vor jedem Deployment automatisch mit dem Secret ab.
Beim ersten Deployment nach der Rotation führt der Runner das entsprechende `ALTER USER` aus;
ein separater SSH-Zugang ist deshalb nicht erforderlich.
Der folgende Befehl ist nur noch ein manueller Notfallweg, falls der Runner-Schritt einmal
nicht verfügbar sein sollte:
Auf dem Server (dort, wo der Postgres-Container läuft – er heißt im Backend-Netz `postgres`):
```bash
docker exec -it postgres psql -U mindflux -d mindfluxdb -c "ALTER USER mindflux WITH PASSWORD 'HIER_DAS_NEUE_PASSWORT';"
```
> Falls der Container anders heißt: mit `docker ps` nachsehen. DB-User ist `mindflux`, Datenbank `mindfluxdb` (siehe `docker-compose.yml`).
>
> Achtung bei Sonderzeichen: Das Passwort steht in einfachen Anführungszeichen im SQL – ein `'` im Passwort müsste verdoppelt werden (`''`). Am einfachsten: Passwort ohne `'` erzeugen (die Generatoren aus Schritt 1 tun das).
## Schritt 4: Deployment auslösen
Einfach einen Push auf `main` machen (oder den Workflow in Gitea manuell erneut ausführen). Der Deploy-Schritt exportiert das Secret und startet den Container neu:
```bash
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
> Zwischen Schritt 3 und dem Neustart gibt es ein kurzes Fenster, in dem der noch laufende alte Container seine bestehenden DB-Verbindungen behält – das ist unkritisch. Sollte die App nach dem Neustart nicht hochkommen, prüfen ob Secret-Wert (Schritt 2) und DB-Passwort (Schritt 3) wirklich identisch sind, dann erneut `docker compose up -d`.
## Schritt 5: Verifizieren
```bash
docker ps # Container "mindflux" muss nach ~40 s "healthy" zeigen (Actuator-Healthcheck)
docker logs mindflux --tail 30 # keine "password authentication failed"-Fehler
```
Danach im Browser einloggen – wenn Login und Dashboard funktionieren, ist die Rotation abgeschlossen. ✅
---
## Hinweise
- **Lokale Entwicklung:** lokal wird das Passwort über die Umgebungsvariable `MINDFLUX_DB_PASSWORD` bzw. eine (nicht eingecheckte) `.env`-Datei gesetzt (`application.properties` importiert sie automatisch). Die lokale Datenbank kann ein eigenes, anderes Passwort haben.
- **Warum die Historie nicht bereinigen?** Das alte Passwort ließe sich nur per Force-Push/History-Rewrite aus Git entfernen – aufwendig und riskant. Rotation ist der richtige Weg: Das alte Passwort darf ruhig bekannt sein, wenn es nirgends mehr gilt.
- Die Postgres-Instanz ist nur im internen `backend_network` erreichbar (kein öffentlicher Port) – die Rotation ist trotzdem Pflicht (Defense in Depth).
@@ -1,12 +0,0 @@
package com.example.mindflux;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AppStartCheck implements CommandLineRunner {
@Override
public void run(String... args) {
System.out.println("✅ App läuft. Sicherheitsmodul sollte aktiv sein.");
}
}
@@ -2,6 +2,8 @@ package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
@@ -11,6 +13,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class AdminSetup {
private static final Logger log = LoggerFactory.getLogger(AdminSetup.class);
@Value("${MINDFLUX_ADMIN_USERNAME:admin}")
private String adminUsername;
@@ -44,7 +48,7 @@ public class AdminSetup {
return; // Admin-Slot nicht konfiguriert -> still überspringen.
}
if (password == null || password.isBlank()) {
System.out.println("Kein Bootstrap-Admin erstellt: Passwort für '" + username + "' ist nicht gesetzt.");
log.warn("Kein Bootstrap-Admin erstellt: Passwort für '{}' ist nicht gesetzt.", username);
return;
}
if (userRepo.findByUsername(username).isEmpty()) {
@@ -54,7 +58,7 @@ public class AdminSetup {
admin.setPassword(passwordEncoder.encode(password));
admin.setRole("ADMIN");
userRepo.save(admin);
System.out.println("Bootstrap-Admin erstellt: " + username);
log.info("Bootstrap-Admin erstellt: {}", username);
}
}
}
@@ -0,0 +1,28 @@
package com.example.mindflux.config;
import com.example.mindflux.service.LoginAttemptService;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
/** Füttert den Login-Rate-Limiter mit den Auth-Ereignissen von Spring Security. */
@Component
public class AuthenticationEventListener {
private final LoginAttemptService loginAttemptService;
public AuthenticationEventListener(LoginAttemptService loginAttemptService) {
this.loginAttemptService = loginAttemptService;
}
@EventListener
public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
loginAttemptService.loginFailed(event.getAuthentication().getName());
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
loginAttemptService.loginSucceeded(event.getAuthentication().getName());
}
}
@@ -0,0 +1,132 @@
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 Logger log = LoggerFactory.getLogger(DevLabSeeder.class);
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()) {
log.warn("Dev-Lab-Seed: content.json ohne 'games'.");
return;
}
Set<String> 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++;
}
}
log.info("DevLab: {} Spiele / {} Aufgaben geladen{}.", gameCount, taskCount,
skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "");
} catch (Exception e) {
log.warn("Dev-Lab-Seed übersprungen: {}", e.getMessage(), e);
}
}
private String uniqueSlug(String base, Set<String> 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);
}
}
@@ -2,15 +2,16 @@ package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import com.example.mindflux.service.LoginAttemptService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
public class SecurityConfig {
@@ -20,36 +21,42 @@ public class SecurityConfig {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // NEU: Adminbereich absichern
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/") // login.html liegt auf "/"
.loginPage("/") // Login-Formular liegt auf der Startseite (index.html)
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an
.permitAll()
)
.logout(logout -> logout
// Logout-Links in den Templates sind GET -> GET-Logout erlauben, sonst 404/Fehler unter CSRF
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
// Logout nur per POST (CSRF-geschützt); die Templates nutzen kleine Formulare.
.logoutUrl("/logout")
.logoutSuccessUrl("/?logout")
.permitAll()
)
.csrf(csrf -> csrf
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**")
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
public UserDetailsService userDetailsService(UserRepository userRepo, LoginAttemptService loginAttemptService) {
return username -> {
User user = userRepo.findByUsername(username)
// Brute-Force-Schutz: Nach zu vielen Fehlversuchen vorübergehend sperren.
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("Zu viele Fehlversuche – bitte später erneut versuchen.");
}
User user = userRepo.findByUsernameIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden"));
return org.springframework.security.core.userdetails.User
@@ -0,0 +1,156 @@
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 Logger log = LoggerFactory.getLogger(SyntaxSagaSeeder.class);
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()) {
log.warn("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++;
}
}
log.info("SyntaxSaga: {} Missionen / {} Lücken geladen.", missionCount, gapCount);
} catch (Exception e) {
log.warn("Syntax-Saga-Seed übersprungen: {}", e.getMessage(), e);
}
}
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);
}
}
@@ -6,6 +6,8 @@ import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@@ -22,6 +24,8 @@ import java.util.Set;
@Component
public class TechtacularSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(TechtacularSeeder.class);
private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper;
@@ -40,7 +44,7 @@ public class TechtacularSeeder implements CommandLineRunner {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) {
System.out.println("⚠️ TechTacular-Seed: content.json ohne 'categories'.");
log.warn("TechTacular-Seed: content.json ohne 'categories'.");
return;
}
@@ -95,9 +99,9 @@ public class TechtacularSeeder implements CommandLineRunner {
catCount++;
}
}
System.out.println("✅ TechTacular: " + catCount + " Kategorien / " + clueCount + " Hinweise geladen.");
log.info("TechTacular: {} Kategorien / {} Hinweise geladen.", catCount, clueCount);
} catch (Exception e) {
System.out.println("⚠️ TechTacular-Seed übersprungen: " + e.getMessage());
log.warn("TechTacular-Seed übersprungen: {}", e.getMessage(), e);
}
}
@@ -19,18 +19,8 @@ public class AchievementController {
@GetMapping("/profile/achievements")
public String showAchievements(Model model, Principal principal) {
String username = principal.getName();
// Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrte Karten.
List<Achievement> achievements = achievementService.getAchievementsForUser(username);
// 🔥 Dummy-Daten nur für Tests!
if (achievements == null || achievements.isEmpty()) {
achievements = List.of(
new Achievement("Hangman Hero", "Gewinne 10 Runden Hangman", "hangman.png", true),
new Achievement("Memory Meister", "Erreiche 1000 Punkte im Memory", "memory.png", false),
new Achievement("Quizkönig", "Beantworte 50 Fragen richtig", "quiz.png", true),
new Achievement("Early Bird", "Lerne vor 7 Uhr morgens", "earlybird.png", false)
);
}
model.addAttribute("achievements", achievements);
return "achievements"; // lädt achievements.html
@@ -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";
}
}
@@ -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";
}
}
@@ -1,8 +1,7 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.AdminUserService;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.service.UserService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -12,16 +11,18 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/admin")
public class AdminUserController {
@Autowired
private AdminUserService userService;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@Autowired
private PasswordEncoder passwordEncoder;
public AdminUserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// ⬅️ Neu: Dashboard-Startseite
// ⬅️ Dashboard-Startseite
@GetMapping("")
public String adminDashboard() {
return "admin/dashboard"; // du brauchst: templates/admin/dashboard.html
return "admin/dashboard";
}
// ⬅️ Benutzerverwaltung
@@ -39,21 +40,43 @@ public class AdminUserController {
@PostMapping("/users/save")
public String saveUser(@ModelAttribute("user") User user) {
if (user.getId() != null && (user.getPassword() == null || user.getPassword().isBlank())) {
if (user.getId() != null) {
User existingUser = userService.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId()));
user.setPassword(existingUser.getPassword());
userService.assertUsernameAvailable(user.getUsername(), user.getId());
user.setRole(userService.validateRole(user.getRole()));
// Passwort: leer = unverändert übernehmen, sonst neu verschlüsseln (Admin-Reset)
if (user.getPassword() == null || user.getPassword().isBlank()) {
user.setPassword(existingUser.getPassword());
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
}
// Profilbild steht nicht im Formular -> bestehendes erhalten, nicht überschreiben
user.setProfilePicture(existingUser.getProfilePicture());
userService.save(user);
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
// Neuanlage zentral über den UserService (Validierung, Eindeutigkeit, Encoding)
userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
return "redirect:/admin/users";
}
userService.save(user);
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
@PostMapping("/users/{id}/reset-password")
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
if (newPassword != null && !newPassword.isBlank()) {
User user = userService.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
user.setPassword(passwordEncoder.encode(newPassword.trim()));
userService.save(user);
}
return "redirect:/admin/users";
}
@GetMapping("/users/edit/{id}")
public String editUserForm(@PathVariable Long id, Model model) {
User user = userService.findById(id).orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
User user = userService.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
model.addAttribute("user", user);
return "admin/users/form";
}
@@ -0,0 +1,75 @@
package com.example.mindflux.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Fehlerbehandlung für alle JSON-APIs: liefert statt Whitelabel-HTML einen
* konsistenten JSON-Körper mit korrektem Statuscode.
*/
@RestControllerAdvice(annotations = RestController.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleStatus(ResponseStatusException e) {
return body(e.getStatusCode().value(), e.getReason());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException e) {
return body(400, e.getMessage());
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handleUnreadable(HttpMessageNotReadableException e) {
return body(400, "Ungültiger Request-Body.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
String details = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + fieldMessage(f))
.collect(Collectors.joining("; "));
return body(400, details.isBlank() ? "Validierung fehlgeschlagen." : details);
}
/** Security-Fehler NICHT schlucken – die behandelt Spring Security selbst (403/Login). */
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException e) {
throw e;
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleServerError(Exception e) {
log.error("Unbehandelter API-Fehler", e);
return body(500, "Interner Fehler. Bitte später erneut versuchen.");
}
private static String fieldMessage(FieldError f) {
return f.getDefaultMessage() == null ? "ungültig" : f.getDefaultMessage();
}
private ResponseEntity<Map<String, Object>> body(int status, String message) {
return ResponseEntity.status(status).body(Map.of(
"error", true,
"status", status,
"message", message == null || message.isBlank() ? "Fehler" : message));
}
}
@@ -1,13 +1,25 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.ProgressService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
@Controller
public class DashboardController {
private final ProgressService progressService;
public DashboardController(ProgressService progressService) {
this.progressService = progressService;
}
@GetMapping("/dashboard")
public String showDashboard() {
return "dashboard"; // lädt dashboard.html aus templates
public String showDashboard(Model model, Principal principal) {
// Echte XP/Level/Streak-Werte für die Sidebar-Widgets (statt Dummy-Daten im Template).
model.addAttribute("overall", progressService.getOverallProgress(principal.getName()));
return "dashboard";
}
}
@@ -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);
}
}
@@ -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";
}
}
@@ -21,8 +21,8 @@ public class GameScoreController {
/** Bekannte Spiele (entsprechen den Dashboard-Routen). */
private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "escape-room",
"service-clash", "bug-hunter", "netzwerker");
"mindmatch", "techtacular", "syntax-saga", "devlab", "codeescape",
"serviceclash", "bughunter", "networker");
private final GameScoreService gameScoreService;
@@ -0,0 +1,60 @@
package com.example.mindflux.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* Fängt Fehler aus den HTML-Controllern ab und zeigt eine gestaltete Fehlerseite
* statt der Whitelabel-Seite. Die JSON-APIs behandelt der ApiExceptionHandler
* (höhere Präzedenz, greift nur für @RestController).
*/
@ControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Ungültige IDs/Parameter (z. B. Admin-Formulare) -> 400 mit lesbarer Meldung. */
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleBadRequest(IllegalArgumentException e, Model model) {
model.addAttribute("status", 400);
model.addAttribute("friendlyMessage",
e.getMessage() == null ? "Ungültige Anfrage." : e.getMessage());
return "error";
}
/** Unbekannte URL -> echte 404-Seite (darf nicht im Catch-all als 500 landen). */
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public String handleNotFound(NoResourceFoundException e, Model model) {
model.addAttribute("status", 404);
model.addAttribute("friendlyMessage", "Diese Seite existiert nicht (mehr).");
return "error";
}
/** Security-Fehler NICHT schlucken – die behandelt Spring Security selbst (403/Login). */
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException e) {
throw e;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public String handleServerError(Exception e, Model model) {
log.error("Unbehandelter Fehler", e);
model.addAttribute("status", 500);
model.addAttribute("friendlyMessage",
"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.");
return "error";
}
}
@@ -2,6 +2,8 @@ package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -29,6 +31,8 @@ import java.util.UUID;
@Controller
public class ProfileController {
private static final Logger log = LoggerFactory.getLogger(ProfileController.class);
@Autowired
private UserService userService;
@@ -61,19 +65,17 @@ public class ProfileController {
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
System.out.println("Versuche zu löschen: " + picturePath.toAbsolutePath());
if (Files.exists(picturePath)) {
Files.delete(picturePath);
System.out.println("Datei erfolgreich gelöscht: " + picturePath.getFileName());
log.info("Profilbild gelöscht: {}", picturePath.getFileName());
} else {
System.out.println("Datei existiert nicht: " + picturePath.toAbsolutePath());
log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath());
}
} catch (IOException e) {
System.err.println("Fehler beim Löschen der Datei: " + e.getMessage());
log.error("Fehler beim Löschen der Profilbild-Datei", e);
}
} else {
System.out.println("Kein Profilbild gesetzt, nichts zu löschen.");
log.debug("Kein Profilbild gesetzt, nichts zu löschen.");
}
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
@@ -83,6 +85,12 @@ public class ProfileController {
return "redirect:/profile?success=true";
}
/** Einstellungen gehören zum Profilbereich – eine Zuständigkeit, ein Controller. */
@GetMapping("/profile/settings")
public String settings() {
return "settings";
}
@GetMapping("/profile/knowledge")
public String knowledgeCenter(Model model, Principal principal) {
String username = principal.getName();
@@ -107,7 +115,7 @@ public class ProfileController {
BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) {
// Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern.
System.out.println("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
} else {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400;
@@ -141,7 +149,7 @@ public class ProfileController {
existingUser.setProfilePicture(fileName);
}
} catch (IOException e) {
e.printStackTrace();
log.error("Fehler beim Speichern des Profilbilds", e);
}
}
@@ -189,7 +197,7 @@ public class ProfileController {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
Files.deleteIfExists(picturePath);
} catch (IOException e) {
System.err.println("Fehler beim Löschen des Profilbilds: " + e.getMessage());
log.error("Fehler beim Löschen des Profilbilds", e);
}
}
}
@@ -198,12 +206,4 @@ public class ProfileController {
new SecurityContextLogoutHandler().logout(request, response, authentication);
return "redirect:/?logout";
}
// Hilfsmethode: Dateiendung extrahieren
private String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf(".") == -1) {
return "";
}
return filename.substring(filename.lastIndexOf("."));
}
}
@@ -1,44 +1,39 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.RegistrationResult;
import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class RegisterController {
@Autowired
private UserService userService;
private final UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
public RegisterController(UserService userService) {
this.userService = userService;
}
@GetMapping("/register")
public String showRegistrationForm(Model model) {
model.addAttribute("user", new User());
return "register";
public String showRegistrationForm() {
// Die eigenständige Registrierungsseite wurde entfernt – das Formular lebt auf der Startseite.
return "redirect:/";
}
@PostMapping("/register")
public String processRegistration(@ModelAttribute("user") User user, Model model) {
// Benutzername klein schreiben, um Doppelungen zu vermeiden
user.setUsername(user.getUsername().toLowerCase());
// Prüfen, ob Benutzername schon existiert
if (userService.findByUsername(user.getUsername()) != null) {
// Zurück zur kombinierten Login/Signup-Seite mit geöffnetem Signup-Panel
return "redirect:/?registerError";
}
// Passwort verschlüsseln
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRole("USER"); // Standardrolle
userService.save(user);
return "redirect:/?registerSuccess"; // zurück zum Login mit Erfolgsmeldung
public String processRegistration(@RequestParam String username,
@RequestParam String email,
@RequestParam String password,
@RequestParam(required = false) String confirmPassword) {
RegistrationResult result = userService.register(username, email, password, confirmPassword);
return switch (result) {
case SUCCESS -> "redirect:/?registerSuccess";
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
case EMAIL_INVALID -> "redirect:/?emailInvalid";
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
};
}
}
@@ -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);
}
}
@@ -4,10 +4,10 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SettingsController {
public class SyntaxSagaController {
@GetMapping("/profile/settings")
public String showSettingsPage() {
return "settings"; // lädt templates/settings.html
@GetMapping("/syntaxsaga")
public String show() {
return "syntaxsaga"; // Lädt templates/syntaxsaga.html
}
}
@@ -1,67 +1,72 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@Autowired
private PasswordEncoder passwordEncoder;
public UserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// 📥 User erstellen
// 📥 User erstellen (Validierung, Eindeutigkeit und Encoding zentral im UserService)
@PostMapping
public User createUser(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
return userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
// 📋 Alle User anzeigen
@GetMapping
public List<User> getAllUsers() {
return userRepository.findAll();
return userService.findAll();
}
// 📍 Einzelnen User anzeigen
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id);
return user.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
return userService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
// ✏️ User aktualisieren
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
return userRepository.findById(id).map(user -> {
user.setUsername(updatedUser.getUsername());
return userService.findById(id).map(user -> {
String newName = updatedUser.getUsername() == null ? "" : updatedUser.getUsername().trim();
if (newName.isBlank()) {
throw new IllegalArgumentException("Ungültiger Benutzername.");
}
userService.assertUsernameAvailable(newName, id);
user.setUsername(newName);
if (updatedUser.getPassword() != null && !updatedUser.getPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(updatedUser.getPassword()));
}
user.setEmail(updatedUser.getEmail());
user.setRole(updatedUser.getRole());
return ResponseEntity.ok(userRepository.save(user));
user.setRole(userService.validateRole(updatedUser.getRole()));
userService.save(user);
return ResponseEntity.ok(user);
}).orElseGet(() -> ResponseEntity.notFound().build());
}
// 🗑️ User löschen
// 🗑️ User löschen (inkl. zugehöriger Scores/Achievements)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userRepository.existsById(id)) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
if (userService.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.notFound().build();
userService.deleteById(id);
return ResponseEntity.noContent().build();
}
}
@@ -1,31 +0,0 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/users")
public class UserViewController {
@Autowired
private UserRepository userRepository;
// Formular anzeigen
@GetMapping("/form")
public String showForm(Model model) {
model.addAttribute("user", new User());
return "user-form";
}
// Benutzer speichern
@PostMapping("/save")
public String saveUser(@ModelAttribute User user) {
userRepository.save(user);
return "user-success"; // statt redirect
}
}
@@ -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; }
}
@@ -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<Item> 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<Item> getItems() { return items; }
public void setItems(List<Item> 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; }
}
}
@@ -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; }
}
@@ -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; }
}
@@ -6,6 +6,15 @@ public class ProgressData {
private int xpForNextLevel;
private int userLevel;
private int progressPercent;
private int streakDays;
public int getStreakDays() {
return streakDays;
}
public void setStreakDays(int streakDays) {
this.streakDays = streakDays;
}
public int getCorrectAnswers() {
return correctAnswers;
@@ -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,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<DevLabTask> 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<DevLabTask> getTasks() { return tasks; }
public void setTasks(List<DevLabTask> tasks) { this.tasks = tasks; }
}
@@ -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; }
}
@@ -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; }
}
@@ -1,7 +0,0 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AdminUserRepository extends JpaRepository<User, Long> {
}
@@ -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<DevLabGame, Long> {
List<DevLabGame> findAllByActiveTrueOrderBySortOrderAscIdAsc();
List<DevLabGame> findAllByOrderBySortOrderAscIdAsc();
Optional<DevLabGame> findBySlug(String slug);
boolean existsBySlug(String slug);
}
@@ -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<DevLabTask, Long> {
long countByGameId(Long gameId);
}
@@ -2,7 +2,10 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.GameScore;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
@@ -18,4 +21,24 @@ public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
/** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */
void deleteByUsername(String username);
/** Gesamtsumme aller Scores eines Spielers (XP-Basis). */
@Query("select coalesce(sum(g.score), 0) from GameScore g where g.username = :username")
long sumScoreByUsername(@Param("username") String username);
/** Summe der moves-Werte (je nach Spiel gelöste Aufgaben/richtige Antworten). */
@Query("select coalesce(sum(g.moves), 0) from GameScore g where g.username = :username")
long sumMovesByUsername(@Param("username") String username);
/** Gesamtzahl gespielter Runden über alle Spiele. */
long countByUsername(String username);
/** Anzahl verschiedener Spiele, die der Spieler gespielt hat. */
@Query("select count(distinct g.game) from GameScore g where g.username = :username")
long countDistinctGamesByUsername(@Param("username") String username);
/** Alle Spieltage (distinct, absteigend) – Basis für die Streak-Berechnung. */
@Query(value = "select distinct cast(played_at as date) from game_scores where username = :username order by 1 desc",
nativeQuery = true)
List<java.sql.Date> findDistinctPlayDaysDesc(@Param("username") String username);
}
@@ -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> {
}
@@ -2,6 +2,7 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
@@ -12,4 +13,8 @@ public interface TechtacularCategoryRepository extends JpaRepository<Techtacular
List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug);
/** Aktive Kategorien inkl. Fragen in einem Rutsch (vermeidet N+1 beim Board-Aufbau). */
@Query("select distinct c from TechtacularCategory c join fetch c.questions where c.active = true")
List<TechtacularCategory> findActiveWithQuestions();
}
@@ -2,8 +2,22 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularQuestion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> {
long countByCategoryId(Long categoryId);
/**
* Initialisiert die Antwort-Collections aller Fragen aktiver Kategorien im selben
* Persistence Context (zweite Query des 2-Query-Musters gegen N+1 beim Board-Aufbau).
*/
@Query("select distinct q from TechtacularQuestion q join fetch q.answers where q.category.active = true")
List<TechtacularQuestion> findActiveWithAnswers();
/** Fragenanzahl je Kategorie als eine GROUP-BY-Query statt eines Counts pro Zeile. */
@Query("select q.category.id, count(q) from TechtacularQuestion q group by q.category.id")
List<Object[]> countGroupedByCategory();
}
@@ -9,6 +9,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
// Benutzer anhand des Benutzernamens suchen
Optional<User> findByUsername(String username);
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
Optional<User> findByUsernameIgnoreCase(String username);
// Benutzer anhand des Benutzernamens löschen
void deleteByUsername(String username);
}
@@ -5,5 +5,13 @@ import com.example.mindflux.model.Achievement;
import java.util.List;
public interface AchievementService {
/** Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrt. */
List<Achievement> getAchievementsForUser(String username);
/**
* Prüft nach einem gespeicherten Spielergebnis alle Regeln und vergibt neue
* Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen.
*/
void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame);
}
@@ -2,19 +2,82 @@ package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.repository.AchievementRepository;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.repository.GameScoreRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AchievementServiceImpl implements AchievementService {
@Autowired
private AchievementRepository achievementRepository;
/** Katalog aller Achievements (Titel -> Beschreibung), in Anzeige-Reihenfolge. */
static final Map<String, String> CATALOG = new LinkedHashMap<>();
static {
CATALOG.put("Erster Sieg", "Schließe dein erstes Spiel ab.");
CATALOG.put("1000er-Club", "Erziele 1000 Punkte in einer einzigen Runde.");
CATALOG.put("Highscore-Jäger", "Verbessere deinen eigenen Highscore.");
CATALOG.put("Dauerbrenner", "Spiele dasselbe Spiel zehnmal.");
CATALOG.put("Punktesammler", "Sammle insgesamt 5000 Punkte.");
CATALOG.put("Allrounder", "Spiele vier verschiedene Spiele.");
}
private final AchievementRepository achievementRepository;
private final GameScoreRepository gameScoreRepository;
public AchievementServiceImpl(AchievementRepository achievementRepository,
GameScoreRepository gameScoreRepository) {
this.achievementRepository = achievementRepository;
this.gameScoreRepository = gameScoreRepository;
}
@Override
public List<Achievement> getAchievementsForUser(String username) {
return achievementRepository.findByUsername(username);
List<Achievement> earned = achievementRepository.findByUsername(username);
Map<String, Achievement> earnedByTitle = earned.stream()
.collect(Collectors.toMap(Achievement::getTitle, a -> a, (a, b) -> a));
List<Achievement> result = new ArrayList<>();
// Katalog in fester Reihenfolge: verdient mit Datum, sonst als gesperrte Karte.
CATALOG.forEach((title, description) -> {
Achievement achievement = earnedByTitle.remove(title);
result.add(achievement != null ? achievement
: new Achievement(title, description, null, false));
});
// Ältere/zusätzliche verdiente Achievements außerhalb des Katalogs behalten.
result.addAll(earnedByTitle.values());
return result;
}
@Override
@Transactional
public void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame) {
Set<String> existing = achievementRepository.findByUsername(username).stream()
.map(Achievement::getTitle)
.collect(Collectors.toSet());
award(existing, username, "Erster Sieg",
gameScoreRepository.countByUsername(username) >= 1);
award(existing, username, "1000er-Club", score >= 1000);
award(existing, username, "Highscore-Jäger", newHighscore && playsInGame > 1);
award(existing, username, "Dauerbrenner", playsInGame >= 10);
award(existing, username, "Punktesammler",
gameScoreRepository.sumScoreByUsername(username) >= 5000);
award(existing, username, "Allrounder",
gameScoreRepository.countDistinctGamesByUsername(username) >= 4);
}
private void award(Set<String> existing, String username, String title, boolean condition) {
if (!condition || existing.contains(title)) {
return;
}
Achievement achievement = new Achievement(title, CATALOG.get(title), null, true);
achievement.setUsername(username);
achievementRepository.save(achievement);
}
}
@@ -1,32 +0,0 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AdminUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class AdminUserService {
@Autowired
private AdminUserRepository userRepository;
public List<User> findAll() {
return userRepository.findAll();
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public User save(User user) {
return userRepository.save(user);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
@@ -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<String> KNOWN_TYPES = Set.of("cloze", "choice", "order", "spot", "match", "regex");
private static final Set<String> 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<String> 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<Integer, String> 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<String> 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<String> 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<String> correctOrder = stringList(p, "items");
List<String> 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<String, String> 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<DevLabGameRow> 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<String> 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<JsonNode> array(JsonNode node, String field) {
JsonNode arr = node == null ? null : node.get(field);
return (arr != null && arr.isArray()) ? arr : mapper.createArrayNode();
}
private List<String> stringList(JsonNode node, String field) {
List<String> out = new ArrayList<>();
for (JsonNode item : array(node, field)) {
out.add(item.asText(""));
}
return out;
}
private ArrayNode shuffledTextArray(List<String> texts) {
List<String> 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);
}
}
}
}
@@ -5,6 +5,8 @@ import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.model.GameScore;
import com.example.mindflux.repository.GameScoreRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@@ -16,10 +18,14 @@ import java.time.LocalDateTime;
@Service
public class GameScoreService {
private final GameScoreRepository repository;
private static final Logger log = LoggerFactory.getLogger(GameScoreService.class);
public GameScoreService(GameScoreRepository repository) {
private final GameScoreRepository repository;
private final AchievementService achievementService;
public GameScoreService(GameScoreRepository repository, AchievementService achievementService) {
this.repository = repository;
this.achievementService = achievementService;
}
/**
@@ -66,9 +72,33 @@ public class GameScoreService {
int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score);
long plays = repository.countByUsernameAndGame(username, game);
// Achievement-Prüfung darf das Speichern des Scores nie gefährden.
try {
achievementService.checkAndAward(username, game, score, newHighscore, plays);
} catch (Exception e) {
log.error("Achievement-Prüfung fehlgeschlagen für {} / {}", username, game, e);
}
return new ScoreResult(score, highscore, newHighscore, plays);
}
/** Summe aller Scores eines Spielers über alle Spiele (XP-Basis). */
public long totalScore(String username) {
return repository.sumScoreByUsername(username);
}
/** Summe der moves-Werte (je nach Spiel gelöste Aufgaben/richtige Antworten). */
public long totalMoves(String username) {
return repository.sumMovesByUsername(username);
}
/** Alle Spieltage absteigend sortiert (für die Tages-Streak). */
public java.util.List<java.time.LocalDate> playDaysDesc(String username) {
return repository.findDistinctPlayDaysDesc(username).stream()
.map(java.sql.Date::toLocalDate)
.toList();
}
/** Aktuelle Statistik (Highscore, Anzahl Spiele, zuletzt gespielt) für ein Spiel. */
public GameStats getStats(String username, String game) {
int highscore = repository.findTopByUsernameAndGameOrderByScoreDesc(username, game)
@@ -0,0 +1,55 @@
package com.example.mindflux.service;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Einfacher In-Memory-Schutz gegen Brute-Force auf den Login: Nach zu vielen
* Fehlversuchen wird der Benutzername für ein Zeitfenster gesperrt. Erfolgreicher
* Login setzt den Zähler zurück. Bewusst ohne externe Abhängigkeiten gehalten.
*/
@Service
public class LoginAttemptService {
static final int MAX_ATTEMPTS = 5;
static final Duration WINDOW = Duration.ofMinutes(15);
private record Attempt(int count, Instant firstFailure) {}
private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();
public void loginFailed(String username) {
String key = normalize(username);
attempts.compute(key, (k, existing) -> {
Instant now = Instant.now();
if (existing == null || existing.firstFailure().plus(WINDOW).isBefore(now)) {
return new Attempt(1, now);
}
return new Attempt(existing.count() + 1, existing.firstFailure());
});
}
public void loginSucceeded(String username) {
attempts.remove(normalize(username));
}
public boolean isBlocked(String username) {
Attempt attempt = attempts.get(normalize(username));
if (attempt == null) {
return false;
}
if (attempt.firstFailure().plus(WINDOW).isBefore(Instant.now())) {
attempts.remove(normalize(username));
return false;
}
return attempt.count() >= MAX_ATTEMPTS;
}
private String normalize(String username) {
return username == null ? "" : username.trim().toLowerCase();
}
}
@@ -6,6 +6,7 @@ import com.example.mindflux.dto.ProgressData;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -19,11 +20,12 @@ public class ProgressService {
static {
GAMES.put("mindmatch", "MindMatch");
GAMES.put("techtacular", "TechTacular");
GAMES.put("syntax-saga", "Syntax Saga");
GAMES.put("escape-room", "Code Escape Room");
GAMES.put("service-clash", "Service Clash");
GAMES.put("bug-hunter", "Bug Hunter");
GAMES.put("netzwerker", "Netzwerker");
GAMES.put("devlab", "DevLab");
GAMES.put("syntax-saga", "SyntaxSaga");
GAMES.put("codeescape", "CodeEscape");
GAMES.put("serviceclash", "ServiceClash");
GAMES.put("bughunter", "BugHunter");
GAMES.put("networker", "NetWorker");
}
private final GameScoreService gameScoreService;
@@ -32,17 +34,53 @@ public class ProgressService {
this.gameScoreService = gameScoreService;
}
/** XP pro Level: Level n ist erreicht ab (n-1) * 1000 Gesamt-XP. */
static final int XP_PER_LEVEL = 1000;
/** Echter Gesamtfortschritt aus game_scores: XP = Summe aller Scores, Level linear. */
public ProgressData getOverallProgress(String username) {
// Dummy-Daten – später aus DB berechnen
long totalXp = gameScoreService.totalScore(username);
int level = (int) (totalXp / XP_PER_LEVEL) + 1;
long nextLevelAt = (long) level * XP_PER_LEVEL;
int withinLevel = (int) (totalXp - (long) (level - 1) * XP_PER_LEVEL);
ProgressData data = new ProgressData();
data.setCorrectAnswers(432);
data.setCurrentXp(2400);
data.setXpForNextLevel(3000);
data.setUserLevel(5);
data.setProgressPercent(78);
// Näherung: moves = je nach Spiel gelöste Aufgaben bzw. richtige Antworten
// (bei MindMatch sind es Zug-Versuche – bewusst in Kauf genommen).
data.setCorrectAnswers((int) Math.min(gameScoreService.totalMoves(username), Integer.MAX_VALUE));
data.setCurrentXp((int) Math.min(totalXp, Integer.MAX_VALUE));
data.setXpForNextLevel((int) Math.min(nextLevelAt, Integer.MAX_VALUE));
data.setUserLevel(level);
data.setProgressPercent(Math.round(withinLevel * 100f / XP_PER_LEVEL));
data.setStreakDays(computeStreak(gameScoreService.playDaysDesc(username), LocalDate.now()));
return data;
}
/**
* Tages-Streak: Anzahl zusammenhängender Spieltage bis heute. Ein Tag Karenz –
* wer heute noch nicht gespielt hat, behält die Serie von gestern.
*/
static int computeStreak(List<LocalDate> playDaysDesc, LocalDate today) {
if (playDaysDesc.isEmpty()) {
return 0;
}
LocalDate anchor = playDaysDesc.get(0);
if (anchor.isBefore(today.minusDays(1))) {
return 0;
}
int streak = 0;
LocalDate expected = anchor;
for (LocalDate day : playDaysDesc) {
if (day.equals(expected)) {
streak++;
expected = expected.minusDays(1);
} else {
break;
}
}
return streak;
}
/** Echter Spiel-Fortschritt pro Spiel aus der Datenbank (game_scores). */
public List<GameProgress> getGameProgress(String username) {
List<GameProgress> result = new ArrayList<>();
@@ -0,0 +1,11 @@
package com.example.mindflux.service;
/** Ergebnis einer Selbstregistrierung – wird vom RegisterController auf Redirect-Flags gemappt. */
public enum RegistrationResult {
SUCCESS,
USERNAME_INVALID,
USERNAME_TAKEN,
EMAIL_INVALID,
PASSWORD_MISMATCH,
PASSWORD_TOO_SHORT
}
@@ -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;
}
}
@@ -43,8 +43,10 @@ public class TechtacularService {
public TechtacularBoardView buildBoard(Set<Long> exclude) {
Set<Long> excluded = (exclude == null) ? Set.of() : exclude;
List<TechtacularCategory> usable = categoryRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
.stream().filter(c -> !c.getQuestions().isEmpty()).collect(Collectors.toList());
// 2-Query-Muster statt N+1: Kategorien+Fragen in einem Fetch-Join, danach alle
// Antwort-Collections in einer zweiten Query im selben Persistence Context laden.
List<TechtacularCategory> usable = new ArrayList<>(categoryRepository.findActiveWithQuestions());
questionRepository.findActiveWithAnswers();
Collections.shuffle(usable, random);
List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList();
@@ -128,12 +130,14 @@ public class TechtacularService {
// ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (lädt nicht alle Fragen). */
/** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
@Transactional(readOnly = true)
public List<AdminCategoryRow> categoryRows() {
Map<Long, Long> counts = questionRepository.countGroupedByCategory().stream()
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
return categoryRepository.findAllByOrderBySortOrderAscIdAsc().stream()
.map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(),
questionRepository.countByCategoryId(c.getId())))
counts.getOrDefault(c.getId(), 0L)))
.toList();
}
@@ -2,8 +2,34 @@ package com.example.mindflux.service;
import com.example.mindflux.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService {
User findByUsername(String username);
User findByUsernameIgnoreCase(String username);
List<User> findAll();
Optional<User> findById(Long id);
void save(User user);
void deleteByUsername(String username); // <-- Nur die Methodensignatur, ohne {}
/** Selbstregistrierung mit allen Regeln (Passwortlänge, E-Mail-Format, Eindeutigkeit). */
RegistrationResult register(String username, String email, String password, String confirmPassword);
/** Anlage durch Admin/REST – wirft IllegalArgumentException bei Regelverstößen. */
User createUser(String username, String email, String rawPassword, String role);
/** Normalisiert die Rolle (Default USER) und wirft bei unbekannten Rollen. */
String validateRole(String role);
/** Wirft, wenn der Benutzername (case-insensitiv) bereits einem anderen Nutzer gehört. */
void assertUsernameAvailable(String username, Long exceptId);
void deleteByUsername(String username);
void deleteById(Long id);
}
@@ -4,25 +4,61 @@ import com.example.mindflux.model.User;
import com.example.mindflux.repository.AchievementRepository;
import com.example.mindflux.repository.GameScoreRepository;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Zentrale Stelle für alle Benutzer-Operationen: Registrierung, Admin-/REST-Anlage,
* Validierung, Passwort-Encoding und Löschen. Alle Anlage-Pfade laufen hier durch,
* damit die Regeln (BCrypt, Rollen, Eindeutigkeit) nicht auseinanderdriften.
*/
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
private static final int MIN_PASSWORD_LENGTH = 8;
private static final Set<String> KNOWN_ROLES = Set.of("USER", "ADMIN");
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
@Autowired
private GameScoreRepository gameScoreRepository;
private final UserRepository userRepository;
private final GameScoreRepository gameScoreRepository;
private final AchievementRepository achievementRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Autowired
private AchievementRepository achievementRepository;
public UserServiceImpl(UserRepository userRepository,
GameScoreRepository gameScoreRepository,
AchievementRepository achievementRepository,
BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.gameScoreRepository = gameScoreRepository;
this.achievementRepository = achievementRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null); // <-- Optional aufgelöst
return userRepository.findByUsername(username).orElse(null);
}
@Override
public User findByUsernameIgnoreCase(String username) {
return userRepository.findByUsernameIgnoreCase(username).orElse(null);
}
@Override
public List<User> findAll() {
return userRepository.findAll();
}
@Override
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Override
@@ -30,14 +66,85 @@ public class UserServiceImpl implements UserService {
userRepository.save(user);
}
@Override
public RegistrationResult register(String username, String email, String password, String confirmPassword) {
String name = username == null ? "" : username.trim();
if (name.isBlank() || name.length() > 100) {
return RegistrationResult.USERNAME_INVALID;
}
if (password == null || password.length() < MIN_PASSWORD_LENGTH) {
return RegistrationResult.PASSWORD_TOO_SHORT;
}
if (!password.equals(confirmPassword)) {
return RegistrationResult.PASSWORD_MISMATCH;
}
if (email == null || !EMAIL.matcher(email.trim()).matches()) {
return RegistrationResult.EMAIL_INVALID;
}
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
return RegistrationResult.USERNAME_TAKEN;
}
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
try {
userRepository.save(user);
} catch (DataIntegrityViolationException e) {
// Race bei parallelem Insert – der case-insensitive Unique-Index (V9) greift.
return RegistrationResult.USERNAME_TAKEN;
}
return RegistrationResult.SUCCESS;
}
@Override
public User createUser(String username, String email, String rawPassword, String role) {
String name = username == null ? "" : username.trim();
if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("Ungültiger Benutzername.");
}
if (rawPassword == null || rawPassword.length() < MIN_PASSWORD_LENGTH) {
throw new IllegalArgumentException(
"Das Passwort muss mindestens " + MIN_PASSWORD_LENGTH + " Zeichen lang sein.");
}
String checkedRole = validateRole(role);
assertUsernameAvailable(name, null);
User user = new User(name, passwordEncoder.encode(rawPassword),
email == null ? null : email.trim(), checkedRole);
try {
return userRepository.save(user);
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException("Benutzername bereits vergeben.");
}
}
@Override
public String validateRole(String role) {
String normalized = (role == null || role.isBlank()) ? "USER" : role.trim().toUpperCase();
if (!KNOWN_ROLES.contains(normalized)) {
throw new IllegalArgumentException("Unbekannte Rolle: " + role);
}
return normalized;
}
@Override
public void assertUsernameAvailable(String username, Long exceptId) {
userRepository.findByUsernameIgnoreCase(username).ifPresent(existing -> {
if (exceptId == null || !existing.getId().equals(exceptId)) {
throw new IllegalArgumentException("Benutzername bereits vergeben.");
}
});
}
@Override
@Transactional
public void deleteByUsername(String username) {
// Zugehörige Daten mitlöschen (kein FK/Cascade -> manuell), damit keine
// verwaisten Datensätze zurückbleiben und ein neuer gleichnamiger Account
// keine Alt-Daten erbt.
// Defensive Mitlöschung – seit V10 sichern zusätzlich FK-Kaskaden in der DB ab.
gameScoreRepository.deleteByUsername(username);
achievementRepository.deleteByUsername(username);
userRepository.deleteByUsername(username);
}
@Override
@Transactional
public void deleteById(Long id) {
userRepository.findById(id).ifPresent(user -> deleteByUsername(user.getUsername()));
}
}
@@ -0,0 +1,30 @@
-- Datenintegrität: E-Mail eindeutig (case-insensitiv) und referenzielle Integrität
-- für Scores/Achievements. Verwaiste Altdaten werden vorher bereinigt.
-- 1) E-Mail-Dubletten auflösen: Die älteste Zeile behält die Adresse, spätere verlieren sie.
UPDATE users u
SET email = NULL
WHERE u.email IS NOT NULL
AND EXISTS (
SELECT 1 FROM users o
WHERE o.email IS NOT NULL
AND lower(o.email) = lower(u.email)
AND o.id < u.id
);
CREATE UNIQUE INDEX uq_users_email_lower ON users (lower(email)) WHERE email IS NOT NULL;
-- 2) Verwaiste Spielstände/Achievements entfernen (stammen aus Zeiten ohne FK).
DELETE FROM game_scores WHERE username NOT IN (SELECT username FROM users);
DELETE FROM achievements WHERE username IS NOT NULL AND username NOT IN (SELECT username FROM users);
-- 3) Referenzielle Integrität: Löschen räumt Daten mit ab, Umbenennen zieht sie mit um.
ALTER TABLE game_scores
ADD CONSTRAINT fk_game_scores_user
FOREIGN KEY (username) REFERENCES users (username)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE achievements
ADD CONSTRAINT fk_achievements_user
FOREIGN KEY (username) REFERENCES users (username)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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,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);
@@ -0,0 +1,6 @@
-- Benutzernamen case-insensitiv eindeutig machen: Die App prüft bei Login und
-- Registrierung ignoreCase, die DB kannte bisher nur ein case-sensitives UNIQUE.
-- Damit ist auch das Check-then-Insert-Race der Registrierung abgesichert.
-- Voraussetzung: Es existieren keine Bestandsnamen, die sich nur in der
-- Groß-/Kleinschreibung unterscheiden (sonst vorher manuell bereinigen).
CREATE UNIQUE INDEX uq_users_username_lower ON users (lower(username));
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
/* === Dev Lab === Zentraler Hub für Programmier- und Webtechnologien.
Design folgt STYLEGUIDE.md: nur theme.css-Tokens, Prefix dl-. */
body.dl-body {
min-height: 100vh;
color: var(--text);
}
.dl-shell.mf-shell.no-right {
min-height: calc(100vh - 3.5rem);
}
.dl-topbar {
align-items: center;
}
.dl-lab-status {
margin-top: auto;
}
.dl-lab-status h4 {
color: var(--accent);
font-size: .74rem;
letter-spacing: .12em;
text-transform: uppercase;
margin-bottom: .75rem;
}
.dl-lab-status p {
color: var(--muted);
font-size: .78rem;
line-height: 1.45;
margin-top: .65rem;
}
.dl-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dl-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
align-items: center;
min-height: 230px;
overflow: hidden;
position: relative;
}
.dl-hero::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent 0 31px, var(--line) 32px, transparent 33px),
linear-gradient(0deg, transparent 0 31px, var(--line) 32px, transparent 33px);
background-size: 34px 34px;
opacity: .2;
pointer-events: none;
}
.dl-hero > * {
position: relative;
z-index: 1;
}
.dl-hero h2 {
max-width: 760px;
color: var(--heading);
font-size: clamp(1.6rem, 3.2vw, 2.7rem);
line-height: 1.08;
margin: .4rem 0 .75rem;
}
.dl-hero p:not(.mf-eyebrow) {
max-width: 780px;
color: var(--muted);
line-height: 1.6;
}
.dl-tube {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 190px;
color: var(--accent);
filter: drop-shadow(0 0 16px var(--glow-s));
}
.dl-tube svg {
width: 132px;
height: 176px;
}
.dl-tube span {
position: absolute;
font-family: "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace;
color: var(--secondary);
font-weight: 800;
text-shadow: 0 0 12px var(--glow-w);
animation: dlFloat 4.8s ease-in-out infinite;
}
.dl-tube span:nth-child(2) { top: 55px; left: 72px; animation-delay: 0s; }
.dl-tube span:nth-child(3) { top: 82px; right: 46px; animation-delay: -.9s; }
.dl-tube span:nth-child(4) { bottom: 56px; left: 80px; animation-delay: -1.8s; }
.dl-tube span:nth-child(5) { bottom: 82px; right: 66px; animation-delay: -2.8s; }
.dl-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
gap: 1rem;
align-items: start;
}
.dl-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: .9rem;
}
.dl-panel-head h2,
.dl-panel-head h3 {
color: var(--heading);
font-size: 1rem;
}
.dl-station-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .8rem;
}
.dl-station {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: .75rem;
width: 100%;
min-height: 126px;
padding: .9rem;
text-align: left;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 14px;
cursor: pointer;
transition: transform .16s ease, border-color .18s ease, box-shadow .2s ease, background-color .2s ease;
}
.dl-station:hover,
.dl-station.active {
transform: translateY(-2px);
border-color: var(--border-accent);
box-shadow: 0 0 18px var(--glow-w);
}
.dl-station.active {
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
}
.dl-station-icon,
.dl-world-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--surface);
border: 1px solid var(--border-accent);
box-shadow: inset 0 0 18px var(--glow-w), 0 0 12px var(--glow-w);
}
.dl-station-icon {
width: 54px;
height: 54px;
border-radius: 13px;
font-size: 1rem;
font-weight: 900;
}
.dl-station h3 {
color: var(--heading);
font-size: 1rem;
margin-bottom: .25rem;
}
.dl-station p {
color: var(--muted);
font-size: .82rem;
line-height: 1.45;
}
.dl-station-meta {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin-top: .65rem;
}
.dl-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: .18rem .48rem;
border-radius: 7px;
border: 1px solid var(--border);
color: var(--muted);
font-size: .68rem;
font-weight: 800;
letter-spacing: .03em;
text-transform: uppercase;
}
.dl-world {
position: sticky;
top: 1rem;
}
.dl-world-head {
display: flex;
align-items: center;
gap: .85rem;
margin-bottom: .8rem;
}
.dl-world-icon {
width: 62px;
height: 62px;
border-radius: 16px;
font-size: 1.1rem;
font-weight: 900;
}
.dl-world h2 {
color: var(--heading);
font-size: 1.5rem;
}
.dl-world-copy {
color: var(--muted);
line-height: 1.55;
}
.dl-progression {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .55rem;
margin: 1rem 0;
}
.dl-progression div {
padding: .65rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
}
.dl-progression span {
display: block;
color: var(--muted);
font-size: .66rem;
letter-spacing: .08em;
text-transform: uppercase;
}
.dl-progression b {
display: block;
color: var(--heading);
font-size: .95rem;
margin-top: .18rem;
}
.dl-mechanics {
display: flex;
flex-wrap: wrap;
gap: .45rem;
margin-bottom: 1rem;
}
.dl-mechanics .dl-chip {
color: var(--secondary);
border-color: var(--secondary);
background: color-mix(in srgb, var(--secondary) 12%, transparent);
}
.dl-games-head {
margin-top: .2rem;
}
.dl-game-list {
display: grid;
gap: .65rem;
}
.dl-game {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: .85rem;
align-items: center;
padding: .8rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
}
.dl-game h4 {
color: var(--heading);
font-size: .95rem;
margin-bottom: .25rem;
}
.dl-game p {
color: var(--muted);
font-size: .8rem;
line-height: 1.45;
}
.dl-game-actions {
display: flex;
flex-direction: column;
gap: .4rem;
align-items: flex-end;
}
.dl-game-state {
font-size: .66rem;
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.dl-game.playable .dl-game-state {
color: var(--success);
}
.dl-game .mf-btn {
min-width: 92px;
padding: .48rem .75rem;
font-size: .72rem;
}
@keyframes dlFloat {
0%, 100% { transform: translateY(0); opacity: .75; }
50% { transform: translateY(-9px); opacity: 1; }
}
@media (max-width: 1120px) {
.dl-workbench {
grid-template-columns: 1fr;
}
.dl-world {
position: static;
}
}
@media (max-width: 760px) {
.dl-hero {
grid-template-columns: 1fr;
min-height: auto;
padding-top: 3.2rem;
}
.dl-tube {
display: none;
}
.dl-station-grid {
grid-template-columns: 1fr;
}
.dl-progression {
grid-template-columns: 1fr;
}
.dl-game {
grid-template-columns: 1fr;
}
.dl-game-actions {
align-items: flex-start;
}
}
@@ -0,0 +1,119 @@
/* === Dev Lab – Spiel-Engine === polymorphe Mechaniken, Farben/Tokens zentral aus theme.css
(richtig = Blau, falsch = Rot, Akzent = Amber). Keine hartkodierten Farben. */
@keyframes dlgIn { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: translateY(0); } }
body.dlg-body { min-height: 100vh; display: flex; flex-direction: column; color: var(--text); }
.dlg-shell { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; padding: 0.7rem 0.9rem 1.4rem; gap: 0.7rem; }
.dlg-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.5rem 1.1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--g-soft); }
.dlg-head-title { text-align: center; }
.dlg-head-title h1 { color: var(--accent); font-size: 1.25rem; text-shadow: 0 0 8px var(--glow-s); }
.dlg-header-tools { display: flex; align-items: center; gap: 0.7rem; }
.dlg-back, .dlg-logout { text-decoration: none; font-weight: bold; }
.dlg-back { color: var(--accent); } .dlg-logout { color: var(--danger); }
.dlg-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.55rem; }
.dlg-bar-group { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.dlg-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; }
.dlg-stat b { font-size: 1.05rem; color: var(--accent); }
.dlg-stage { flex: 1; display: grid; grid-template-columns: 1fr 300px; gap: 0.8rem; align-items: start; }
.dlg-card { background: var(--surface); border: 1px solid var(--border-accent); border-radius: 16px; padding: 1.3rem 1.5rem 1.4rem; box-shadow: 0 0 24px var(--glow-w); animation: dlgIn 0.32s ease-out; min-width: 0; }
.dlg-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.dlg-points { color: var(--accent); font-weight: 800; }
.dlg-card h2 { color: var(--heading); font-size: 1.3rem; font-weight: 800; margin-bottom: 0.4rem; }
.dlg-narrative { color: var(--text); line-height: 1.55; margin-bottom: 0.5rem; }
.dlg-narrative[hidden], .dlg-instruction[hidden] { display: none; }
.dlg-instruction { color: var(--muted); font-size: 0.9rem; margin-bottom: 0.7rem; }
.dlg-playfield { margin: 0.4rem 0 0.8rem; }
.dlg-mini { color: var(--muted); font-size: 0.78rem; margin: 0.5rem 0 0.3rem; }
.dlg-mini.reveal { color: var(--secondary); font-family: monospace; }
/* Code + Lücken (cloze) */
.dlg-code { background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.1rem; overflow-x: auto; box-shadow: inset 0 0 22px var(--glow-w); }
.dlg-code, .dlg-code code { font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; font-size: 0.92rem; line-height: 2; white-space: pre; color: var(--text); }
.dlg-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; }
.dlg-gap::placeholder { color: var(--muted); opacity: 0.7; }
.dlg-gap:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); }
.dlg-gap.correct { color: var(--success); border-color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); }
.dlg-gap.wrong { color: var(--danger); border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, transparent); }
.dlg-gap.revealed { color: var(--secondary); border-color: var(--secondary); background: color-mix(in srgb, var(--secondary) 14%, transparent); }
/* Multiple Choice */
.dlg-question { color: var(--heading); font-weight: 600; margin-bottom: 0.6rem; }
.dlg-choices { display: grid; gap: 0.55rem; }
.dlg-choice { text-align: left; padding: 0.7rem 1rem; border: 1px solid var(--border-accent); border-radius: 10px; background: var(--surface-2); color: var(--text); font-size: 0.98rem; cursor: pointer; transition: border-color .18s, background .18s; }
.dlg-choice:hover:not(:disabled) { border-color: var(--accent); background: var(--surface); }
.dlg-choice.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); }
.dlg-choice.correct { border-color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); color: var(--success); }
.dlg-choice.wrong { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
.dlg-choice:disabled { cursor: default; }
/* Tokens (order + spot) */
.dlg-token, .dlg-spot-token { font-family: "JetBrains Mono", "Consolas", monospace; font-size: 0.9rem; padding: 0.4rem 0.7rem; border: 1px solid var(--border-accent); border-radius: 8px; background: var(--surface-2); color: var(--text); cursor: pointer; transition: all .15s; }
.dlg-token:hover:not(:disabled), .dlg-spot-token:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); }
.dlg-order-answer { min-height: 2.6rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.6rem; border: 1px dashed var(--border-accent); border-radius: 10px; background: color-mix(in srgb, var(--accent) 5%, transparent); }
.dlg-order-answer:not(.filled)::before { content: attr(data-empty); color: var(--muted); font-size: 0.82rem; }
.dlg-order-bank { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.dlg-spot { display: flex; flex-wrap: wrap; gap: 0.45rem; padding: 0.8rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; }
.dlg-spot-token.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); color: var(--accent); }
.dlg-spot-token.correct { border-color: var(--success); color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.dlg-spot-token.wrong { border-color: var(--danger); color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
/* Zuordnen (match) */
.dlg-match { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; }
.dlg-match-col { display: flex; flex-direction: column; gap: 0.5rem; }
.dlg-match-item { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.6rem 0.8rem; border: 1px solid var(--border-accent); border-radius: 10px; background: var(--surface-2); color: var(--text); cursor: pointer; text-align: left; transition: all .15s; }
.dlg-match-item:hover:not(:disabled) { border-color: var(--accent); }
.dlg-match-item.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); }
.dlg-match-item.connected { border-color: var(--secondary); background: color-mix(in srgb, var(--secondary) 12%, transparent); }
.dlg-match-badge { min-width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 0.72rem; font-weight: 800; color: var(--accent-ink); background: transparent; }
.dlg-match-item.connected .dlg-match-badge { background: var(--secondary); }
/* Regex */
.dlg-regex { display: flex; flex-direction: column; gap: 0.7rem; }
.dlg-regex-input { font-family: "JetBrains Mono", "Consolas", monospace; font-size: 1rem; padding: 0.6rem 0.8rem; border: 1px solid var(--border-accent); border-radius: 10px; background: var(--surface-2); color: var(--accent); }
.dlg-regex-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); }
.dlg-regex-samples { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.dlg-sample { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.4rem 0.7rem; border: 1px solid var(--border); border-radius: 9px; font-family: "JetBrains Mono", monospace; font-size: 0.85rem; background: var(--surface-2); }
.dlg-sample small { font-family: inherit; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.dlg-sample.ok { border-color: var(--success); color: var(--success); }
.dlg-sample.bad { border-color: var(--danger); color: var(--danger); }
/* Feedback */
.dlg-feedback { min-height: 1.3rem; font-weight: 700; margin-bottom: 0.4rem; line-height: 1.5; }
.dlg-feedback.correct { color: var(--success); } .dlg-feedback.correct::before { content: "\2713 "; }
.dlg-feedback.wrong { color: var(--danger); } .dlg-feedback.wrong::before { content: "\2717 "; }
.dlg-feedback.revealed { color: var(--secondary); }
.dlg-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.7rem; line-height: 1.5; }
.dlg-success[hidden] { display: none; }
.dlg-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.dlg-actions .mf-btn[hidden] { display: none; }
/* Seitenpanel */
.dlg-side h2 { color: var(--accent); font-size: 1rem; margin-bottom: 0.5rem; }
.dlg-description { color: var(--muted); font-size: 0.88rem; line-height: 1.5; margin-bottom: 0.9rem; }
.dlg-progress span { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; }
.dlg-progress .mf-bar { margin-top: 0.3rem; }
.dlg-best { color: var(--muted); font-size: 0.82rem; margin-top: 0.9rem; }
/* Ergebnis-Overlay */
.dlg-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--bg-glow), rgba(0,0,0,.55); backdrop-filter: blur(4px); z-index: 1000; padding: 1rem; }
.dlg-overlay[hidden] { display: none; }
.dlg-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: dlgIn 0.4s ease-out; }
.dlg-result-card h2 { color: var(--accent); margin-bottom: 0.4rem; }
.dlg-result-stars { font-size: 2rem; color: var(--accent); letter-spacing: 0.2rem; margin-bottom: 0.5rem; text-shadow: 0 0 12px var(--glow-s); }
.dlg-result-score { font-size: 1.35rem; color: var(--text); margin-bottom: 0.4rem; } .dlg-result-score strong { color: var(--accent); }
.dlg-result-detail { color: var(--muted); margin-bottom: 0.5rem; }
.dlg-result-high { color: var(--accent); font-weight: 800; margin-bottom: 0.8rem; }
.dlg-result-high[hidden] { display: none; }
.dlg-result-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; margin-top: 1rem; }
@media (max-width: 820px) { .dlg-stage { grid-template-columns: 1fr; } }
@media (max-width: 560px) {
.dlg-head-title h1 { font-size: 1.05rem; }
.dlg-stat { font-size: 0.62rem; padding: 0.3rem 0.5rem; }
.dlg-code, .dlg-code code { font-size: 0.82rem; }
}
@@ -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; }
}
+6
View File
@@ -203,3 +203,9 @@ body.mf-nav-open .mf-nav-backdrop { opacity:1; pointer-events:auto; }
}
@media (max-width:560px){ .mf-grid{grid-template-columns:1fr;} }
/* ===== Profilbereich: Tab-Navigation (Fragment fragments/layout :: profileTabs) ===== */
.mf-tab-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-bottom:1.4rem; }
.mf-tab { padding:.55rem .95rem; border-radius:10px; border:1px solid var(--border); color:var(--muted); text-decoration:none; font-size:.85rem; font-weight:700; transition:all .18s ease; }
.mf-tab:hover { color:var(--text); background:var(--surface-2); }
.mf-tab.active { color:var(--accent-ink); background:linear-gradient(100deg,var(--accent),var(--accent-2)); box-shadow:0 0 18px var(--glow-s); border-color:transparent; }
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Entwurfsmuster - FinalFlux AP2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/finalflux.css">
<script src="/js/theme.js"></script>
</head>
<body class="finalflux-page">
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main>
<a href="/finalflux" class="back-link">Zurück zu FinalFlux</a>
<h1>Entwurfsmuster (Design Patterns)</h1>
<section>
<h2>Lernfokus</h2>
<p>AP2 Anwendungsentwicklung: die prüfungsrelevanten Entwurfsmuster erkennen, erklären und dem richtigen Problem zuordnen.</p>
<ul class="key-list">
<li>Zweck von Entwurfsmustern erklären.</li>
<li>Singleton, Factory und Observer unterscheiden.</li>
<li>MVC-Schichten und ihre Aufgaben zuordnen.</li>
</ul>
</section>
<section>
<h2>Was sind Entwurfsmuster?</h2>
<p>Entwurfsmuster sind bewährte, wiederverwendbare Lösungsschablonen für häufige Probleme im Software-Design. Sie sind kein fertiger Code, sondern beschreiben eine Struktur aus Klassen und deren Zusammenspiel. In der AP2 wird typischerweise verlangt, ein Muster zu benennen, seinen Zweck zu erklären oder es in einem UML-Diagramm wiederzuerkennen.</p>
<p class="merksatz"><strong>Merksatz:</strong> Ein Entwurfsmuster ist eine Schablone für ein Entwurfsproblem – nicht der Code selbst.</p>
</section>
<section>
<h2>Singleton</h2>
<p>Stellt sicher, dass von einer Klasse <strong>genau eine Instanz</strong> existiert, und bietet einen globalen Zugriffspunkt darauf. Typische Einsätze: Konfiguration, Logger, Verbindungs-Pools.</p>
<ul class="key-list">
<li><strong>Privater Konstruktor:</strong> verhindert Instanziierung von außen.</li>
<li><strong>Statische Zugriffsmethode</strong> (z. B. <code>getInstance()</code>) liefert immer dieselbe Instanz.</li>
<li><strong>Kritik:</strong> globaler Zustand erschwert Tests – in der Prüfung als Nachteil nennen können.</li>
</ul>
</section>
<section>
<h2>Factory (Fabrikmethode)</h2>
<p>Kapselt die Objekterzeugung: Der aufrufende Code kennt nur das Interface, die Fabrik entscheidet, welche konkrete Klasse instanziiert wird. So lassen sich neue Varianten ergänzen, ohne den Client-Code zu ändern (Offen-Geschlossen-Prinzip).</p>
<ul class="key-list">
<li><strong>Problem:</strong> <code>new KonkreteKlasse()</code> überall verstreut macht Änderungen teuer.</li>
<li><strong>Lösung:</strong> zentrale Erzeugungsmethode, die das passende Objekt liefert.</li>
</ul>
</section>
<section>
<h2>Observer (Beobachter)</h2>
<p>Ein Subjekt hält eine Liste von Beobachtern und benachrichtigt sie automatisch bei Zustandsänderungen (1:n-Abhängigkeit). Grundlage vieler GUI- und Event-Systeme.</p>
<ul class="key-list">
<li><strong>Subjekt:</strong> <code>anmelden()</code>, <code>abmelden()</code>, <code>benachrichtigen()</code>.</li>
<li><strong>Beobachter:</strong> gemeinsames Interface mit <code>aktualisiere()</code>.</li>
<li><strong>Vorteil:</strong> lose Kopplung – das Subjekt kennt nur das Interface.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> Observer = abonnieren und benachrichtigt werden, statt ständig nachzufragen (Polling).</p>
</section>
<section>
<h2>MVC (Model-View-Controller)</h2>
<p>Architektur-Muster, das eine Anwendung in drei Verantwortlichkeiten trennt. In der AP2 häufig als Zuordnungsaufgabe gefragt.</p>
<ul class="key-list">
<li><strong>Model:</strong> Daten und Geschäftslogik – unabhängig von der Darstellung.</li>
<li><strong>View:</strong> Präsentation der Daten für den Benutzer.</li>
<li><strong>Controller:</strong> nimmt Eingaben entgegen, ruft das Model auf, wählt die View.</li>
</ul>
<p>Vorteile: getrennte Zuständigkeiten, parallele Entwicklung, bessere Testbarkeit, austauschbare Oberflächen.</p>
</section>
<section>
<h2>Prüfungsmodus</h2>
<p>Dieser FluxCheck führt dich durch die wichtigsten AP2-Aspekte dieses Themas. Du kannst Antworten prüfen, Fragen überspringen und bekommst am Ende eine Auswertung nach Kategorie.</p>
</section>
<section class="finalflux-test" data-finalflux-test aria-live="polite">
<div class="test-meta">
<span data-question-counter>Frage 1</span>
<span data-score-counter>0 richtig</span>
</div>
<div class="category-block">
<p class="eyebrow">Kategorie</p>
<h2 data-category-title>Entwurfsmuster</h2>
<p data-category-hint>AP2-Training</p>
</div>
<div class="question-card">
<h3 data-question-text></h3>
<div data-answer-options class="answer-options"></div>
<div data-answer-feedback class="fluxcheck-result"></div>
</div>
<div class="test-actions">
<button type="button" data-check-answer>Antwort prüfen</button>
<button type="button" data-skip-question class="secondary">Überspringen</button>
<button type="button" data-next-question class="secondary" disabled>Weiter</button>
</div>
</section>
<section data-test-result class="finalflux-panel" hidden>
<h2>Auswertung</h2>
<p data-result-summary></p>
<ul data-category-summary class="key-list"></ul>
<div class="finalflux-actions">
<button type="button" data-restart-test>Test neu starten</button>
<a href="/finalflux">Zurück zur Übersicht</a>
</div>
</section>
</main>
<script src="/js/finalflux-test.js" data-questions-src="/finalflux/questions/ap2_fiae_entwurfsmuster.json" defer></script>
</body>
</html>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Hochverfügbarkeit - FinalFlux AP2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/finalflux.css">
<script src="/js/theme.js"></script>
</head>
<body class="finalflux-page">
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main>
<a href="/finalflux" class="back-link">Zurück zu FinalFlux</a>
<h1>Hochverfügbarkeit (HA)</h1>
<section>
<h2>Lernfokus</h2>
<p>AP2 Systemintegration: Verfügbarkeit berechnen, Redundanzkonzepte unterscheiden und RTO/RPO sicher einordnen.</p>
<ul class="key-list">
<li>Verfügbarkeit in Prozent und Ausfallzeit umrechnen.</li>
<li>Redundanzkonzepte (RAID, Cluster, USV, Netzteil) einordnen.</li>
<li>RTO und RPO unterscheiden.</li>
</ul>
</section>
<section>
<h2>Was bedeutet Hochverfügbarkeit?</h2>
<p>Hochverfügbarkeit beschreibt die Fähigkeit eines Systems, trotz Ausfällen einzelner Komponenten weiterzulaufen. Die Verfügbarkeit wird in Prozent der Betriebszeit angegeben – die bekannten „Neunen": 99 % bedeuten rund 3,65 Tage Ausfall pro Jahr, 99,9 % rund 8,76 Stunden, 99,99 % rund 53 Minuten.</p>
<ul class="key-list">
<li><strong>Formel:</strong> Verfügbarkeit = (Gesamtzeit − Ausfallzeit) / Gesamtzeit × 100 %.</li>
<li><strong>SPOF (Single Point of Failure):</strong> jede Komponente, deren Ausfall das Gesamtsystem stoppt – Ziel ist, SPOFs durch Redundanz zu eliminieren.</li>
<li><strong>MTBF/MTTR:</strong> mittlere Zeit zwischen Ausfällen bzw. mittlere Reparaturzeit.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> Jede zusätzliche „Neun" kostet überproportional mehr – Verfügbarkeit ist immer eine Kosten-Nutzen-Abwägung.</p>
</section>
<section>
<h2>Redundanzkonzepte</h2>
<p>Redundanz bedeutet, kritische Komponenten mehrfach auszulegen, damit der Ausfall einer Komponente abgefangen wird.</p>
<ul class="key-list">
<li><strong>RAID:</strong> schützt gegen Ausfall einzelner Festplatten – <em>ersetzt aber kein Backup</em> (gelöschte Daten sind auf allen Platten gelöscht).</li>
<li><strong>Cluster (Failover):</strong> zweiter Server übernimmt automatisch, wenn der aktive ausfällt (aktiv/passiv) oder Last wird verteilt (aktiv/aktiv).</li>
<li><strong>USV:</strong> überbrückt Stromausfälle und ermöglicht geordnetes Herunterfahren.</li>
<li><strong>Redundante Netzteile/Netzwerkpfade:</strong> doppelte Stromversorgung, zwei Switches/Uplinks gegen SPOFs im Netz.</li>
<li><strong>Georedundanz:</strong> zweiter Standort gegen Standortausfall (Brand, Hochwasser).</li>
</ul>
</section>
<section>
<h2>RTO und RPO</h2>
<p>Zwei Kennzahlen aus der Notfallplanung, die in der AP2 gern verwechselt werden:</p>
<ul class="key-list">
<li><strong>RTO (Recovery Time Objective):</strong> Wie lange darf die Wiederherstellung maximal dauern? → maximale Ausfall<em>zeit</em>.</li>
<li><strong>RPO (Recovery Point Objective):</strong> Wie viele Daten dürfen maximal verloren gehen? → maximaler Daten<em>verlust</em>, bestimmt die Backup-Frequenz.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> RTO = Zeit bis alles wieder läuft, RPO = Zeitraum an Daten, der fehlen darf.</p>
</section>
<section>
<h2>Prüfungsmodus</h2>
<p>Dieser FluxCheck führt dich durch die wichtigsten AP2-Aspekte dieses Themas. Du kannst Antworten prüfen, Fragen überspringen und bekommst am Ende eine Auswertung nach Kategorie.</p>
</section>
<section class="finalflux-test" data-finalflux-test aria-live="polite">
<div class="test-meta">
<span data-question-counter>Frage 1</span>
<span data-score-counter>0 richtig</span>
</div>
<div class="category-block">
<p class="eyebrow">Kategorie</p>
<h2 data-category-title>Hochverfügbarkeit</h2>
<p data-category-hint>AP2-Training</p>
</div>
<div class="question-card">
<h3 data-question-text></h3>
<div data-answer-options class="answer-options"></div>
<div data-answer-feedback class="fluxcheck-result"></div>
</div>
<div class="test-actions">
<button type="button" data-check-answer>Antwort prüfen</button>
<button type="button" data-skip-question class="secondary">Überspringen</button>
<button type="button" data-next-question class="secondary" disabled>Weiter</button>
</div>
</section>
<section data-test-result class="finalflux-panel" hidden>
<h2>Auswertung</h2>
<p data-result-summary></p>
<ul data-category-summary class="key-list"></ul>
<div class="finalflux-actions">
<button type="button" data-restart-test>Test neu starten</button>
<a href="/finalflux">Zurück zur Übersicht</a>
</div>
</section>
</main>
<script src="/js/finalflux-test.js" data-questions-src="/finalflux/questions/ap2_fisi_hochverfuegbarkeit.json" defer></script>
</body>
</html>
@@ -0,0 +1,77 @@
{
"title": "Entwurfsmuster",
"questions": [
{
"category": "Grundlagen",
"hint": "Zweck von Entwurfsmustern.",
"text": "Was ist ein Entwurfsmuster?",
"answers": [
"Eine bewährte, wiederverwendbare Lösungsschablone für ein häufiges Entwurfsproblem.",
"Eine fertige Code-Bibliothek, die direkt eingebunden wird.",
"Ein Werkzeug zur automatischen Codegenerierung aus UML-Diagrammen."
],
"correct": 0,
"explanation": "Ein Entwurfsmuster beschreibt eine Struktur und ihr Zusammenspiel – es ist kein fertiger Code, sondern eine Schablone."
},
{
"category": "Singleton",
"hint": "Genau eine Instanz.",
"text": "Welches Merkmal gehört zwingend zum Singleton-Muster?",
"answers": [
"Ein öffentlicher Konstruktor, damit jede Klasse Instanzen erzeugen kann.",
"Ein privater Konstruktor und eine statische Zugriffsmethode auf die einzige Instanz.",
"Eine Liste von Beobachtern, die bei Änderungen benachrichtigt werden."
],
"correct": 1,
"explanation": "Der private Konstruktor verhindert Fremd-Instanziierung; getInstance() liefert immer dieselbe Instanz."
},
{
"category": "Factory",
"hint": "Objekterzeugung kapseln.",
"text": "Welches Problem löst die Fabrikmethode (Factory)?",
"answers": [
"Sie sorgt dafür, dass Objekte automatisch gelöscht werden.",
"Sie verschlüsselt die Kommunikation zwischen Objekten.",
"Sie kapselt die Objekterzeugung, sodass der Client nur das Interface kennt."
],
"correct": 2,
"explanation": "Die Fabrik entscheidet, welche konkrete Klasse instanziiert wird – neue Varianten erfordern keine Änderung im Client-Code."
},
{
"category": "Observer",
"hint": "1:n-Abhängigkeit.",
"text": "Wann ist das Observer-Muster die richtige Wahl?",
"answers": [
"Wenn mehrere Objekte automatisch über Zustandsänderungen eines Objekts informiert werden sollen.",
"Wenn von einer Klasse nur eine einzige Instanz existieren darf.",
"Wenn die Erzeugung von Objekten zentralisiert werden soll."
],
"correct": 0,
"explanation": "Das Subjekt benachrichtigt alle angemeldeten Beobachter – lose gekoppelt über ein gemeinsames Interface."
},
{
"category": "MVC",
"hint": "Drei Verantwortlichkeiten.",
"text": "Welche Aufgabe hat der Controller im MVC-Muster?",
"answers": [
"Er speichert die Daten und enthält die Geschäftslogik.",
"Er nimmt Benutzereingaben entgegen, ruft das Model auf und wählt die View.",
"Er stellt die Daten grafisch für den Benutzer dar."
],
"correct": 1,
"explanation": "Model = Daten/Logik, View = Darstellung, Controller = Vermittler zwischen Eingabe, Model und View."
},
{
"category": "MVC",
"hint": "Vorteile der Trennung.",
"text": "Welcher Vorteil ergibt sich direkt aus der MVC-Trennung?",
"answers": [
"Die Anwendung benötigt keine Datenbank mehr.",
"Der Quellcode wird automatisch schneller ausgeführt.",
"Oberfläche und Logik sind getrennt test- und austauschbar."
],
"correct": 2,
"explanation": "Durch die Trennung der Schichten lassen sich Views austauschen und Model-Logik isoliert testen."
}
]
}
@@ -0,0 +1,77 @@
{
"title": "Hochverfügbarkeit",
"questions": [
{
"category": "Grundlagen",
"hint": "Die Neunen kennen.",
"text": "Ein System hat eine Verfügbarkeit von 99,9 % pro Jahr. Wie viel Ausfallzeit entspricht das ungefähr?",
"answers": [
"Rund 8,8 Stunden pro Jahr.",
"Rund 3,65 Tage pro Jahr.",
"Rund 53 Minuten pro Jahr."
],
"correct": 0,
"explanation": "0,1 % von 8760 Stunden sind rund 8,76 Stunden. 3,65 Tage gehören zu 99 %, 53 Minuten zu 99,99 %."
},
{
"category": "Grundlagen",
"hint": "Kritische Einzelkomponenten.",
"text": "Was ist ein Single Point of Failure (SPOF)?",
"answers": [
"Eine Komponente, die doppelt ausgelegt ist und darum nie ausfällt.",
"Eine Komponente, deren Ausfall das gesamte System zum Stillstand bringt.",
"Der zentrale Monitoring-Server eines Rechenzentrums."
],
"correct": 1,
"explanation": "Ein SPOF ist jede nicht redundante Komponente, an der das Gesamtsystem hängt – Ziel ist, SPOFs durch Redundanz zu eliminieren."
},
{
"category": "Redundanz",
"hint": "RAID richtig einordnen.",
"text": "Warum ersetzt ein RAID kein Backup?",
"answers": [
"Weil RAID nur bei SSDs funktioniert, Backups aber auf Festplatten liegen.",
"Weil RAID die Daten komprimiert und dadurch Datenverlust entsteht.",
"Weil versehentlich gelöschte oder verschlüsselte Daten sofort auf allen Platten betroffen sind."
],
"correct": 2,
"explanation": "RAID schützt gegen Hardware-Ausfall einzelner Platten. Logische Fehler (Löschen, Ransomware) treffen alle Kopien gleichzeitig – dagegen hilft nur ein Backup."
},
{
"category": "Redundanz",
"hint": "Cluster-Betriebsarten.",
"text": "Was kennzeichnet einen Aktiv/Passiv-Failover-Cluster?",
"answers": [
"Ein Standby-Server übernimmt automatisch, wenn der aktive Server ausfällt.",
"Beide Server bearbeiten dauerhaft gemeinsam die Last.",
"Die Server sichern sich gegenseitig täglich per Backup."
],
"correct": 0,
"explanation": "Beim Aktiv/Passiv-Cluster steht der zweite Knoten bereit und übernimmt beim Ausfall (Failover). Aktiv/Aktiv verteilt dagegen die Last auf beide."
},
{
"category": "RTO & RPO",
"hint": "Zeit vs. Datenverlust.",
"text": "Welche Aussage zu RTO und RPO ist korrekt?",
"answers": [
"RTO begrenzt den maximalen Datenverlust, RPO die Wiederherstellungszeit.",
"RTO begrenzt die maximale Wiederherstellungszeit, RPO den maximalen Datenverlust.",
"RTO und RPO bezeichnen dasselbe und sind austauschbar."
],
"correct": 1,
"explanation": "RTO = wie lange darf die Wiederherstellung dauern; RPO = wie alt darf der letzte Datenstand sein (bestimmt die Backup-Frequenz)."
},
{
"category": "RTO & RPO",
"hint": "Backup-Frequenz ableiten.",
"text": "Ein Unternehmen definiert einen RPO von 1 Stunde. Was folgt daraus?",
"answers": [
"Das System muss nach einem Ausfall innerhalb 1 Stunde wieder laufen.",
"Die USV muss das System mindestens 1 Stunde überbrücken.",
"Die Daten müssen mindestens stündlich gesichert bzw. repliziert werden."
],
"correct": 2,
"explanation": "Der RPO begrenzt den maximal tolerierten Datenverlust – bei 1 Stunde RPO darf der letzte Sicherungsstand höchstens 1 Stunde alt sein."
}
]
}
+76
View File
@@ -0,0 +1,76 @@
/* Dev Lab Hub – lädt den Spiele-Katalog aus /api/devlab/catalog (DB-gestützt) und gruppiert
die Spiele nach Trainingspfad. Spielkarten verlinken auf /devlab/games/{slug}. */
(function () {
const PATHS = [
{ id: 'syntax', marker: '{}', title: 'Syntax & Grundlagen', type: 'Grundlagenpfad',
copy: 'Sprachsyntax, Strukturen und Muster verstehen, bevor der Code komplex wird.',
mechanics: ['Lücken füllen', 'Zusammensetzen', 'Multiple Choice', 'Regex live'], badge: 'Syntax Core' },
{ id: 'debugging', marker: '</>', title: 'Debugging & Problemlösung', type: 'Ermittlungspfad',
copy: 'Fehler finden, Ursache erkennen und den passenden Reparaturschritt wählen.',
mechanics: ['Bug finden', 'Lücken füllen', 'Befehlsfolgen', 'Storyfälle'], badge: 'Bug Hunter' },
{ id: 'visual', marker: '[]', title: 'Praxis & Visualisierung', type: 'Werkstattpfad',
copy: 'Direkt sehen, was Regeln, Layouts, APIs und Kaskaden im System bewirken.',
mechanics: ['Zuordnen', 'Multiple Choice', 'Statuscodes', 'Kaskade'], badge: 'Visual Builder' }
];
const pathRoot = document.getElementById('dl-stations');
const gameRoot = document.getElementById('dl-games');
const mechanicsRoot = document.getElementById('dl-mechanics');
const ui = {
title: document.getElementById('dl-world-title'), type: document.getElementById('dl-world-type'),
icon: document.getElementById('dl-world-icon'), copy: document.getElementById('dl-world-copy'),
levels: document.getElementById('dl-levels'), xp: document.getElementById('dl-xp'),
badge: document.getElementById('dl-badge'), gameCount: document.getElementById('dl-game-count')
};
const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
let byPath = {};
function renderStations(activeId) {
pathRoot.innerHTML = PATHS.map((p) => {
const games = byPath[p.id] || [];
return `<button type="button" class="dl-station${p.id === activeId ? ' active' : ''}" data-path="${p.id}">
<span class="dl-station-icon">${esc(p.marker)}</span>
<span><h3>${esc(p.title)}</h3><p>${esc(p.copy)}</p>
<span class="dl-station-meta"><span class="dl-chip">${games.length} Spiele</span><span class="dl-chip">${esc(p.type)}</span></span>
</span></button>`;
}).join('');
}
function renderPath(p) {
const games = byPath[p.id] || [];
const totalTasks = games.reduce((sum, g) => sum + (g.taskCount || 0), 0);
ui.title.textContent = p.title; ui.type.textContent = p.type; ui.icon.textContent = p.marker;
ui.copy.textContent = p.copy; ui.levels.textContent = games.length; ui.xp.textContent = totalTasks; ui.badge.textContent = p.badge;
ui.gameCount.textContent = `${games.length} Spiele`;
mechanicsRoot.innerHTML = p.mechanics.map((m) => `<span class="dl-chip">${esc(m)}</span>`).join('');
gameRoot.innerHTML = games.length ? games.map((g) => `
<article class="dl-game playable">
<div><h4>${esc(g.title)}</h4><p>${esc(g.description || '')}</p></div>
<div class="dl-game-actions">
<span class="dl-game-state">${g.taskCount} Aufgaben</span>
<a class="mf-btn" href="/devlab/games/${encodeURIComponent(g.slug)}">Starten</a>
</div>
</article>`).join('') : '<p class="dl-empty">Für diesen Pfad sind noch keine Spiele freigeschaltet.</p>';
renderStations(p.id);
}
pathRoot.addEventListener('click', (e) => {
const btn = e.target.closest('.dl-station');
if (!btn) return;
const p = PATHS.find((x) => x.id === btn.dataset.path);
if (p) renderPath(p);
});
fetch('/api/devlab/catalog')
.then((res) => res.json())
.then((data) => {
byPath = {};
(data.games || []).forEach((g) => {
const key = PATHS.some((p) => p.id === g.path) ? g.path : 'syntax';
(byPath[key] = byPath[key] || []).push(g);
});
renderPath(PATHS[0]);
})
.catch(() => renderPath(PATHS[0]));
})();
+377
View File
@@ -0,0 +1,377 @@
/* Dev Lab – polymorphe Spiel-Engine. Aufgaben kommen aus /api/devlab/games/{slug}/session
(ohne Lösung), geprüft + bewertet wird serverseitig. Eine Render-/Submit-Funktion je Typ:
cloze · choice · order · spot · match · regex. Interaktion klick-basiert. */
(function () {
'use strict';
const MAX_ATTEMPTS = 3;
const GAME = 'devlab';
const slug = document.body.dataset.game || '';
const MECH_LABEL = {
cloze: 'Lücken füllen', choice: 'Multiple Choice', order: 'Zusammensetzen',
spot: 'Bug finden', match: 'Zuordnen', regex: 'Regex'
};
const el = (id) => document.getElementById(id);
const r = {
title: el('dlg-title'), language: el('dlg-language'), taskNo: el('dlg-task-no'),
score: el('dlg-score'), time: el('dlg-time'), best: el('dlg-best'), mech: el('dlg-mech'),
kind: el('dlg-kind'), points: el('dlg-points'), prompt: el('dlg-prompt'),
narrative: el('dlg-narrative'), instruction: el('dlg-instruction'), playfield: el('dlg-playfield'),
feedback: el('dlg-feedback'), success: el('dlg-success'),
check: el('dlg-check'), hint: el('dlg-hint'), reveal: el('dlg-reveal'), next: el('dlg-next'),
result: el('dlg-result'), resultStars: el('dlg-result-stars'), resultScore: el('dlg-result-score'),
resultMax: el('dlg-result-max'), resultDetail: el('dlg-result-detail'), resultHigh: el('dlg-result-high'),
again: el('dlg-again'), description: el('dlg-description'), sideTitle: el('dlg-side-title'),
progress: el('dlg-progress'), bestLine: el('dlg-best-line')
};
let session = null, index = 0, score = 0, seconds = 0, timer = null, running = false;
let finishItems = [];
let cur = null; // { task, attempts, solved, revealed, collect, lock, mark }
// ---------- Helfer ----------
const escapeHtml = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formatTime = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
function awarded(points, attempts) {
const a = Math.max(1, attempts);
return Math.round(points * (a === 1 ? 1 : a === 2 ? 0.6 : 0.3));
}
function starString(stars) {
const n = Math.max(0, Math.min(3, stars));
return '★'.repeat(n) + '☆'.repeat(3 - n);
}
function startTimer() { if (!timer) timer = setInterval(() => { seconds++; r.time.textContent = formatTime(seconds); }, 1000); }
function stopTimer() { clearInterval(timer); timer = null; }
function updateHud() {
r.taskNo.textContent = `${Math.min(index + 1, session.tasks.length)} / ${session.tasks.length}`;
r.score.textContent = String(score);
r.progress.style.width = `${Math.round((index / session.tasks.length) * 100)}%`;
}
// ---------- Renderer je Mechanik ----------
const RENDERERS = {
cloze(task) {
const inputs = {};
const pre = document.createElement('pre');
pre.className = 'dlg-code';
const code = document.createElement('code');
const tpl = task.codeTemplate || '';
const regex = /\{\{(\d+)\}\}/g;
const gapMap = Object.fromEntries((task.gaps || []).map((g) => [g.position, g]));
let last = 0, m;
while ((m = regex.exec(tpl)) !== null) {
if (m.index > last) code.appendChild(document.createTextNode(tpl.slice(last, m.index)));
const pos = Number(m[1]);
const gap = gapMap[pos] || {};
const input = document.createElement('input');
input.type = 'text'; input.className = 'dlg-gap'; input.dataset.pos = pos;
input.autocomplete = 'off'; input.autocapitalize = 'off'; input.spellcheck = false;
input.setAttribute('autocorrect', 'off');
if (gap.placeholder) input.placeholder = gap.placeholder;
input.size = Math.max(6, (gap.placeholder || '').length + 1);
input.addEventListener('input', () => { input.size = Math.max(4, input.value.length + 1); });
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doCheck(false); } });
inputs[pos] = input;
code.appendChild(input);
last = m.index + m[0].length;
}
if (last < tpl.length) code.appendChild(document.createTextNode(tpl.slice(last)));
pre.appendChild(code);
r.playfield.appendChild(pre);
const first = Object.values(inputs)[0]; if (first) first.focus();
return {
collect: () => ({ gaps: Object.entries(inputs).map(([pos, i]) => ({ position: Number(pos), text: i.value })) }),
lock: () => Object.values(inputs).forEach((i) => { i.disabled = true; }),
mark: (data) => (data.detail || []).forEach((d) => {
const i = inputs[d.position]; if (!i) return;
i.classList.toggle('correct', d.correct); i.classList.toggle('wrong', !d.correct);
if (d.solution) { i.value = d.solution; i.classList.remove('wrong'); i.classList.add('revealed'); }
})
};
},
choice(task) {
let selected = null;
const wrap = document.createElement('div'); wrap.className = 'dlg-choices';
if (task.code) { const pre = document.createElement('pre'); pre.className = 'dlg-code'; pre.textContent = task.code; r.playfield.appendChild(pre); }
if (task.question) { const q = document.createElement('p'); q.className = 'dlg-question'; q.textContent = task.question; r.playfield.appendChild(q); }
(task.options || []).forEach((opt) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'dlg-choice'; b.textContent = opt;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
selected = opt;
wrap.querySelectorAll('.dlg-choice').forEach((x) => x.classList.toggle('selected', x === b));
});
wrap.appendChild(b);
});
r.playfield.appendChild(wrap);
return {
collect: () => ({ choice: selected || '' }),
lock: () => wrap.querySelectorAll('.dlg-choice').forEach((b) => { b.disabled = true; }),
mark: (data) => {
wrap.querySelectorAll('.dlg-choice').forEach((b) => {
if (b.textContent === selected) b.classList.add(data.correct ? 'correct' : 'wrong');
if (data.solution && b.textContent === data.solution) b.classList.add('correct');
});
}
};
},
order(task) {
const answer = document.createElement('div'); answer.className = 'dlg-order-answer'; answer.dataset.empty = 'Hier in die richtige Reihenfolge klicken …';
const bank = document.createElement('div'); bank.className = 'dlg-order-bank';
function chip(text, inAnswer) {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-token'; b.textContent = text;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
if (inAnswer) { bank.appendChild(chip(text, false)); b.remove(); }
else { answer.appendChild(chip(text, true)); b.remove(); }
answer.classList.toggle('filled', answer.children.length > 0);
});
return b;
}
(task.items || []).forEach((it) => bank.appendChild(chip(it, false)));
const wrap = document.createElement('div'); wrap.className = 'dlg-order';
const lblA = document.createElement('p'); lblA.className = 'dlg-mini'; lblA.textContent = 'Deine Reihenfolge:';
const lblB = document.createElement('p'); lblB.className = 'dlg-mini'; lblB.textContent = 'Bausteine:';
wrap.append(lblA, answer, lblB, bank);
r.playfield.appendChild(wrap);
return {
collect: () => ({ order: Array.from(answer.children).map((c) => c.textContent) }),
lock: () => wrap.querySelectorAll('.dlg-token').forEach((b) => { b.disabled = true; }),
mark: (data) => { if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution; wrap.appendChild(s); } }
};
},
spot(task) {
let selected = -1;
const box = document.createElement('div'); box.className = 'dlg-spot';
(task.tokens || []).forEach((tok, i) => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-spot-token'; b.textContent = tok;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
selected = i;
box.querySelectorAll('.dlg-spot-token').forEach((x, j) => x.classList.toggle('selected', j === i));
});
box.appendChild(b);
});
r.playfield.appendChild(box);
return {
collect: () => ({ index: selected }),
lock: () => box.querySelectorAll('.dlg-spot-token').forEach((b) => { b.disabled = true; }),
mark: (data) => {
if (data.correct && selected >= 0) box.children[selected].classList.add('correct');
else if (selected >= 0) box.children[selected].classList.add('wrong');
const sol = data.solution; if (sol && typeof sol.index === 'number' && box.children[sol.index]) box.children[sol.index].classList.add('correct');
}
};
},
match(task) {
const left = task.left || [], right = task.right || [];
const conn = {}; // leftIndex -> rightIndex
let activeLeft = -1;
const wrap = document.createElement('div'); wrap.className = 'dlg-match';
const colL = document.createElement('div'); colL.className = 'dlg-match-col';
const colR = document.createElement('div'); colR.className = 'dlg-match-col';
function render() {
wrap.querySelectorAll('.dlg-match-item').forEach((node) => {
const side = node.dataset.side, i = Number(node.dataset.i);
node.classList.toggle('active', side === 'l' && i === activeLeft);
let badge = '';
if (side === 'l' && conn[i] != null) badge = String(Object.keys(conn).indexOf(String(i)) + 1);
if (side === 'r') { const li = Object.keys(conn).find((k) => conn[k] === i); if (li != null) badge = String(Object.keys(conn).indexOf(li) + 1); }
node.classList.toggle('connected', !!badge);
node.querySelector('.dlg-match-badge').textContent = badge;
});
}
function item(text, side, i) {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-match-item'; b.dataset.side = side; b.dataset.i = i;
b.innerHTML = `<span>${escapeHtml(text)}</span><span class="dlg-match-badge"></span>`;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
if (side === 'l') { activeLeft = (conn[i] != null) ? (delete conn[i], -1) : i; }
else if (activeLeft >= 0) { Object.keys(conn).forEach((k) => { if (conn[k] === i) delete conn[k]; }); conn[activeLeft] = i; activeLeft = -1; }
render();
});
return b;
}
left.forEach((t, i) => colL.appendChild(item(t, 'l', i)));
right.forEach((t, i) => colR.appendChild(item(t, 'r', i)));
wrap.append(colL, colR); r.playfield.appendChild(wrap);
return {
collect: () => ({ pairs: Object.entries(conn).map(([li, ri]) => [left[li], right[ri]]) }),
lock: () => wrap.querySelectorAll('.dlg-match-item').forEach((b) => { b.disabled = true; }),
mark: (data) => {
if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution.map((p) => `${p[0]} → ${p[1]}`).join(' · '); wrap.parentElement.appendChild(s); }
}
};
},
regex(task) {
const input = document.createElement('input');
input.type = 'text'; input.className = 'dlg-regex-input'; input.placeholder = 'Regex-Muster …';
input.autocomplete = 'off'; input.spellcheck = false;
const list = document.createElement('div'); list.className = 'dlg-regex-samples';
const samples = task.samples || [];
function preview() {
let pat = null;
try { pat = input.value ? new RegExp(input.value) : null; } catch (e) { pat = null; }
list.innerHTML = samples.map((s) => {
const matches = pat ? pat.test(s.text) : false;
const ok = matches === s.shouldMatch;
const want = s.shouldMatch ? 'soll matchen' : 'soll nicht matchen';
return `<span class="dlg-sample ${ok ? 'ok' : 'bad'}">${escapeHtml(s.text === '' ? '∅ (leer)' : s.text)}<small>${want}</small></span>`;
}).join('');
}
input.addEventListener('input', preview);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doCheck(false); } });
const wrap = document.createElement('div'); wrap.className = 'dlg-regex';
wrap.append(input, list); r.playfield.appendChild(wrap); preview(); input.focus();
return {
collect: () => ({ pattern: input.value }),
lock: () => { input.disabled = true; },
mark: (data) => { if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution; wrap.appendChild(s); } }
};
}
};
// ---------- Ablauf ----------
function renderTask() {
const task = session.tasks[index];
cur = { task, attempts: 0, solved: false, revealed: false };
r.kind.textContent = `Aufgabe ${index + 1}`;
r.mech.textContent = MECH_LABEL[task.type] || task.type;
r.points.textContent = String(task.points || 100);
r.prompt.textContent = task.prompt || '';
r.narrative.textContent = task.narrative || '';
r.narrative.hidden = !task.narrative;
r.instruction.textContent = task.instruction || '';
r.instruction.hidden = !task.instruction;
r.feedback.textContent = ''; r.feedback.className = 'dlg-feedback';
r.success.hidden = true; r.success.textContent = '';
r.check.hidden = false; r.hint.hidden = !task.hint; r.reveal.hidden = true; r.next.hidden = true;
r.playfield.innerHTML = '';
const renderer = RENDERERS[task.type];
cur.ctrl = renderer ? renderer(task) : { collect: () => ({}), lock: () => {}, mark: () => {} };
updateHud();
}
async function doCheck(reveal) {
if (!running || !cur || cur.solved) return;
if (!reveal) cur.attempts++;
const submission = cur.ctrl.collect();
let data;
try {
const res = await fetch('/api/devlab/check', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: cur.task.taskId, reveal, submission })
});
data = await res.json();
} catch (err) { r.feedback.textContent = 'Prüfung fehlgeschlagen.'; r.feedback.className = 'dlg-feedback wrong'; return; }
cur.lastSubmission = submission;
cur.ctrl.mark(data);
if (data.correct) {
cur.solved = true;
score += awarded(cur.task.points || 100, cur.attempts);
cur.ctrl.lock();
r.feedback.textContent = 'Richtig. Sitzt!'; r.feedback.className = 'dlg-feedback correct';
finishMissionUi(data, false);
} else if (reveal) {
cur.revealed = true;
cur.ctrl.lock();
r.feedback.textContent = 'Aufgelöst – schau dir die Lösung an, dann weiter.'; r.feedback.className = 'dlg-feedback revealed';
finishMissionUi(data, true);
} else {
const hint = cur.task.hint ? ` Hinweis: ${cur.task.hint}` : '';
r.feedback.textContent = (data.error || 'Noch nicht ganz.') + hint; r.feedback.className = 'dlg-feedback wrong';
if (cur.attempts >= MAX_ATTEMPTS) r.reveal.hidden = false;
}
updateHud();
}
function finishMissionUi(data, revealed) {
r.check.hidden = true; r.hint.hidden = true; r.reveal.hidden = true; r.next.hidden = false;
if (data.successText) { r.success.textContent = data.successText; r.success.hidden = false; }
r.next.focus();
}
function showHint() {
if (!cur || !cur.task.hint) return;
r.feedback.textContent = `Hinweis: ${cur.task.hint}`; r.feedback.className = 'dlg-feedback revealed';
score = Math.max(0, score - 10); updateHud();
}
function nextTask() {
finishItems.push({ taskId: cur.task.taskId, submission: cur.lastSubmission || cur.ctrl.collect(), attempts: cur.attempts || 1, revealed: cur.revealed });
index++;
if (index >= session.tasks.length) finish();
else renderTask();
}
async function finish() {
running = false; stopTimer();
try {
const res = await fetch('/api/devlab/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, durationSeconds: seconds, items: finishItems })
});
const data = await res.json();
score = data.score; updateHud();
r.resultStars.textContent = starString(data.stars);
r.resultScore.textContent = String(data.score);
r.resultMax.textContent = String(data.maxScore);
r.resultDetail.textContent = `${data.solvedTasks} von ${data.totalTasks} auf Anhieb · ${formatTime(seconds)}`;
r.resultHigh.hidden = !data.newHighscore;
r.best.textContent = String(data.highscore);
r.result.hidden = false;
} catch (err) { console.error('Abschluss fehlgeschlagen', err); }
}
async function loadStats() {
try {
const res = await fetch(`/api/games/${GAME}/stats`);
if (!res.ok) return;
const s = await res.json();
r.best.textContent = s.highscore > 0 ? String(s.highscore) : '–';
r.bestLine.textContent = s.highscore > 0 ? `Dev-Lab-Bestwert: ${s.highscore} Punkte` : 'Noch kein Dev-Lab-Bestwert.';
} catch (err) { /* egal */ }
}
function start() {
index = 0; score = 0; seconds = 0; finishItems = []; r.time.textContent = '0:00'; r.result.hidden = true;
running = true; renderTask(); startTimer();
}
async function init() {
try {
const res = await fetch(`/api/devlab/games/${encodeURIComponent(slug)}/session`);
session = await res.json();
} catch (err) { session = null; }
if (!session || !session.found || !session.tasks || !session.tasks.length) {
r.title.textContent = 'Spiel nicht gefunden';
r.prompt.textContent = 'Für diese Disziplin gibt es noch keine Aufgaben.';
r.check.hidden = true; r.hint.hidden = true;
return;
}
document.title = `${session.title} – Mindflux`;
r.title.textContent = session.title;
r.language.textContent = session.language || 'Dev Lab';
r.sideTitle.textContent = session.title;
r.description.textContent = session.description || '';
loadStats();
start();
}
r.check.addEventListener('click', () => doCheck(false));
r.hint.addEventListener('click', showHint);
r.reveal.addEventListener('click', () => doCheck(true));
r.next.addEventListener('click', nextTask);
r.again.addEventListener('click', start);
init();
})();
+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.');
})();
+12 -1
View File
@@ -21,7 +21,18 @@
try { localStorage.setItem(KEY, next); } catch (e) {}
paint();
}
window.MindfluxTheme = { toggle: toggle, apply: apply };
function set(mode) {
var next = mode === 'light' ? 'light' : 'dark';
apply(next);
try { localStorage.setItem(KEY, next); } catch (e) {}
paint();
}
window.MindfluxTheme = {
toggle: toggle,
apply: apply,
set: set,
current: function () { return document.documentElement.getAttribute('data-theme'); }
};
// Mobile-Nav: Off-Canvas-Drawer für Seiten mit App-Sidebar (.mf-shell)
function initMobileNav() {
+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)"] }
]
}
]
}
@@ -30,12 +30,14 @@
<div class="mf-container">
<div class="mf-page-head">
<a href="/profile" class="mf-back">⟵ Zurück zum Profil</a>
<a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div>
<span class="mf-eyebrow">Deine Trophäen</span>
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">🏆 Deine Trophäen</h1>
<span class="mf-eyebrow">Deine Erfolge</span>
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">🏆 Deine Erfolge</h1>
<p style="color:var(--muted); margin-bottom:1.4rem;">Sammle sie alle und werde zur Legende!</p>
<nav th:replace="~{fragments/layout :: profileTabs('erfolge')}"></nav>
<div class="mf-panel">
<div class="mf-ach-grid">
<div class="achievement-card"
@@ -46,6 +46,8 @@
<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/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a>
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
<a th:href="@{/admin/devlab}" class="admin-card">⚗️ Dev-Lab-Aufgaben verwalten</a>
</div>
<p style="margin-top:1.1rem; color:var(--muted);">
Hier entsteht noch Großes... ✨ Bleib dran!
@@ -0,0 +1,108 @@
<!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="${game.title} + ' – Dev Lab'">Dev-Lab-Spiel</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.dlg { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
.dlg .grid2 { display: grid; grid-template-columns: 160px 1fr; gap: 0.7rem; }
.dlg .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
.dlg .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
.dlg textarea.mf-input { font-family: "JetBrains Mono", monospace; resize: vertical; min-height: 8rem; font-size: 0.82rem; }
.dlg .task { 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); }
.dlg .task-head { display: flex; align-items: center; gap: 0.6rem; justify-content: space-between; flex-wrap: wrap; }
.dlg .ttype { 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.78rem; font-family: monospace; }
.dlg .tprompt { color: var(--heading); font-weight: 600; }
.dlg .hint { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.8rem; line-height: 1.5; }
.dlg .hint code { color: var(--accent); }
.dlg .empty { color: var(--danger); }
.dlg details { margin-top: 0.5rem; }
.dlg summary { cursor: pointer; color: var(--accent); font-size: 0.85rem; }
@media (max-width: 600px) { .dlg .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/devlab}" class="mf-back">⟵ Zurück zur Spieleliste</a>
</div>
<header style="margin-bottom:1.4rem">
<div class="mf-eyebrow">Dev Lab · Spiel</div>
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem" th:text="'⚗️ ' + ${game.title}">Spiel</h1>
<p style="color:var(--muted); margin-top:.3rem">
<span th:text="'/' + ${game.slug}">/slug</span> ·
<span th:text="${game.path}">syntax</span> ·
<span th:text="${game.language}">SQL</span>
</p>
</header>
<main class="dlg">
<h4 style="color:var(--accent); margin:0 0 0.7rem; text-transform:uppercase; letter-spacing:.08em; font-size:.8rem">Aufgaben</h4>
<div class="task" th:each="t : ${game.tasks}">
<div class="task-head">
<span><span class="ttype" th:text="${t.type}">cloze</span> <span class="tprompt" th:text="${t.prompt}">Titel</span></span>
<form th:action="@{'/admin/devlab/tasks/' + ${t.id} + '/delete'}" method="post" onsubmit="return confirm('Aufgabe löschen?');">
<input type="hidden" name="gameId" th:value="${game.id}">
<button type="submit" class="mf-btn danger">✕ Löschen</button>
</form>
</div>
<details>
<summary>Bearbeiten (JSON-Payload)</summary>
<form th:action="@{'/admin/devlab/tasks/' + ${t.id} + '/update'}" method="post" style="margin-top:.6rem">
<input type="hidden" name="gameId" th:value="${game.id}">
<div class="grid2">
<div class="field"><label>Punkte</label><input class="mf-input" type="number" name="points" th:value="${t.points}" min="10" max="1000"></div>
<div class="field"><label>Titel</label><input class="mf-input" type="text" name="prompt" th:value="${t.prompt}" required></div>
</div>
<div class="field"><label>Payload (JSON)</label><textarea class="mf-input" name="payload" th:text="${t.payload}" required></textarea></div>
<button type="submit" class="mf-btn">💾 Speichern</button>
</form>
</details>
</div>
<p th:if="${game.tasks.isEmpty()}" class="empty">Noch keine Aufgaben – unten hinzufügen. Ohne Aufgaben erscheint das Spiel nicht.</p>
<div class="mf-panel accent" style="margin-top:1.6rem">
<h4>Aufgabe hinzufügen</h4>
<p class="hint">Typ wählen und den passenden <strong>JSON-Payload</strong> eintragen. Pflichtfelder je Typ:
<code>cloze</code>: codeTemplate (<code>{{1}}</code>…), gaps[{position, placeholder, hint, caseSensitive, solutions[]}] ·
<code>choice</code>: question, options[], correctIndex ·
<code>order</code>: instruction, items[] (richtige Reihenfolge) ·
<code>spot</code>: tokens[], bugIndex, fix ·
<code>match</code>: instruction, pairs[[l,r]…] ·
<code>regex</code>: instruction, samples[{text, shouldMatch}], pattern.
Optional überall: narrative, hint, successText. Platzhalter nie die Lösung!</p>
<form th:action="@{'/admin/devlab/games/' + ${game.id} + '/tasks'}" method="post">
<div class="grid2">
<div class="field"><label>Typ</label>
<select class="mf-select" name="type">
<option value="cloze">cloze – Lücke tippen</option>
<option value="choice">choice – Multiple Choice</option>
<option value="order">order – Zusammensetzen</option>
<option value="spot">spot – Bug finden</option>
<option value="match">match – Zuordnen</option>
<option value="regex">regex – Muster schreiben</option>
</select>
</div>
<div class="field"><label>Titel</label><input class="mf-input" type="text" name="prompt" placeholder="kurzer Aufgabentitel" required></div>
</div>
<div class="field" style="max-width:160px"><label>Punkte</label><input class="mf-input" type="number" name="points" value="100" min="10" max="1000"></div>
<div class="field"><label>Payload (JSON)</label><textarea class="mf-input" name="payload" placeholder='{ "question": "...", "options": ["A","B"], "correctIndex": 0, "hint": "...", "successText": "..." }' required></textarea></div>
<button type="submit" class="mf-btn">➕ Aufgabe 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,96 @@
<!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>Dev-Lab-Verwaltung – Admin</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.dla { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
.dla .new-game { margin-bottom: 1.6rem; }
.dla .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.dla .game {
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);
}
.dla .game.inactive { border-left-color: var(--danger); opacity: 0.7; }
.dla .game h2 { color: var(--heading); margin: 0; font-size: 1.05rem; }
.dla .slug { color: var(--accent); font-size: 0.78rem; font-family: monospace; }
.dla .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); }
.dla .badge.on { background: var(--glow-w); color: var(--accent); border-color: var(--border-accent); }
.dla .badge.off { background: rgba(255,95,107,.12); color: var(--danger); border-color: var(--danger); }
.dla .count { color: var(--muted); font-size: 0.85rem; }
.dla .actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.dla .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
.dla .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
.dla .empty { color: var(--muted); }
@media (max-width: 600px) { .dla .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">Dev Lab</div>
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem">⚗️ Dev-Lab-Verwaltung</h1>
</header>
<main class="dla">
<div class="mf-panel accent new-game">
<h4>Neues Spiel anlegen</h4>
<form th:action="@{/admin/devlab/games}" method="post">
<div class="grid2">
<div class="field"><label>Titel</label><input class="mf-input" type="text" name="title" placeholder="z. B. SQL Detective" required></div>
<div class="field"><label>Sprache/Domäne</label><input class="mf-input" type="text" name="language" placeholder="SQL / CSS / Git …"></div>
</div>
<div class="grid2">
<div class="field"><label>Pfad</label>
<select class="mf-select" name="path">
<option value="syntax">Syntax &amp; Grundlagen</option>
<option value="debugging">Debugging &amp; Problemlösung</option>
<option value="visual">Praxis &amp; Visualisierung</option>
</select>
</div>
<div class="field"><label>Beschreibung</label><input class="mf-input" type="text" name="description" placeholder="kurze Beschreibung"></div>
</div>
<button type="submit" class="mf-btn">➕ Spiel anlegen &amp; Aufgaben hinzufügen</button>
</form>
</div>
<div class="game" th:each="g : ${games}" th:classappend="${g.active} ? '' : 'inactive'">
<div>
<h2 th:text="${g.title}">Spiel</h2>
<span class="slug" th:text="'/' + ${g.slug}">/slug</span>
<span class="badge" th:text="${g.path}">syntax</span>
<span class="badge" th:if="${g.language}" th:text="${g.language}">SQL</span>
<span class="badge on" th:if="${g.active}">aktiv</span>
<span class="badge off" th:unless="${g.active}">inaktiv</span>
<span class="count" th:text="'· ' + ${g.taskCount} + ' Aufgaben'">· 0 Aufgaben</span>
</div>
<div class="actions">
<a class="mf-btn" th:href="@{'/admin/devlab/games/' + ${g.id}}">Aufgaben verwalten</a>
<form th:action="@{'/admin/devlab/games/' + ${g.id} + '/toggle'}" method="post">
<button type="submit" class="mf-btn outline" th:text="${g.active} ? 'Deaktivieren' : 'Aktivieren'">Toggle</button>
</form>
<form th:action="@{'/admin/devlab/games/' + ${g.id} + '/delete'}" method="post" onsubmit="return confirm('Spiel inkl. aller Aufgaben löschen?');">
<button type="submit" class="mf-btn danger">Löschen</button>
</form>
</div>
</div>
<p th:if="${games.isEmpty()}" class="empty">Noch keine Spiele. Lege oben das 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>
@@ -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>
@@ -34,8 +34,9 @@
</div>
<div class="mf-field">
<label class="mf-label">Passwort</label>
<input class="mf-input" type="password" name="password" th:required="${user.id == null}" autocomplete="new-password"/>
<label class="mf-label" th:text="${user.id == null} ? 'Passwort' : 'Neues Passwort (leer lassen = unverändert)'">Passwort</label>
<input class="mf-input" type="password" name="password" th:required="${user.id == null}" autocomplete="new-password"
th:placeholder="${user.id == null} ? 'Passwort' : 'leer lassen = unverändert'"/>
</div>
<div class="mf-field">
@@ -43,9 +43,17 @@
<td th:text="${user.id}">1</td>
<td th:text="${user.username}">Max</td>
<td th:text="${user.email}">max@mindflux.de</td>
<td style="display:flex; gap:.5rem; flex-wrap:wrap;">
<td style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<a th:href="@{'/admin/users/edit/' + ${user.id}}" class="mf-btn outline">Bearbeiten</a>
<form th:action="@{'/admin/users/delete/' + ${user.id}}" method="post" style="display:inline;">
<form th:action="@{'/admin/users/' + ${user.id} + '/reset-password'}" method="post"
style="display:flex; gap:.35rem; align-items:center;"
onsubmit="return confirm('Passwort für diesen Nutzer wirklich neu setzen?');">
<input class="mf-input" type="text" name="newPassword" placeholder="Neues Passwort"
required minlength="4" style="width:150px; padding:.45rem .6rem;">
<button type="submit" class="mf-btn">PW zurücksetzen</button>
</form>
<form th:action="@{'/admin/users/delete/' + ${user.id}}" method="post" style="display:inline;"
onsubmit="return confirm('Benutzer wirklich löschen?');">
<button type="submit" class="mf-btn danger">Löschen</button>
</form>
</td>
+12 -68
View File
@@ -35,68 +35,8 @@
<body>
<div class="mf-shell">
<!-- ===== Sidebar ===== -->
<aside class="mf-sidebar">
<a th:href="@{/dashboard}" class="mf-logo">
<span class="mark"></span>
<span class="txt"><b>MINDFLUX</b><span>KNOWLEDGE. CODE. WIN.</span></span>
</a>
<nav class="mf-nav">
<a th:href="@{/dashboard}" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
Dashboard
</a>
<a th:href="@{/admin}" sec:authorize="hasRole('ADMIN')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 4v6c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Admin-Dashboard
</a>
<a th:href="@{/profile/knowledge}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/></svg>
Knowledge Core
</a>
<a th:href="@{/progress}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>
Statistiken
</a>
<a th:href="@{/profile/achievements}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="9" r="6"/><path d="M9 14l-2 8 5-3 5 3-2-8"/></svg>
Achievements
</a>
<a th:href="@{/profile/settings}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3.2"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1L14.5 2h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z"/></svg>
Einstellungen
</a>
</nav>
<a th:href="@{/profile}" class="mf-profile" style="text-decoration:none">
<div class="row">
<span class="mf-avatar" th:text="${#authentication.name != null ? #authentication.name.substring(0,1).toUpperCase() : 'U'}">A</span>
<div>
<b sec:authentication="name">Alice Darkest</b>
<span class="role" sec:authorize="hasRole('ADMIN')">Administrator</span>
<span class="role" sec:authorize="!hasRole('ADMIN')">Mitglied</span>
</div>
<svg style="margin-left:auto;color:var(--muted);width:18px;height:18px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</div>
</a>
<div class="mf-meta">
<div class="lvl">LEVEL <b>12</b></div>
<div class="xp">3250 / 5000 XP</div>
<div class="mf-bar"><i style="width:65%"></i></div>
</div>
<div class="mf-streak">
<span class="fire">🔥</span><span><b>STREAK</b><small>7 Tage in Folge</small></span>
</div>
<div class="mf-brain">
<svg viewBox="0 0 120 110" fill="none" stroke="var(--accent)" stroke-width="1.4">
<path d="M60 12c-14-8-34-2-36 14-10 4-12 18-4 26-4 10 2 22 14 24 4 8 18 10 26 4 8 6 22 4 26-4 12-2 18-14 14-24 8-8 6-22-4-26-2-16-22-22-36-14z"/>
<path d="M60 16v82M40 30c8 4 8 14 0 18M80 30c-8 4-8 14 0 18M34 60c10 0 14 8 14 16M86 60c-10 0-14 8-14 16M48 44h24M44 76h32"/>
</svg>
</div>
</aside>
<!-- ===== Sidebar (gemeinsames Fragment) ===== -->
<aside th:replace="~{fragments/layout :: sidebar('dashboard')}"></aside>
<!-- ===== Topbar ===== -->
<header class="mf-topbar">
@@ -111,7 +51,9 @@
<button class="mf-iconbtn" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>
<div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div>
<button class="mf-theme"></button>
<a class="mf-iconbtn" href="/logout" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></a>
<form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
</form>
</div>
</header>
@@ -176,6 +118,7 @@
brain:'<path d="M28 8c-9-5-22 0-23 11-7 2-9 12-3 18-3 7 2 15 10 16 3 6 13 7 18 2M28 8c9-5 22 0 23 11 7 2 9 12 3 18 3 7-2 15-10 16-3 6-13 7-18 2M28 8v45M16 22c6 2 6 10 0 13M40 22c-6 2-6 10 0 13M12 40c7 0 10 5 10 11M44 40c-7 0-10 5-10 11"/>',
code:'<path d="M22 16L8 28l14 12M42 16l14 12-14 12"/>',
terminal:'<rect x="6" y="10" width="52" height="44" rx="4"/><path d="M16 26l9 6-9 6M32 40h14"/>',
lab:'<path d="M26 8h12"/><path d="M30 8v18L16 48c-2.3 3.7.4 8.5 4.8 8.5h22.4c4.4 0 7.1-4.8 4.8-8.5L34 26V8"/><path d="M23 43h18"/><path d="M25 49h14"/><path d="M25 32l-4 4 4 4"/><path d="M39 32l4 4-4 4"/><path d="M30 42l4-12"/>',
lock:'<rect x="12" y="28" width="40" height="28" rx="5"/><path d="M20 28v-7a12 12 0 0 1 24 0v7M32 39v6"/>',
sword:'<path d="M48 8l4 4-22 22-4-4zM26 30l8 8M30 26l8 8M22 38l-6 6 6 6 6-6M16 44l-6 6"/>',
bug:'<rect x="20" y="20" width="24" height="30" rx="12"/><path d="M32 20v-6M24 12l4 6M40 12l-4 6M20 30h-8M52 30h-8M20 40H10M54 40H44M20 50l-7 7M44 50l7 7"/>',
@@ -185,11 +128,12 @@
const GAMES = [
['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'],
['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'],
['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/comingsoon'],
['Code Escape Room','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
['Service Clash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
['Bug Hunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
['Netzwerker – Das Router-Rätselspiel','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'],
['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
];
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
@@ -0,0 +1,87 @@
<!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>Dev Lab Spiel – Mindflux</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<link rel="stylesheet" href="/css/devlabgame.css?v=3">
</head>
<body class="dlg-body" th:attr="data-game=${gameSlug}">
<div class="dlg-shell">
<header class="dlg-header">
<a href="/devlab" class="dlg-back">⟵ Dev Lab</a>
<div class="dlg-head-title">
<p id="dlg-language" class="mf-eyebrow">Dev Lab</p>
<h1 id="dlg-title">Spiel wird geladen…</h1>
</div>
<div class="dlg-header-tools">
<button type="button" class="mf-theme"></button>
<form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="dlg-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
</form>
</div>
</header>
<div class="dlg-bar">
<div class="dlg-bar-group">
<span class="dlg-stat">Aufgabe <b id="dlg-task-no">– / –</b></span>
<span class="dlg-stat">Punkte <b id="dlg-score">0</b></span>
<span class="dlg-stat">Zeit <b id="dlg-time">0:00</b></span>
<span class="dlg-stat">Bestwert <b id="dlg-best">–</b></span>
</div>
<div class="dlg-bar-group">
<span id="dlg-mech" class="mf-badge">–</span>
</div>
</div>
<main class="dlg-stage">
<section class="dlg-card">
<div class="dlg-card-head">
<span id="dlg-kind" class="mf-badge">Aufgabe</span>
<span id="dlg-points" class="dlg-points">100</span>
</div>
<h2 id="dlg-prompt"></h2>
<p id="dlg-narrative" class="dlg-narrative"></p>
<p id="dlg-instruction" class="dlg-instruction"></p>
<div id="dlg-playfield" class="dlg-playfield"></div>
<p id="dlg-feedback" class="dlg-feedback" aria-live="polite"></p>
<p id="dlg-success" class="dlg-success" hidden></p>
<div class="dlg-actions">
<button id="dlg-check" class="mf-btn" type="button">Prüfen</button>
<button id="dlg-hint" class="mf-btn ghost" type="button">Hinweis</button>
<button id="dlg-reveal" class="mf-btn outline" type="button" hidden>Auflösung</button>
<button id="dlg-next" class="mf-btn" type="button" hidden>Weiter</button>
</div>
</section>
<aside class="dlg-side mf-panel accent">
<h2 id="dlg-side-title">Mission Log</h2>
<p id="dlg-description" class="dlg-description"></p>
<div class="dlg-progress">
<span>Fortschritt</span>
<div class="mf-bar"><i id="dlg-progress" style="width:0"></i></div>
</div>
<p id="dlg-best-line" class="dlg-best"></p>
</aside>
</main>
</div>
<div id="dlg-result" class="dlg-overlay" hidden>
<div class="dlg-result-card">
<h2>Disziplin abgeschlossen 🎉</h2>
<p id="dlg-result-stars" class="dlg-result-stars">★★★</p>
<p class="dlg-result-score">Score: <strong id="dlg-result-score">0</strong> / <span id="dlg-result-max">0</span></p>
<p id="dlg-result-detail" class="dlg-result-detail"></p>
<p id="dlg-result-high" class="dlg-result-high" hidden>🏆 Neuer Bestwert!</p>
<div class="dlg-result-actions">
<button id="dlg-again" class="mf-btn" type="button">Nochmal</button>
<a href="/devlab" class="mf-btn outline">Zur Spielsammlung</a>
</div>
</div>
</div>
<script src="/js/devlabgame.js?v=3" defer></script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
<!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>Dev Lab - Mindflux</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<link rel="stylesheet" href="/css/devlab.css?v=2">
</head>
<body class="dl-body">
<div class="mf-shell no-right dl-shell">
<aside class="mf-sidebar">
<a th:href="@{/dashboard}" class="mf-logo">
<span class="mark"></span>
<span class="txt"><b>MINDFLUX</b><span>KNOWLEDGE. CODE. WIN.</span></span>
</a>
<nav class="mf-nav">
<a th:href="@{/dashboard}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
Dashboard
</a>
<a th:href="@{/devlab}" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 2h6"/><path d="M10 2v6l-5 9a3 3 0 0 0 2.6 4.5h8.8A3 3 0 0 0 19 17l-5-9V2"/><path d="M8 15h8"/><path d="M9 18h6"/></svg>
Dev Lab
</a>
<a th:href="@{/progress}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>
Statistiken
</a>
</nav>
<div class="dl-lab-status mf-panel accent">
<h4>Laborstatus</h4>
<div class="mf-bar"><i style="width:42%"></i></div>
<p>3 Trainingspfade · 12 Spiele · Konzept umgesetzt</p>
</div>
</aside>
<header class="mf-topbar dl-topbar">
<div class="mf-hello">
<p class="mf-eyebrow">Entwicklerlabor</p>
<h1>DevLab</h1>
<p>Ein zentraler Hub für die Spiele aus Syntax, Debugging und visueller Praxis.</p>
</div>
<div class="mf-tools">
<button type="button" class="mf-theme"></button>
<form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
</form>
</div>
</header>
<main class="mf-main dl-main">
<section class="dl-hero mf-panel accent">
<div>
<p class="mf-eyebrow">Dev-Lab-Ausbildungszentrum</p>
<h2>Wähle einen Trainingspfad und starte ein Spiel.</h2>
<p>Die Spiele sind nach Fähigkeit gruppiert: Syntax &amp; Grundlagen, Debugging &amp; Problemlösung sowie Praxis &amp; Visualisierung.</p>
</div>
<div class="dl-tube" aria-hidden="true">
<svg viewBox="0 0 96 128" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M36 8h24"/>
<path d="M42 8v36L19 91c-6 12 3 27 17 27h24c14 0 23-15 17-27L54 44V8"/>
<path d="M31 87h34"/>
<path d="M35 100h26"/>
<path d="M34 69l-8 7 8 7"/>
<path d="M62 69l8 7-8 7"/>
<path d="M44 92l8-30"/>
</svg>
<span>{}</span><span>&lt;/&gt;</span><span>[]</span><span>0101</span>
</div>
</section>
<section class="dl-workbench">
<div class="dl-stations mf-panel">
<div class="dl-panel-head">
<h2>Trainingspfade</h2>
<span class="mf-badge">3 Pfade</span>
</div>
<div id="dl-stations" class="dl-station-grid" aria-label="Trainingspfade"></div>
</div>
<aside class="dl-world mf-panel accent" aria-live="polite">
<div class="dl-world-head">
<span id="dl-world-icon" class="dl-world-icon">{}</span>
<div>
<p id="dl-world-type" class="mf-eyebrow">Grundlagenpfad</p>
<h2 id="dl-world-title">Syntax &amp; Grundlagen</h2>
</div>
</div>
<p id="dl-world-copy" class="dl-world-copy"></p>
<div class="dl-progression">
<div><span>Level</span><b id="dl-levels">0</b></div>
<div><span>XP</span><b id="dl-xp">0</b></div>
<div><span>Badge</span><b id="dl-badge">-</b></div>
</div>
<div class="dl-mechanics" id="dl-mechanics"></div>
<div class="dl-panel-head dl-games-head">
<h3>Spiele</h3>
<span id="dl-game-count" class="mf-badge">0 Spiele</span>
</div>
<div id="dl-games" class="dl-game-list"></div>
</aside>
</section>
</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>
<script src="/js/devlab.js?v=4" defer></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!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>Mindflux – Fehler</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-container" style="max-width:640px; margin:10vh auto 0;">
<div class="mf-panel" style="text-align:center; padding:2.5rem 2rem;">
<div style="font-size:2.6rem; margin-bottom:.6rem;">⚡</div>
<h1 style="color:var(--heading); font-size:1.6rem; font-weight:800; margin-bottom:.4rem;">
Ups – da ist etwas schiefgelaufen
</h1>
<p style="color:var(--muted); margin-bottom:1rem;">
<span th:if="${status != null}" th:text="'Fehlercode ' + ${status}">Fehlercode</span>
<span th:unless="${status != null}">Unerwarteter Fehler</span>
</p>
<p th:if="${friendlyMessage != null}" th:text="${friendlyMessage}"
style="color:var(--text); margin-bottom:1.4rem;"></p>
<a th:href="@{/dashboard}" class="mf-btn">Zum Dashboard</a>
</div>
</div>
</body>
</html>
+12 -2
View File
@@ -16,9 +16,9 @@
<button class="mf-theme"></button>
</div>
<h1>FinalFlux</h1>
<p>AP1 Informatik nach aktueller IHK-Ausbildungsordnung.</p>
<p>AP1 &amp; AP2 nach aktueller IHK-Ausbildungsordnung – die gesamte Fachinformatiker-Ausbildung.</p>
<nav class="finalflux-nav" aria-label="FinalFlux AP1 Navigation">
<nav class="finalflux-nav" aria-label="FinalFlux Navigation">
<section>
<h2>Grundlagen</h2>
<a href="/finalflux/ap1/zahlensysteme.html">Zahlensysteme & Speicher</a>
@@ -58,6 +58,16 @@
<a href="/finalflux/wirtschaft/harvard_konzept.html">Harvard-Konzept</a>
<a href="/finalflux/wirtschaft/win_win_strategie.html">Win-Win-Strategie</a>
</section>
<section>
<h2>AP2 Anwendungsentwicklung</h2>
<a href="/finalflux/ap2-fiae/entwurfsmuster.html">Entwurfsmuster</a>
</section>
<section>
<h2>AP2 Systemintegration</h2>
<a href="/finalflux/ap2-fisi/hochverfuegbarkeit.html">Hochverfügbarkeit</a>
</section>
</nav>
</aside>

Some files were not shown because too many files have changed in this diff Show More