45 Commits

Author SHA1 Message Date
AliceDarkest ea1ba38cf0 Accessibility-Icon an Kopfzeilengröße angleichen
mindflux-ci / build (push) Successful in 21s
2026-07-12 23:56:25 +02:00
AliceDarkest 548a90f15e Accessibility in Kopfzeilen integrieren und Kategorien angleichen
mindflux-ci / build (push) Successful in 22s
2026-07-12 23:51:46 +02:00
AliceDarkest a23a1b4e7e Logout-Navigation in Spielen vereinheitlichen
mindflux-ci / build (push) Successful in 22s
2026-07-12 23:42:44 +02:00
AliceDarkest 87cd63e373 Accessibility-Trigger ohne Inhaltsüberlagerung andocken
mindflux-ci / build (push) Successful in 21s
2026-07-12 23:40:16 +02:00
AliceDarkest 4bf019747a Dokumentation auf SystemEscape und Accessibility abgleichen
mindflux-ci / build (push) Successful in 4s
2026-07-12 23:15:34 +02:00
AliceDarkest 333af0253e Accessibility-Icon und Dyslexie-Lesemodus präzisieren
mindflux-ci / build (push) Successful in 21s
2026-07-12 23:11:21 +02:00
AliceDarkest 018efdee0f Globales Barrierefreiheitsmenü ergänzen
mindflux-ci / build (push) Successful in 21s
2026-07-12 23:05:48 +02:00
AliceDarkest 0f2c9eae86 DevLab viewportfüllend und skalierbar gestalten
mindflux-ci / build (push) Successful in 22s
2026-07-12 22:57:48 +02:00
AliceDarkest 17702554f8 SystemEscape mit zufälligen Runs und Raumpool erweitern
mindflux-ci / build (push) Successful in 22s
2026-07-12 22:48:04 +02:00
AliceDarkest 4935d00b4e CodeEscape sichtbar in SystemEscape umbenennen
mindflux-ci / build (push) Successful in 21s
2026-07-12 22:42:42 +02:00
AliceDarkest 0b0360be9d CodeEscape Spielführung und Schleusenlogik korrigieren
mindflux-ci / build (push) Successful in 22s
2026-07-12 22:39:40 +02:00
AliceDarkest edb5b36408 CodeEscape als serverautoritativen Spielmodus umsetzen
mindflux-ci / build (push) Successful in 21s
2026-07-12 22:24:50 +02:00
AliceDarkest 986f6226ef Login bereinigen und Zukunftsplan aktualisieren
mindflux-ci / build (push) Successful in 21s
2026-07-12 22:15:23 +02:00
AliceDarkest 4f99593d34 FluxTimer-Icon als Uhr mit Blitz gestalten
mindflux-ci / build (push) Successful in 20s
2026-07-12 22:12:45 +02:00
AliceDarkest 6195a11501 Dashboard-Topbar und FluxTimer-Icon verfeinern
mindflux-ci / build (push) Successful in 21s
2026-07-12 22:09:18 +02:00
AliceDarkest 02975ebb25 FluxTimer und Fundament für neue Spiele umsetzen
mindflux-ci / build (push) Successful in 21s
2026-07-12 22:03:44 +02:00
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
AliceDarkest bbe24c67eb Mobile-Fixes: Spiele scrollbar + FinalFlux-Hub Overlap/Menue
- MindMatch/TechTacular: Boards passten auf dem Handy nicht in 100vh
  (iOS-Adressleiste) und die Seite war auf overflow:hidden gesperrt ->
  untere Felder unerreichbar. Auf <=820px jetzt scrollbar (height:auto,
  overflow-y:auto, 100dvh), Karten via aspect-ratio, Kacheln via min-height.
- FinalFlux-Hub: das generische `main {}` aus der Alt-style.css traf das
  Hub-<main class="finalflux-main"> (display:flex; width:90%; margin:auto)
  und verursachte Overlap + unerreichbares Themen-Menue. In .finalflux-main
  neutralisiert; Shell stapelt auf Mobil als display:block, Sidebar volle Hoehe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:12:10 +02:00
AliceDarkest b4d71d7d59 Merge pull request 'Feature/responsive mobile' (#5) from feature/responsive-mobile into main
mindflux-ci / build (push) Successful in 29s
Reviewed-on: #5
2026-06-29 17:51:19 +00:00
AliceDarkest e26a0c059c Fehler-Rot von Orange abgesetzt (kuehleres Crimson)
--danger lag farblich zu nah am orangen Akzent (v.a. Light-Mode:
accent #d65116 vs danger #c61f2b). Jetzt kuehleres Crimson-Rose:
dunkel #ff4d6d, hell #be123c -> klare Abgrenzung von Orange, weiterhin
eindeutig Alarm-Rot. Erbt automatisch in alle Spiele-/Alert-States.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:48:03 +02:00
AliceDarkest bf27c9ccdc Fix: Login-Overlay im Light-Mode lesbar (Orange-Panel statt weiss-auf-weiss)
Das Overlay nutzt im Dark-Mode dunkle Surface-Toene mit Glow. Im Light-Mode
sind diese weiss -> das rechte Panel verschwand (weiss auf weiss). Per
[data-theme="light"]-Overrides bekommt es jetzt ein kraeftiges Orange-Panel
(accent-2 -> accent) mit weissem Text, Emblem und Ghost-Button. Dark-Mode
bleibt unveraendert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:43:14 +02:00
AliceDarkest 0e4645541d Responsive: mobile Off-Canvas-Navigation + Layout-Anpassungen
- Sidebar wird auf <=820px nicht mehr ausgeblendet (kein Nav-Verlust), sondern
  zu einem Off-Canvas-Drawer mit Hamburger-Button + Backdrop (theme.js
  injiziert ihn auf allen .mf-shell-Seiten; Styles in theme.css).
- Topbar stapelt/umbricht auf Mobil (dekorativer Puls + Suche ausgeblendet,
  kleinere Begrüßung), HUD-Karten-Grid 3->2->1, breite mf-Tabellen horizontal
  scrollbar. Schließen per Backdrop, Nav-Link oder Escape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:39:33 +02:00
AliceDarkest 477d4dc04a Merge pull request 'Rechtstexte, FinalFlux-Überarbeitung, neue Login-Optik, Konto-Löschung-Fix, BTTF-Farbrelaunch' (#4) from feature/bttf-relaunch-und-inhalte into main
mindflux-ci / build (push) Successful in 30s
Reviewed-on: #4
2026-06-29 17:27:45 +00:00
AliceDarkest 34b050503d Rechtstexte, FinalFlux-Überarbeitung, neue Login-Optik, Konto-Löschung-Fix, BTTF-Farbrelaunch
- Rechtsseiten: Impressum-Platzhalter + echte Nutzungsbedingungen/AGB,
  Datenschutz (DSGVO) und Richtlinien; LegalController/ImpressumController,
  Footer app-weit verlinkt, Seiten als permitAll freigegeben.
- FinalFlux: 28 Themen / ~742 Fragen überarbeitet (plausible Distraktoren statt
  Dummy-Antworten, echte Erklärungen) + echte Lehrtexte je Seite;
  Netzwerktopologien mit Inline-SVG-Diagrammen (Stern/Bus/Ring/Mesh/Baum).
- Login/Registrierung als kombiniertes Dual-Panel (Slide-Overlay + Neon-Hover-
  Button), ausgelagert nach auth.css; Register-Duplikat -> /?registerError.
- Konto löschen repariert: @Transactional + Löschen von Spielständen,
  Achievements und Profilbild-Datei (DSGVO Art. 17). Impressum-Zurück-Link
  login-abhängig.
- App-weiter Farb-Relaunch auf "Back to the Future"-Palette: Feuer-Amber primär
  + Flux-Blau sekundär, warmer Glow; Dark/Light bleiben. Spiele-Feedback ohne
  Grün: richtig = Blau + Haken, falsch = Rot + Kreuz (neue Tokens
  --success/--danger/--secondary). Legacy-Cyan in style.css auf Variablen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:05 +02:00
AliceDarkest 83929f24ba Merge pull request 'Profilbild-Upload (413+Skalierung) und CSRF' (#3) from fix/upload-csrf into main
mindflux-ci / build (push) Successful in 27s
2026-06-26 13:24:32 +00:00
AliceDarkest fc98e5aad1 Profilbild-Upload (413 + Auto-Skalierung) und CSRF aktivieren
- application.properties: multipart max-file/request-size auf 10MB
  (nginx client_max_body_size muss serverseitig passend gesetzt sein)
- ProfileController: hochgeladene Profilbilder werden serverseitig auf
  max. 400px herunterskaliert und als JPEG normalisiert gespeichert
  (statt der Originaldatei roh); nicht lesbare Bilder werden ignoriert
- SecurityConfig: CSRF aktiviert; Spiele-JSON-APIs (/api/games,
  /api/techtacular, /api/mindmatch) ausgenommen; GET-Logout erlaubt,
  da die Logout-Links in den Templates GET sind

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:22:25 +02:00
AliceDarkest b3a0fde104 Merge pull request 'Fix: Passwort-Aenderung Whitelabel-Error' (#2) from fix/change-password into main
mindflux-ci / build (push) Successful in 26s
2026-06-26 13:10:01 +00:00
AliceDarkest 7cb6621d1b Fix: Passwort-Änderung wirft Whitelabel-Error
ProfileController.changePassword gab "profile" zurück, ohne das von
profile.html benötigte Model-Attribut `user` zu setzen -> Template-Rendering
schlug fehl (500/Whitelabel). Zudem lag das Formular auf der settings-Seite.

- changePassword nutzt jetzt PRG: Redirect auf /profile/settings mit
  Flash-Attributen (message/error) statt direktem View-Return.
- settings.html zeigt die Flash-Meldungen (Erfolg/Fehler) an.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:07:53 +02:00
AliceDarkest 7ede78d984 Merge pull request 'Volle Mindflux-App ins Deploy-Repo uebernehmen' (#1) from deploy/full-app into main
mindflux-ci / build (push) Successful in 27s
2026-06-26 12:59:01 +00:00
AliceDarkest 9022961762 Volle Mindflux-App ins Deploy-Repo übernehmen
Ersetzt das Hello-World-Gerüst durch die vollständige Anwendung
(com.example.mindflux: 64 Klassen, Thymeleaf-Templates, Static-Assets,
Flyway V1–V6). Deploy-Infrastruktur (.gitea, Dockerfile) bleibt erhalten.

- pom: volle Abhängigkeiten (Security, Thymeleaf, JPA, Flyway), Spring Boot 3.4.4 / Java 17
- docker-compose: SPRING_DATASOURCE_* unverändert; App lauscht auf 8080
  (nginx -> mindflux:8080); Uploads-Volume; Admin-Passwörter via Gitea-Secrets
- Workflow: Deploy-Step injiziert MINDFLUX_ADMIN*_PASSWORD aus Secrets
- .dockerignore ergänzt; Hello-World (com.mindflux) und Test-Datei 'main' entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:51:30 +02:00
287 changed files with 37463 additions and 145 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
target
uploads
*.log
.env
.env.*
.claude
.idea
.settings
.classpath
.project
+2
View File
@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf
+10
View File
@@ -21,6 +21,16 @@ jobs:
docker push localhost:5000/mindflux:latest
- 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
+36 -41
View File
@@ -1,45 +1,40 @@
# ---> Java
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# ---> Maven
HELP.md
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
.mvn/wrapper/maven-wrapper.jar
uploads/
.env
.env.*
!.env.example
*.log
.claude/
hs_err_pid*.log
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
# Eclipse m2e generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
+360
View File
@@ -0,0 +1,360 @@
# Changelog
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.
### Responsive Darstellung & Barrierefreiheit
- Logout-Navigation in Dashboard, DevLab, MindMatch, SyntaxSaga und TechTacular auf ein
einheitliches Icon mit zugänglichem Namen und Tooltip „Abmelden“ vereinheitlicht.
- **Globales Accessibility-Widget:** Ein schwebender Rollstuhl-Button öffnet auf jeder Seite
ein tastaturbedienbares Panel. Einstellungen werden ausschließlich lokal im Browser gespeichert.
- Enthalten sind vier Textgrößen, Hochkontrast, farbenblindfreundliche Statusfarben, Monochrom,
einen Lesemodus für Dyslexie mit Verdana und angepassten Abständen, unterstrichene Links, reduzierte Animationen,
großer Cursor und ein zentraler Reset.
- Floating-Button an die Mindflux-Palette angepasst; der Schrift-Schalter heißt nun transparent
„Lesemodus (Dyslexie)“ und weist auf Verdana plus vergrößerte Abstände hin.
- Der dauerhaft erreichbare Auslöser besitzt nun reservierten Layoutplatz statt Spielkarten oder
Formulare zu überdecken: seitlich auf Desktop und in einer unteren Schutzzone auf kleinen Displays.
- Accessibility-Zugriff in die jeweilige sticky Kopfzeile neben Theme/Logout verschoben; Seiten
ohne Werkzeugleiste erhalten eine eigene reservierte Utility-Zeile statt eines Inhalts-Overlays.
- Accessibility-Schaltfläche auf dieselben 38 px Außen- und 18 px Iconmaße wie die übrigen
Kopfzeilen-Werkzeuge vereinheitlicht.
- SystemEscape behält seine Terminal-Typografie im Spiel, verwendet für globale Navigation und
Werkzeuge aber die einheitliche Mindflux-Sans-Serif-Schrift.
- TechTacular-Kategoriekarten auf identische Höhe gesetzt und zweizeilige Titel mittig ausgerichtet.
- Das Panel besitzt Dialogsemantik, sichtbaren Tastaturfokus, Escape-Schließen und verständliche
Aktivzustände; Spielzustände oder Benutzerkonten werden dabei nicht verändert.
- **DevLab-Spielansicht nutzt große Bildschirme aus:** Die bisherige starre Begrenzung auf
1100 px wurde durch ein fluides, bis 2200 px breites Viewport-Layout ersetzt. Hauptbereich,
Seitenpanel, Abstände und Grundschrift skalieren mit der verfügbaren Bildschirmgröße.
- Auf Mobilgeräten bleibt das Layout einspaltig und setzt die Grundschrift auf mindestens 16 px.
### 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
SystemEscape, 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.
### Zukunftsfundament & FluxTimer
- **Plan technisch aktualisiert:** freie Flyway-Reihenfolge V11–V14, persistiertes
`exam_scope`-Tagging und verbindlicher S3-Anti-Cheat-Vertrag für alle neuen Spiele.
- **Spiel-Akzentbasis:** Dark-/Light-Tokens für SystemEscape, BugHunter, ServiceClash und NetWorker.
- **FluxTimer-MVP:** lokaler Pausentimer mit 5–240 Minuten Intervall, rotierenden Erinnerungen,
eigenem Text, Snooze, Probelauf, optionalem Sound und lokaler Browser-Benachrichtigung.
Countdown und Overlay laufen über Navigationen weiter; ein eigener Topbar-Button öffnet den
Schnellzugriff und die Einstellungen enthalten die vollständige Konfiguration.
- **Dashboard-Topbar aufgeräumt:** generische Winkehand und dekorative Pulsline entfernt.
FluxTimer besitzt statt der Glocke jetzt ein eigenes Uhr-mit-Blitz-Icon im Zeitreise-Look;
die Glocke bleibt frei für spätere echte Benachrichtigungen.
- **Login entschlackt:** das generische Schloss-Emoji aus dem Login-Button entfernt; die klare
Beschriftung reicht als Handlungsaufforderung und passt besser zur eigenständigen Designsprache.
- Datenschutzerklärung um lokale Speicherung und Browser-Benachrichtigung ergänzt.
### SystemEscape (interner Key `codeescape`)
- **Variable SystemEscape-Runs:** Jedes Szenario besitzt nun acht Räume; ein Durchlauf wählt
serverseitig vier davon in zufälliger Reihenfolge. Der Pool wächst damit auf 24 Räume und
69 Rätsel, mit 1.680 möglichen geordneten Raumkombinationen je Szenario.
- Die konkrete Raum- und Rätselauswahl wird im Server-Run festgeschrieben. Checks, Hinweise,
Türen, Finish, Maximalpunktzahl und Sterne akzeptieren ausschließlich IDs dieses Runs.
- Lobby erklärt Poolgröße und Run-Länge; ein neuer Start erzeugt ausdrücklich eine neue Route.
- **Sichtbarer Name zu SystemEscape geändert:** Der neue Name bildet Netzwerk-, Security-,
Hardware-, Backup- und Entwicklungsthemen besser ab. Interner Key, Route und Datenmodell
bleiben für bestehende Scores und Migrationen kompatibel bei `codeescape`.
- **Spielablauf verdeutlicht und Schleusenfehler behoben:** dauerhafte Drei-Schritt-Führung
„Rätsel lösen → Fragmente sammeln → Türcode bilden“, nummerierte Fragmentreihenfolge,
optionales automatisches Einsetzen und klare Rückmeldungen bei Prüfung, Erfolg und Fehler.
- **Produktive Türcodes repariert:** ergänzende V11.1-Datenmigration gleicht alle neun Türcodes
mit ihren sichtbaren Fragmenten ab. Die Lösereihenfolge der Rätsel bleibt bewusst frei.
- Schleusen lassen sich serverseitig erst nach Abschluss aller Rätsel öffnen; ein zusätzlicher
Test sichert diesen Ablauf und die hilfreiche Fehlermeldung ab.
- **Neues vollständiges Spiel:** drei Facility-Szenarien mit neun Räumen und 24 IT-Rätseln
von AP1 bis AP2 (FIAE/FISI/Cross), Fragment-Inventar, Türcodes, Countdown, Alarmzustand,
Hinweise, Reveal, Zeitbonus, Sterne, Highscore und Ergebnis-Debriefing.
- **Serverautoritativ:** V11-Schema mit `exam_scope`, lösungsfreie Session-DTOs, serverseitig
protokollierte Versuche/Hinweise/Reveals/Türen und vollständige Score-Neuberechnung beim Finish.
Fremde oder doppelte IDs, überlange Listen und unplausible Zeiten werden abgewiesen.
- **Vier Mechaniken:** freie Code-Eingabe, Choice, Reihenfolge und Matching; alle Lösungen bleiben
bis zur expliziten Auflösung auf dem Server.
- **Eigene visuelle Welt:** dunkle Facility-/CRT-Optik, Scanlines, Terminal-Dialoge,
Fragment-Chips und SystemEscape-Akzent im Dashboard; responsive für mobile Ansichten.
- **Admin-CRUD:** Szenarien, Räume und Rätsel inklusive JSON-Payload und Prüfungsbereich pflegbar.
- Dashboard, Security-CSRF-Ausnahme, Score-/XP-/Achievement-System und Admin-Dashboard integriert.
- Vier neue Service-Tests prüfen Lösungsfreiheit, serverseitige Hint-/Versuchsabrechnung sowie
Schutz vor fremden IDs und unplausiblen Laufzeiten.
## [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
überarbeitete FinalFlux-Inhalte, neue Login-Optik, eine reparierte
Konto-Löschung sowie ein app-weiter Farb-Relaunch auf das
„Back to the Future"-Schema.
### Hinzugefügt
- **Rechtsseiten:** Impressum-Platzhalter (Hinweis „in Entwicklung / nicht für die öffentliche Nutzung") sowie **echte** Nutzungsbedingungen/AGB, Datenschutzerklärung (DSGVO-konform, spiegelt die tatsächlich verarbeiteten Daten) und Community-/Nutzungsrichtlinien. Neuer `LegalController` (`/nutzungsbedingungen`, `/datenschutz`, `/richtlinien`) + `ImpressumController`; alle Footer-Links app-weit verdrahtet.
- **Kombinierte Login/Registrierung:** Dual-Panel mit überschiebendem Overlay (Sign-in ↔ „Account erstellen") und Neon-Button mit Hover-Expand und rotierendem Conic-Glow; ausgelagert nach `static/css/auth.css`. Responsiver Fallback (gestapelt) auf schmalen Screens.
- **FinalFlux – Netzwerktopologien:** fünf theme-adaptive Inline-SVG-Diagramme (Stern, Bus, Ring, Mesh, Baum) in den Erklärungen.
### Geändert
- **FinalFlux-Inhalte komplett überarbeitet (28 Themen, ~742 Fragen):** die generischen Dummy-Falschantworten („nur Dekoration", „zufälliger Begriff" …) durch plausible, themennahe Distraktoren ersetzt, jede Frage mit echter Begründung und variierender Antwortposition. Jede Themenseite hat jetzt **echten Lehrtext** (Definitionen, Funktionsweise, Rechenbeispiele, Merksätze) statt des bisherigen Stubs.
- **App-weiter Farb-Relaunch auf „Back to the Future"-Palette:** Primär **Feuer-Amber** (`#f0a81a`) + Gelb-Glow, **Flux-Blau** (`#2d9cce`) als Zweitakzent (u. a. Links); warmer Bernstein-Glow statt Matrix-Grün. Dark/Light bleiben (sehr dunkler/sehr heller Grund). Zentral über `theme.css`-Tokens; Legacy-Cyan in `style.css` auf Theme-Variablen umgestellt.
- **Spiele-Feedback ohne Grün:** richtig = **Flux-Blau + ✓**, falsch = **Rot + ✗** in FinalFlux, MindMatch und TechTacular (löst den fehlenden Kontrast von „grün auf grün"); neue Tokens `--success`, `--danger`, `--secondary`. Das Fehler-Rot ist ein kühleres Crimson (`#ff4d6d` dunkel / `#be123c` hell), damit es sich klar vom orangen Akzent abhebt (v. a. im Light-Mode).
- Registrierungs-Duplikat leitet auf `/?registerError` (kombinierte Seite öffnet automatisch das Signup-Panel).
- **Vollständig responsive / mobiltauglich:** Auf schmalen Screens wird die App-Sidebar nicht mehr ausgeblendet (vorher fehlte die Navigation komplett), sondern zu einer Off-Canvas-Schublade mit Hamburger-Button + Backdrop (global via `theme.js` injiziert, Styles in `theme.css`). Topbar stapelt/umbricht (dekorativer Puls + Suche ausgeblendet), Spielkarten-Grid 3→2→1, breite Tabellen horizontal scrollbar. Spiele, FinalFlux und Login/Registrierung waren bereits responsiv.
### Behoben
- **Konto-Löschung repariert:** „Profil löschen" warf einen 500er (fehlendes `@Transactional` bei einer abgeleiteten Delete-Query) und löschte nichts. Jetzt werden Konto **plus** Spielstände, Achievements und Profilbild-Datei in einer Transaktion entfernt — erfüllt die in der Datenschutzerklärung zugesicherte Löschung (Art. 17 DSGVO).
- Impressum-„Zurück"-Link loggte eingeloggte Nutzer scheinbar aus; jetzt login-abhängig (eingeloggt → Dashboard, sonst → Startseite). Gleiche Logik auf den Rechtsseiten.
- Login-Overlay im **Light-Mode** war weiß-auf-weiß/verwaschen; jetzt kräftiges Orange-Panel mit weißem Text (Dark-Mode unverändert).
- **Mobile-Bedienbarkeit der Spiele:** MindMatch- und TechTacular-Boards ragten auf dem Handy aus dem Bildschirm, ohne dass man scrollen konnte (`height:100vh; overflow:hidden` + iOS-Adressleiste). Auf Touch-Breiten jetzt scrollbar, Karten/Kacheln mit fester Proportion bzw. Mindesthöhe → alle Felder erreichbar.
- **FinalFlux-Hub auf Mobil:** Überlappung von Kopfbereich und Inhalt + unerreichbares Themen-Menü. Ursache: das generische `main {}` aus der Alt-`style.css` traf das Hub-`<main>`; jetzt in `.finalflux-main` neutralisiert und die Shell stapelt auf Mobil sauber (`display:block`, Sidebar mit voller Höhe).
### Sicherheit
- Neue Rechtsseiten öffentlich erreichbar (`permitAll` für `/impressum`, `/nutzungsbedingungen`, `/datenschutz`, `/richtlinien`).
### Hinweise
- Footer-Links **Support** und **Feedback** sind weiterhin Platzhalter (`href="#"`).
- Die Rechtstexte sind inhaltlich vollständig, aber KI-erstellt — vor einem öffentlichen Launch fachlich/juristisch prüfen (insb. konkrete Anbieteranschrift im Impressum).
- Vier ungenutzte Alt-CSS-Dateien (`progress/settings/achievements/soziales.css`, keine Referenzen) blieben vorerst liegen.
## [Unreleased] – 2026-06-25
Großer Ausbau: zwei vollständige Spiele, durchgängig DB-gestützte und
admin-pflegbare Inhalte sowie ein kompletter Optik-Relaunch (Theme-System
mit Dark/Light-Umschaltung).
### Hinzugefügt
#### Spiel: MindMatch (Lern-Memory)
- Memory-Spiel, das **Begriff ↔ Bedeutung** paart (klassische Aufdeck-Mechanik mit Lernwert).
- Deck-Auswahl, Zug-/Zeitzähler, Sternebewertung und „Spiel abbrechen".
- **No-Scroll-Layout**: alle Karten einer Runde passen ohne Scrollen auf den Schirm (Brett skaliert die Karten).
- Kartenrückseiten tragen das leuchtende Mindflux-Emblem (grün/magenta je Theme).
- Inhalte **DB-gestützt und im Admin pflegbar** (`/admin/mindmatch`): Decks und Paare jederzeit erweiterbar.
- Mitgelieferte Decks: Netzwerk, Hardware, IT-Sicherheit, SQL-Befehle sowie ein großer Fachinformatiker-Satz (15 Decks insgesamt).
#### Spiel: TechTacular (Jeopardy-Quiz)
- **Echtes Jeopardy-Prinzip (umgekehrt):** angezeigt wird eine Aussage, gesucht ist der Begriff als „Was ist …?".
- 5×5-Board (Kategorie × Punktwert 100–500), Antwortprüfung mit Erklärung.
- **Server-autoritativ & Anti-Cheat:** das Board enthält nie die Lösung; Prüfung und Wertung passieren ausschließlich serverseitig.
- **≥ 60 % neue Fragen pro Runde:** das Board schließt die Fragen der Vorrunde aus.
- **Kuratierter Inhalt:** 12 Kategorien über das ganze Informatik-Prüfungsspektrum (Netzwerk, Betriebssysteme, Programmierung, Datenstrukturen & Algorithmen, Datenbanken/SQL, IT-Sicherheit & Krypto, Web, Rechnerarchitektur, Software-Engineering, Theoretische Informatik, Cloud/DevOps, IT-Recht), **384 Hinweise**.
- Admin-Verwaltung als Drill-down (`/admin/techtacular`).
#### Generisches Spiel-Backend
- Tabelle `game_scores` + `GameScoreService`/`GameScoreController` mit `POST /api/games/{game}/scores` und `GET /api/games/{game}/stats` für alle Spiele.
- `/progress` zeigt echte Spiel-Statistiken (Highscore, Spiele, zuletzt gespielt).
#### Optik-Relaunch / Theme-System
- **Zentrale Tokens** in `theme.css` + Umschalter `theme.js`, gesteuert über `<html data-theme="dark|light">` (Persistenz via `localStorage`, kein Aufblitzen).
- **Dark = Grün-Neon, Light = heller Grund + Magenta** (bewusst invertiert).
- Wiederverwendbare `mf-`-Komponenten (Shell/Sidebar/Topbar, HUD-Karten, Panels, Buttons, Formulare, Tabellen, Footer …).
- **Dashboard** im „Cyber-Grid"-Look neu: linke Sidebar (Nav, Profil, Level/XP, Streak), Mitte mit HUD-Spielkarten, rechte Widget-Spalte (System-Status, Fortschritt, Top-Kategorien, Challenge), Top-Leiste mit Suche/Glocke/Theme-Schalter.
- Original-Mindflux-Logo per CSS-Maske theme-abhängig eingefärbt (`/img/mindflux-emblem.png`).
- Theme + Umschalter **app-weit** ausgerollt: Login/Registrierung, beide Spiele, Profil/Fortschritt/Achievements/Einstellungen/Soziales/Knowledge, alle Admin-Seiten, FinalFlux-Hub und 28 statische Lernseiten.
- **Fußzeile** (Support · Feedback · Nutzungsbedingungen · Richtlinien · Datenschutz · Impressum) als `.mf-footer`-Komponente, einheitlich auf allen Content-Seiten.
### Geändert
- `ProgressService` liefert echte DB-Daten statt der bisherigen Dummy-Spiele.
- Admin-Bootstrap-User auf `AliceDarkest` umbenannt (vorher „Alice Darkest" mit Leerzeichen); zusätzlicher Admin `KevCipher` angelegt.
### Sicherheit
- Statische JS-Ressourcen (`/js/**`) wie CSS/Bilder öffentlich (`permitAll`) — kein schützenswerter Inhalt.
- Score-Endpunkte gegen eine Allowlist bekannter Spiele abgesichert; Punkte werden serverseitig berechnet (kein vom Client gemeldeter Score).
### Technisch
- **Flyway-Migrationen V2–V6:** `game_scores` (V2), MindMatch-Decks/-Paare (V3) + Fachinformatiker-Inhalte (V4), TechTacular-Schema (V5) + kuratierte Reverse-Inhalte (V6).
- Inhalts-Import für TechTacular über einen `CommandLineRunner`-Seeder aus `techtacular/content.json`.
- Hinweis (Thymeleaf): Inline-`<script>` mit `[[`/`]]` benötigt `th:inline="none"`, sonst bricht die Antwort ab.
### Hinweise
- Footer-Links sind aktuell Platzhalter (`href="#"`); echte Impressum-/AGB-/Datenschutz-Seiten existieren noch nicht.
- Die fünf übrigen Dashboard-Spiele (Syntax Saga, Code Escape Room, Service Clash, Bug Hunter, Netzwerker) sind noch nicht umgesetzt und verweisen auf „coming soon".
---
_Frühere Stände (Grundgerüst der Lernplattform: Benutzer/Login, Profil, Achievements, FinalFlux, Admin) wurden vor Einführung dieses Changelogs entwickelt._
+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).*
+28 -4
View File
@@ -5,16 +5,40 @@ services:
restart: unless-stopped
environment:
# --- 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"
MINDFLUX_PUBLIC_URL: "https://mindflux.sysnaptik.de"
MINDFLUX_COOKIE_SECURE: "true"
# --- Bootstrap-Admins: Passwörter kommen aus Gitea-Secrets (siehe Workflow) ---
MINDFLUX_ADMIN_USERNAME: "AliceDarkest"
MINDFLUX_ADMIN_EMAIL: "AliceDarkest@gmail.com"
MINDFLUX_ADMIN_PASSWORD: "${MINDFLUX_ADMIN_PASSWORD}"
MINDFLUX_ADMIN2_USERNAME: "KevCipher"
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
networks:
- backend_network
ports:
- "8081:8080"
networks:
backend_network:
external: true
volumes:
mindflux_uploads:
-17
View File
@@ -1,17 +0,0 @@
hello ci pipeline
versuch 2
Versuch 3
Versuch 4
Versuch 5
Versuch 6
Versuch 7
Versuch 8
Versuch 9
Versuch 10
Versuch 11
Versuch 12
Versuch 13
Versuch 14 Claude Fix
Versuch 15 Maven Fix
Versuch 16 Maven Fix pushed
rollback
+543
View File
@@ -0,0 +1,543 @@
# 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. **SystemEscape** – „Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System."
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 `codeescape`, `serviceclash`, `bughunter`, `networker` stehen in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`) und in `ProgressService.GAMES`; 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, SyntaxSaga, DevLab) 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):** SystemEscape → 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:** Neue Parent-Inhalte erhalten von Anfang an ein persistiertes Feld `exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1'` mit der Allowlist `AP1 | AP2_FIAE | AP2_FISI | CROSS`. Seeder und Admin validieren dieses Feld; Katalog-DTOs geben es aus. Titelpräfixe sind nur Anzeige, nie Datenquelle. Lobby-Filter können dadurch später ohne Migration ergänzt werden.
- **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` | – |
| SystemEscape | **SystemEscape** | `/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 historischen Plan-Aliasse `escape-room`, `service-clash`, `bug-hunter` und `netzwerker` hatten noch keine Scores und wurden deshalb vor der Implementierung auf `codeescape`, `serviceclash`, `bughunter` und `networker` vereinheitlicht. Nur `syntax-saga` behält seinen Key wegen möglicher Bestandsdaten. Die Tabelle und der Abschnitt „Gemeinsames Architektur-Rezept" sind die verbindliche Quelle; verbliebene Altbegriffe in erklärenden Designpassagen sind keine Routen- oder Schema-Vorgaben.
---
## Visuelle Identität: vier Spiele, vier Welten
> ◐ **Fundament umgesetzt:** Die Dark-/Light-Akzent-Tokens für alle vier Spiele sind in
> `theme.css` angelegt. Offen bleiben die konkreten Karten-Designs, Spiel-Layouts, Illustrationen
> und Animationen; diese entstehen jeweils zusammen mit dem betreffenden Spiel.
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 |
|-------|---------------|--------------|---------------------------|
| **SystemEscape** | 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 (SystemEscape), 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** ab `db/migration/V11__…` (V9/V10 sind durch User-/Datenintegrität belegt; `ddl-auto=validate` ⇒ Migration ist Pflicht). Reserviert: V11 SystemEscape (interner Key `codeescape`), V12 BugHunter, V13 NetWorker, V14 ServiceClash; danach FluxNet. SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `exam_scope VARCHAR(20)`, `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.)
### Verbindlicher Sicherheitsvertrag für neue Spiele (S3)
1. Session-/Katalogantworten enthalten nur opaque IDs und lösungsfreie DTOs; Lösung, Reveal-Status und Scoring-Metadaten bleiben serverseitig.
2. `finish` akzeptiert nur IDs aus der angeforderten Runde und verwirft fremde, doppelte oder nicht zum Parent gehörende IDs.
3. Punkte, Sterne, Highscore, gelöste Anzahl und Boni werden ausschließlich serverseitig aus kanonischen Daten neu berechnet.
4. `durationSeconds` muss plausibel sein (`>= 1` und höchstens das konfigurierte Limit plus dokumentierte Toleranz); Listen und Zähler erhalten fachliche Obergrenzen.
5. Reveal-/Hint-Nutzung wird serverseitig protokolliert oder beim Finish erneut validiert und kann nicht durch Weglassen im Request verborgen werden.
6. Deterministische Seeds werden spätestens bei ServiceClash mit einem serverseitigen HMAC signiert; der Client darf keinen frei wählbaren Seed zur Wertung einreichen.
7. Für jedes Spiel entstehen Negativtests: fremde IDs, Duplikate, überlange Listen, unmögliche Zeiten, manipulierte gesperrte Felder und Reveal-vor-Finish.
---
## Teil 1: Spieldesigns
### Plattformweite Barrierefreiheit ✅ Grundwidget umgesetzt
- Globales, per Tastatur erreichbares Accessibility-Panel über einen schwebenden Rollstuhl-Button.
- Schriftgröße in vier nachvollziehbaren Stufen (100 %, 115 %, 130 %, 150 %), lokal gespeichert
und auf Dashboard, Lernseiten sowie allen Spielen identisch angewendet.
- Enthalten: erhöhter Kontrast, farbenblindfreundliches Statusfarbprofil, Monochrommodus,
Lesemodus für Dyslexie mit Verdana und angepassten Abständen, unterstrichene Links, reduzierte Bewegungen, großer
Cursor, gut sichtbare Fokusmarkierungen und zentraler Reset.
- Noch offen bleibt die systematische Prüfung aller Spielmechaniken auf vollständige
Tastaturbedienbarkeit sowie ein WCAG-Audit mit Kontrastmessung und Screenreader-Tests.
- Layouts dürfen bei Textvergrößerung weder Inhalte abschneiden noch horizontales Scrollen der
gesamten Seite erzwingen; Codebereiche dürfen weiterhin gezielt horizontal scrollen.
- Das Panel ergänzt semantisches HTML und WCAG-Prüfungen, ersetzt diese aber nicht.
### Spiel 1: SystemEscape (interner Key: `codeescape`, Migration V11 + Datenfix V11.1) ✅ MVP inklusive Spielführung umgesetzt
**Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** besitzt einen Pool aus 8 Räumen; jeder Run zieht daraus 4 Räume in zufälliger Reihenfolge. Jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment**; 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, "codeescape", szenarioSlug, baseScore + zeitbonus, gelösteRätsel, sekunden)`.
**Datenmodell (`V11__codeescape.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/codeescape`):** `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: BugHunter (Key: `bughunter`, Migration V12)
**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, "bughunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
**Datenmodell (`V12__bughunter.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/bughunter`):** `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: ServiceClash (Key: `serviceclash`, Migration V14)
**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 (`V14__serviceclash.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/serviceclash`):** `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: NetWorker (Key: `networker`, Migration V13)
**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 (`V13__networker.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/networker`):** `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, Dashboard-Namen und Keys, `devlab` in `ProgressService`, K2,
W8, V11–V14-Migrationsreservierung, `exam_scope`-Konvention, S3-Sicherheitsvertrag und
Spiel-Akzent-Tokens in `theme.css` sind umgesetzt.
1. **FluxTimer ✅** – risikoarmer, rein lokaler Ausbau; validiert den globalen UI-Bootstrap und belegt die Glocke vor FluxNet. ≈ 1,5–2 PT
2. **SystemEscape (V11) ✅** – serverautoritatives Spiel mit 3 Szenarien, 24 Räumen, 69 Rätseln
und vier zufälligen Räumen pro Run; Admin-CRUD, `exam_scope`, festgeschriebene Run-Auswahl,
dynamische Wertung und Anti-Cheat-Negativtests umgesetzt.
3. **BugHunter (V12)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 2. ≈ 4,5 PT
4. **NetWorker (V13)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
5. **ServiceClash (V14)** – 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 ✅ Fundament | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Verbindlicher Sicherheitsvertrag ist oben dokumentiert und gilt für jedes neue Spiel; konkrete Implementierung und Negativtests entstehen jeweils mit dem Spiel. |
| 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
> ✅ **MVP umgesetzt:** lokaler Zustand, globaler Countdown, Einstellungen, eigener Uhr-mit-Blitz-
> Schnellzugriff in der Topbar, In-App-Overlay, Snooze, Probelauf sowie optionale Browser-
> Benachrichtigung und Web-Audio-Signal. Die Glocke bleibt für FluxNet-/Systembenachrichtigungen
> reserviert. Eine serverseitige Synchronisation bleibt bewusst optional.
> 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 in den Einstellungen und über das eigene
Uhr-mit-Blitz-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` ✅ | Sektion „FluxTimer“: Toggle, Minuten-Eingabe, Erinnerungs-Checkboxen, Freitext, Benachrichtigung/Sound-Optionen, Probelauf-Button |
| `dashboard.html` (Topbar) ✅ | Eigenes Uhr-mit-Blitz-Icon öffnet das Popover mit Status, Countdown, Schnell-Toggle und Link zu den Einstellungen; Glocke bleibt frei |
| `theme.js` ✅ | Zentraler, idempotenter Asset-Bootstrap, damit der Timer über Seitennavigationen weiterläuft |
| `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, SystemEscape, ServiceClash, BugHunter, NetWorker) und die verbliebenen `'/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.
+120 -54
View File
@@ -1,59 +1,125 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>com.mindflux</groupId>
<artifactId>mindflux</artifactId>
<version>1.0.0</version>
<modelVersion>4.0.0</modelVersion>
<parent>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<groupId>com.mindflux</groupId>
<artifactId>mindflux</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mindflux</name>
<description>Mindflux – Lernplattform zur Prüfungsvorbereitung</description>
<properties>
<!-- Angeglichen an die Dockerfile-Laufzeit (Temurin 21) -->
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf für Templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Thymeleaf + Spring Security Integration -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Security komplett -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation (@Valid, @NotBlank, @Email ...) -->
<dependency>
<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 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Datenbank-Migrationen -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Hot Reload für Entwicklung -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Unit Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<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>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- JPA optional aber sinnvoll -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+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,10 +1,11 @@
package com.mindflux;
package com.example.mindflux;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MindfluxApplication {
public static void main(String[] args) {
SpringApplication.run(MindfluxApplication.class, args);
}
@@ -0,0 +1,64 @@
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;
import org.springframework.context.annotation.Configuration;
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;
@Value("${MINDFLUX_ADMIN_EMAIL:admin@mindflux.de}")
private String adminEmail;
@Value("${MINDFLUX_ADMIN_PASSWORD:}")
private String adminPassword;
// Optionaler zweiter Bootstrap-Admin. Wird nur angelegt, wenn Username + Passwort gesetzt sind.
@Value("${MINDFLUX_ADMIN2_USERNAME:}")
private String admin2Username;
@Value("${MINDFLUX_ADMIN2_EMAIL:}")
private String admin2Email;
@Value("${MINDFLUX_ADMIN2_PASSWORD:}")
private String admin2Password;
@Bean
public CommandLineRunner createAdmin(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder) {
return args -> {
createAdminIfAbsent(userRepo, passwordEncoder, adminUsername, adminEmail, adminPassword);
createAdminIfAbsent(userRepo, passwordEncoder, admin2Username, admin2Email, admin2Password);
};
}
private void createAdminIfAbsent(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder,
String username, String email, String password) {
if (username == null || username.isBlank()) {
return; // Admin-Slot nicht konfiguriert -> still überspringen.
}
if (password == null || password.isBlank()) {
log.warn("Kein Bootstrap-Admin erstellt: Passwort für '{}' ist nicht gesetzt.", username);
return;
}
if (userRepo.findByUsername(username).isEmpty()) {
User admin = new User();
admin.setUsername(username);
admin.setEmail(email);
admin.setPassword(passwordEncoder.encode(password));
admin.setRole("ADMIN");
userRepo.save(admin);
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,2 @@
package com.example.mindflux.config; import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.*;import org.slf4j.*;import org.springframework.boot.CommandLineRunner;import org.springframework.core.io.ClassPathResource;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional;
@Component public class CodeEscapeSeeder implements CommandLineRunner {private static final Logger log=LoggerFactory.getLogger(CodeEscapeSeeder.class);private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public CodeEscapeSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;}@Override @Transactional public void run(String...a){if(repo.count()>0)return;try{JsonNode root=mapper.readTree(new ClassPathResource("codeescape/content.json").getInputStream());int si=0,pc=0;for(JsonNode sn:root.path("scenarios")){EscapeScenario s=new EscapeScenario(sn.path("slug").asText(),sn.path("title").asText(),sn.path("difficulty").asText(),sn.path("examScope").asText("AP1"),sn.path("description").asText(),sn.path("timeLimitSeconds").asInt(900),si++);int ri=0;for(JsonNode rn:sn.path("rooms")){EscapeRoom r=new EscapeRoom(rn.path("title").asText(),rn.path("intro").asText(),rn.path("doorPrompt").asText(),rn.path("doorCode").asText(),rn.path("successText").asText(),ri++);int pi=0;for(JsonNode pn:rn.path("puzzles")){r.addPuzzle(new EscapePuzzle(pn.path("type").asText(),pn.path("prompt").asText(),pn.path("points").asInt(100),pn.path("fragment").asText(),pi++,mapper.writeValueAsString(pn.path("payload"))));pc++;}s.addRoom(r);}repo.save(s);}log.info("CodeEscape: {} Szenarien / {} Rätsel geladen.",si,pc);}catch(Exception e){log.error("CodeEscape-Seed fehlgeschlagen",e);}}}
@@ -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);
}
}
@@ -0,0 +1,79 @@
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;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/") // Login-Formular liegt auf der Startseite (index.html)
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an
.permitAll()
)
.logout(logout -> logout
// 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/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**")
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo, LoginAttemptService loginAttemptService) {
return 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
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
.build();
};
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -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);
}
}
@@ -0,0 +1,25 @@
package com.example.mindflux.config;
import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.ObjectMapper;import com.fasterxml.jackson.databind.node.ObjectNode;import org.springframework.boot.context.event.ApplicationReadyEvent;import org.springframework.context.event.EventListener;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional;import java.util.*;
@Component public class SystemEscapePoolSeeder{
private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public SystemEscapePoolSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;}
@EventListener(ApplicationReadyEvent.class) @Transactional public void expand()throws Exception{
add("verlassener-serverraum","DNS-Kammer","Ein Resolver hält die nächste Schleuse gesperrt.",q("DNS-Record für IPv4-Adresse?","A","A"),q("DNS-Record für Mailserver?","MX","MX"),q("DNS-Standardport?","53","53"));
add("verlassener-serverraum","VLAN-Brücke","Getrennte Segmente müssen korrekt verbunden werden.",q("VLAN-Tagging-Standard IEEE 802.?","1Q","1Q"),q("Gerät zwischen IP-Netzen?","Router","RTR"),q("VLAN ohne Tag am Trunk?","Native VLAN","NAT"));
add("verlassener-serverraum","Routing-Schacht","Das Gateway erwartet drei Routing-Grundlagen.",q("Route für alle unbekannten Ziele?","Default Route","DEF"),q("IPv4-Loopback-Adresse?","127.0.0.1","127"),q("Befehl zum Weg eines Pakets?","traceroute","TRC"));
add("verlassener-serverraum","Firewall-Gang","Nur korrekt gefilterter Verkehr öffnet den Gang.",q("Prinzip: alles verbieten, nur Nötiges erlauben?","Default Deny","DENY"),q("Firewall mit Verbindungszustand?","Stateful","STATE"),q("HTTPS-Protokoll über TCP-Port?","443","443"));
add("verlassener-serverraum","Monitoring-Zentrale","Metriken und Ereignisse liefern die Fragmente.",q("Protokoll für Geräteüberwachung?","SNMP","SNMP"),q("Zentrales Systemprotokoll?","Syslog","LOG"),q("ICMP-Werkzeug für Erreichbarkeit?","ping","PING"));
add("notfall-im-rechenzentrum","Identity-Vault","Der Identitätsdienst verlangt sichere Grundlagen.",q("Zweiter Faktor neben Passwort?","MFA","MFA"),q("Zentraler Verzeichnisdienst von Microsoft?","Active Directory","AD"),q("Minimal notwendige Rechte?","Least Privilege","LP"));
add("notfall-im-rechenzentrum","Container-Dock","Isolierte Workloads blockieren den Ausgang.",q("Container teilen den Kernel des?","Hosts","HOST"),q("Deklarative Container-Zusammenstellung?","Compose","COMP"),q("Persistenter Containerspeicher?","Volume","VOL"));
add("notfall-im-rechenzentrum","Patch-Leitstand","Ein ungepatchtes System hält die Tür verriegelt.",q("Bewertung bekannter Schwachstellen?","CVSS","CVSS"),q("Kennung einer veröffentlichten Schwachstelle?","CVE","CVE"),q("Testumgebung vor Produktion?","Staging","STG"));
add("notfall-im-rechenzentrum","Cluster-Korridor","Hochverfügbarkeit muss wiederhergestellt werden.",q("Automatischer Rollenwechsel bei Ausfall?","Failover","FAIL"),q("Verteilung von Anfragen?","Load Balancing","LB"),q("Prüfung der Dienstverfügbarkeit?","Health Check","HC"));
add("notfall-im-rechenzentrum","Storage-Array","Das Speichernetz fordert drei Begriffe.",q("Blockzugriff über IP?","iSCSI","ISCSI"),q("Dateifreigabe für Linux/Unix?","NFS","NFS"),q("Redundanz ist kein Ersatz für ein?","Backup","BAK"));
add("blackout-protokoll","PKI-Tresor","Die Zertifikatskette muss rekonstruiert werden.",q("Widerrufsliste für Zertifikate?","CRL","CRL"),q("Online-Prüfung eines Zertifikatsstatus?","OCSP","OCSP"),q("Oberste Vertrauensinstanz?","Root CA","ROOT"));
add("blackout-protokoll","API-Gateway","Die Schnittstelle akzeptiert nur sichere Antworten.",q("HTTP-Methode zum Lesen?","GET","GET"),q("Statuscode für nicht autorisiert?","401","401"),q("Datenformat mit Objekten und Arrays?","JSON","JSON"));
add("blackout-protokoll","Git-Archiv","Die Versionshistorie enthält den nächsten Schlüssel.",q("Lokale Änderungen vorm Commit vormerken?","git add","ADD"),q("Neuen Entwicklungszweig anlegen?","git branch","BR"),q("Historie eines Repositories anzeigen?","git log","LOG"));
add("blackout-protokoll","Datenbank-Kern","Transaktionen und Indizes sichern den Weg.",q("SQL-Befehl zum Lesen?","SELECT","SEL"),q("Index mit eindeutigen Werten?","UNIQUE","UNQ"),q("Transaktion zurücknehmen?","ROLLBACK","RB"));
add("blackout-protokoll","Zero-Trust-Schleuse","Jeder Zugriff muss erneut bewiesen werden.",q("Zero-Trust-Grundsatz: never trust, always?","verify","VERIFY"),q("Netze in kleine Zonen teilen?","Microsegmentation","MICRO"),q("Zugriffsentscheidung anhand von Rollen?","RBAC","RBAC"));
}
private Q q(String p,String a,String f){return new Q(p,a,f);}
private void add(String slug,String title,String intro,Q...qs)throws Exception{EscapeScenario s=repo.findBySlug(slug).orElse(null);if(s==null||s.getRooms().stream().anyMatch(r->r.getTitle().equals(title)))return;String code=Arrays.stream(qs).map(Q::fragment).reduce("",String::concat);EscapeRoom r=new EscapeRoom(title,intro,"Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.",code,"Die Schleuse entriegelt und gibt den nächsten Abschnitt frei.",s.getRooms().size());int i=0;for(Q q:qs){ObjectNode p=mapper.createObjectNode();p.put("instruction",q.prompt());p.putArray("solutions").add(q.answer());p.putArray("hints").add("Ordne den gesuchten Begriff dem IT-Kontext zu.").add("Der gesuchte Wert lautet: "+q.answer()+".");p.put("explanation",q.answer()+" ist die korrekte Lösung.");r.addPuzzle(new EscapePuzzle("code",q.prompt(),120,q.fragment(),i++,mapper.writeValueAsString(p)));}s.addRoom(r);repo.save(s);}
private record Q(String prompt,String answer,String fragment){}
}
@@ -0,0 +1,131 @@
package com.example.mindflux.config;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularCategory;
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;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
/**
* Befüllt TechTacular einmalig (wenn leer) aus der kuratierten Inhaltsdatei
* techtacular/content.json. Format: Jeopardy umgekehrt – pro Hinweis eine Aussage
* ("clue") und der gesuchte Begriff ("solution", = richtige Antwort) plus Distraktoren.
*/
@Component
public class TechtacularSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(TechtacularSeeder.class);
private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper;
public TechtacularSeeder(TechtacularCategoryRepository categoryRepository, ObjectMapper objectMapper) {
this.categoryRepository = categoryRepository;
this.objectMapper = objectMapper;
}
@Override
@Transactional
public void run(String... args) {
if (categoryRepository.count() > 0) {
return; // schon befüllt
}
try {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) {
log.warn("TechTacular-Seed: content.json ohne 'categories'.");
return;
}
Set<String> usedSlugs = new HashSet<>();
int order = 1;
int catCount = 0;
int clueCount = 0;
for (JsonNode catNode : categories) {
String title = catNode.path("title").asText("");
JsonNode clues = catNode.get("clues");
if (title.isBlank() || clues == null || !clues.isArray()) {
continue;
}
TechtacularCategory category =
new TechtacularCategory(uniqueSlug(title, usedSlugs), trim(title, 200), order, true);
for (JsonNode clue : clues) {
String clueText = clue.path("clue").asText("");
String solution = clue.path("solution").asText("");
JsonNode distractors = clue.get("distractors");
if (clueText.isBlank() || solution.isBlank() || distractors == null || !distractors.isArray()) {
continue;
}
int points = clue.path("points").asInt(100);
String explanation = clue.hasNonNull("explanation") ? clue.get("explanation").asText() : null;
TechtacularQuestion question = new TechtacularQuestion(
trim(clueText, 1000),
(explanation == null || explanation.isBlank()) ? null : trim(explanation, 1000),
points);
int sort = 0;
question.addAnswer(new TechtacularAnswer(trim(solution, 500), true, sort++)); // richtige Antwort
for (JsonNode d : distractors) {
String dt = d.asText("");
if (!dt.isBlank()) {
question.addAnswer(new TechtacularAnswer(trim(dt, 500), false, sort++));
}
}
if (question.getAnswers().size() >= 2) {
category.addQuestion(question);
clueCount++;
}
}
if (!category.getQuestions().isEmpty()) {
categoryRepository.save(category);
order++;
catCount++;
}
}
log.info("TechTacular: {} Kategorien / {} Hinweise geladen.", catCount, clueCount);
} catch (Exception e) {
log.warn("TechTacular-Seed übersprungen: {}", e.getMessage(), e);
}
}
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 = "kategorie";
}
String slug = base;
int counter = 2;
while (used.contains(slug) || categoryRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
used.add(slug);
return slug;
}
private String trim(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,16 @@
package com.example.mindflux.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
}
}
@@ -0,0 +1,28 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.service.AchievementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class AchievementController {
@Autowired
private AchievementService achievementService;
@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);
model.addAttribute("achievements", achievements);
return "achievements"; // lädt achievements.html
}
}
@@ -0,0 +1,2 @@
package com.example.mindflux.controller; import com.example.mindflux.service.CodeEscapeService;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.*;
@Controller @RequestMapping("/admin/codeescape") public class AdminCodeEscapeController {private final CodeEscapeService service;public AdminCodeEscapeController(CodeEscapeService s){service=s;}@GetMapping public String list(Model m){m.addAttribute("scenarios",service.all());return"admin/codeescape";}@PostMapping("/add")public String add(@RequestParam String title,@RequestParam String difficulty,@RequestParam String examScope,@RequestParam(required=false)String description,@RequestParam(defaultValue="900")int timeLimitSeconds){Long id=service.addScenario(title,difficulty,examScope,description,timeLimitSeconds);return"redirect:/admin/codeescape/"+id;}@GetMapping("/{id}")public String scenario(@PathVariable Long id,Model m){m.addAttribute("scenario",service.scenario(id));return"admin/codeescape-scenario";}@PostMapping("/{id}/rooms")public String room(@PathVariable Long id,@RequestParam String title,@RequestParam String intro,@RequestParam String doorPrompt,@RequestParam String doorCode,@RequestParam String successText){service.addRoom(id,title,intro,doorPrompt,doorCode,successText);return"redirect:/admin/codeescape/"+id;}@GetMapping("/rooms/{id}")public String roomPage(@PathVariable Long id,Model m){m.addAttribute("room",service.room(id));return"admin/codeescape-room";}@PostMapping("/rooms/{id}/puzzles")public String puzzle(@PathVariable Long id,@RequestParam String type,@RequestParam String prompt,@RequestParam int points,@RequestParam String codeFragment,@RequestParam String payload){service.addPuzzle(id,type,prompt,points,codeFragment,payload);return"redirect:/admin/codeescape/rooms/"+id;}@PostMapping("/{id}/toggle")public String toggle(@PathVariable Long id){service.toggle(id);return"redirect:/admin/codeescape";}@PostMapping("/{id}/delete")public String del(@PathVariable Long id){service.deleteScenario(id);return"redirect:/admin/codeescape";}@PostMapping("/rooms/{id}/delete")public String delRoom(@PathVariable Long id){var r=service.room(id);Long sid=r.getScenario().getId();service.deleteRoom(id);return"redirect:/admin/codeescape/"+sid;}@PostMapping("/puzzles/{id}/delete")public String delPuzzle(@PathVariable Long id,@RequestParam Long roomId){service.deletePuzzle(id);return"redirect:/admin/codeescape/rooms/"+roomId;}}
@@ -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,59 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.MindMatchService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
/**
* Admin-Verwaltung der MindMatch-Decks und -Paare. Liegt unter /admin/** und ist
* damit per SecurityConfig auf ROLE_ADMIN beschränkt.
*/
@Controller
@RequestMapping("/admin/mindmatch")
public class AdminMindMatchController {
private final MindMatchService mindMatchService;
public AdminMindMatchController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("")
public String manage(Model model) {
model.addAttribute("decks", mindMatchService.allDecksWithPairs());
return "admin/mindmatch";
}
@PostMapping("/decks")
public String createDeck(@RequestParam String title) {
mindMatchService.createDeck(title);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/pairs")
public String addPair(@PathVariable Long deckId,
@RequestParam String term,
@RequestParam String match) {
mindMatchService.addPair(deckId, term, match);
return "redirect:/admin/mindmatch";
}
@PostMapping("/pairs/{pairId}/delete")
public String deletePair(@PathVariable Long pairId) {
mindMatchService.deletePair(pairId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/delete")
public String deleteDeck(@PathVariable Long deckId) {
mindMatchService.deleteDeck(deckId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/toggle")
public String toggleDeck(@PathVariable Long deckId) {
mindMatchService.toggleDeck(deckId);
return "redirect:/admin/mindmatch";
}
}
@@ -0,0 +1,102 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.SyntaxSagaMission;
import com.example.mindflux.service.SyntaxSagaService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/** Admin-Verwaltung der Syntax-Saga-Missionen, -Lücken und -Lösungen (ROLE_ADMIN via /admin/**). */
@Controller
@RequestMapping("/admin/syntaxsaga")
public class AdminSyntaxSagaController {
private final SyntaxSagaService syntaxSagaService;
public AdminSyntaxSagaController(SyntaxSagaService syntaxSagaService) {
this.syntaxSagaService = syntaxSagaService;
}
/** Übersicht aller Missionen. */
@GetMapping("")
public String manage(Model model) {
model.addAttribute("missions", syntaxSagaService.missionRows());
return "admin/syntaxsaga";
}
/** Detailseite einer Mission mit ihren Lücken. */
@GetMapping("/missions/{missionId}")
public String mission(@PathVariable Long missionId, Model model) {
SyntaxSagaMission mission = syntaxSagaService.missionWithGaps(missionId);
if (mission == null) {
return "redirect:/admin/syntaxsaga";
}
model.addAttribute("mission", mission);
return "admin/syntaxsaga-mission";
}
@PostMapping("/missions")
public String createMission(@RequestParam String title,
@RequestParam(required = false) String difficulty,
@RequestParam(required = false) String narrative,
@RequestParam(required = false) String dialog,
@RequestParam(required = false) String language,
@RequestParam String codeTemplate,
@RequestParam(required = false) String successText) {
Long id = syntaxSagaService.createMission(title, difficulty, narrative, dialog,
language, codeTemplate, successText);
return id == null ? "redirect:/admin/syntaxsaga"
: "redirect:/admin/syntaxsaga/missions/" + id;
}
@PostMapping("/missions/{missionId}/update")
public String updateMission(@PathVariable Long missionId,
@RequestParam String title,
@RequestParam(required = false) String difficulty,
@RequestParam(required = false) String narrative,
@RequestParam(required = false) String dialog,
@RequestParam(required = false) String language,
@RequestParam String codeTemplate,
@RequestParam(required = false) String successText) {
syntaxSagaService.updateMission(missionId, title, difficulty, narrative, dialog,
language, codeTemplate, successText);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/missions/{missionId}/gaps")
public String addGap(@PathVariable Long missionId,
@RequestParam(defaultValue = "0") int position,
@RequestParam(defaultValue = "100") int points,
@RequestParam(defaultValue = "false") boolean caseSensitive,
@RequestParam(required = false) String hint,
@RequestParam(required = false) String placeholder,
@RequestParam(required = false, defaultValue = "") String solutions) {
List<String> parsed = Arrays.stream(solutions.split("\\r?\\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
syntaxSagaService.addGap(missionId, position, points, caseSensitive, hint, placeholder, parsed);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/gaps/{gapId}/delete")
public String deleteGap(@PathVariable Long gapId, @RequestParam Long missionId) {
syntaxSagaService.deleteGap(gapId);
return "redirect:/admin/syntaxsaga/missions/" + missionId;
}
@PostMapping("/missions/{missionId}/delete")
public String deleteMission(@PathVariable Long missionId) {
syntaxSagaService.deleteMission(missionId);
return "redirect:/admin/syntaxsaga";
}
@PostMapping("/missions/{missionId}/toggle")
public String toggleMission(@PathVariable Long missionId) {
syntaxSagaService.toggleMission(missionId);
return "redirect:/admin/syntaxsaga";
}
}
@@ -0,0 +1,79 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.service.TechtacularService;
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 TechTacular-Kategorien, -Fragen und -Antworten (ROLE_ADMIN via /admin/**). */
@Controller
@RequestMapping("/admin/techtacular")
public class AdminTechtacularController {
private final TechtacularService techtacularService;
public AdminTechtacularController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** Übersicht aller Kategorien. */
@GetMapping("")
public String manage(Model model) {
model.addAttribute("categories", techtacularService.categoryRows());
return "admin/techtacular";
}
/** Detailseite einer Kategorie mit ihren Fragen. */
@GetMapping("/categories/{categoryId}")
public String category(@PathVariable Long categoryId, Model model) {
TechtacularCategory category = techtacularService.categoryWithQuestions(categoryId);
if (category == null) {
return "redirect:/admin/techtacular";
}
model.addAttribute("category", category);
return "admin/techtacular-category";
}
@PostMapping("/categories")
public String createCategory(@RequestParam String title) {
techtacularService.createCategory(title);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/questions")
public String addQuestion(@PathVariable Long categoryId,
@RequestParam String text,
@RequestParam(required = false) String explanation,
@RequestParam(defaultValue = "100") int points,
@RequestParam(required = false, defaultValue = "") String answer1,
@RequestParam(required = false, defaultValue = "") String answer2,
@RequestParam(required = false, defaultValue = "") String answer3,
@RequestParam(required = false, defaultValue = "") String answer4,
@RequestParam(defaultValue = "0") int correct) {
List<String> answers = Arrays.asList(answer1, answer2, answer3, answer4);
techtacularService.addQuestion(categoryId, text, explanation, points, answers, correct);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/questions/{questionId}/delete")
public String deleteQuestion(@PathVariable Long questionId, @RequestParam Long categoryId) {
techtacularService.deleteQuestion(questionId);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/categories/{categoryId}/delete")
public String deleteCategory(@PathVariable Long categoryId) {
techtacularService.deleteCategory(categoryId);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/toggle")
public String toggleCategory(@PathVariable Long categoryId) {
techtacularService.toggleCategory(categoryId);
return "redirect:/admin/techtacular";
}
}
@@ -0,0 +1,89 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/admin")
public class AdminUserController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
public AdminUserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// ⬅️ Dashboard-Startseite
@GetMapping("")
public String adminDashboard() {
return "admin/dashboard";
}
// ⬅️ Benutzerverwaltung
@GetMapping("/users")
public String listUsers(Model model) {
model.addAttribute("users", userService.findAll());
return "admin/users/list";
}
@GetMapping("/users/new")
public String createUserForm(Model model) {
model.addAttribute("user", new User());
return "admin/users/form";
}
@PostMapping("/users/save")
public String saveUser(@ModelAttribute("user") User user) {
if (user.getId() != null) {
User existingUser = userService.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId()));
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 {
// Neuanlage zentral über den UserService (Validierung, Eindeutigkeit, Encoding)
userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
return "redirect:/admin/users";
}
/** 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));
model.addAttribute("user", user);
return "admin/users/form";
}
@PostMapping("/users/delete/{id}")
public String deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return "redirect:/admin/users";
}
}
@@ -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));
}
}
@@ -0,0 +1,2 @@
package com.example.mindflux.controller; import com.example.mindflux.dto.*;import com.example.mindflux.service.CodeEscapeService;import com.fasterxml.jackson.databind.JsonNode;import jakarta.servlet.http.HttpSession;import org.springframework.web.bind.annotation.*;import java.security.Principal;
@RestController @RequestMapping("/api/codeescape") public class CodeEscapeApiController {private final CodeEscapeService service;public CodeEscapeApiController(CodeEscapeService s){service=s;}@GetMapping("/scenarios")public JsonNode catalog(){return service.catalog();}@GetMapping("/session/{slug}")public JsonNode session(@PathVariable String slug,HttpSession s){return service.session(slug,s);}@PostMapping("/check")public JsonNode check(@RequestBody EscapeCheckRequest r,HttpSession s){return service.check(r.getPuzzleId(),r.isReveal(),r.getSubmission(),s);}@PostMapping("/door")public JsonNode door(@RequestBody EscapeDoorRequest r,HttpSession s){return service.door(r.getRoomId(),r.getCode(),s);}@PostMapping("/hint")public JsonNode hint(@RequestBody EscapeHintRequest r,HttpSession s){return service.hint(r.getPuzzleId(),r.getLevel(),s);}@PostMapping("/finish")public EscapeResult finish(@RequestBody EscapeFinishRequest r,Principal p,HttpSession s){return service.finish(p.getName(),r,s);}}
@@ -0,0 +1 @@
package com.example.mindflux.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class CodeEscapeController{@GetMapping("/codeescape")public String page(){return "codeescape";}}
@@ -0,0 +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(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";
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DummyController {
@GetMapping("/comingsoon")
public String comingSoon() {
return "dummy"; // verweist auf dummy.html in src/main/resources/templates
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class FinalfluxController {
@GetMapping("/finalflux")
public String showFinalfluxOverview() {
return "finalflux"; // Lädt templates/finalflux.html
}
}
@@ -0,0 +1,50 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.service.GameScoreService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.Set;
/**
* Generisches Score-API für alle Mindflux-Spiele. Der Spielname kommt aus der URL
* und wird gegen eine Allowlist geprüft, damit keine beliebigen Spiele-Kennungen
* in die Datenbank wandern.
*/
@RestController
@RequestMapping("/api/games")
public class GameScoreController {
/** Bekannte Spiele (entsprechen den Dashboard-Routen). */
private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "devlab", "codeescape",
"serviceclash", "bughunter", "networker");
private final GameScoreService gameScoreService;
public GameScoreController(GameScoreService gameScoreService) {
this.gameScoreService = gameScoreService;
}
@PostMapping("/{game}/scores")
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
@RequestBody ScoreSubmission submission,
Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.record(principal.getName(), game, submission));
}
@GetMapping("/{game}/stats")
public ResponseEntity<GameStats> getStats(@PathVariable String game, Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.getStats(principal.getName(), game));
}
}
@@ -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";
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ImpressumController {
@GetMapping("/impressum")
public String impressum() {
return "impressum"; // verweist auf impressum.html in src/main/resources/templates
}
}
@@ -0,0 +1,27 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Liefert die statischen Rechts-/Informationsseiten aus dem Footer:
* AGB / Nutzungsbedingungen, Datenschutzerklärung und Community-Richtlinien.
*/
@Controller
public class LegalController {
@GetMapping("/nutzungsbedingungen")
public String nutzungsbedingungen() {
return "legal/nutzungsbedingungen";
}
@GetMapping("/datenschutz")
public String datenschutz() {
return "legal/datenschutz";
}
@GetMapping("/richtlinien")
public String richtlinien() {
return "legal/richtlinien";
}
}
@@ -0,0 +1,26 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.MindMatchDeckView;
import com.example.mindflux.service.MindMatchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Liefert die aktiven MindMatch-Decks (inkl. Paare) an die Spielseite. */
@RestController
@RequestMapping("/api/mindmatch")
public class MindMatchApiController {
private final MindMatchService mindMatchService;
public MindMatchApiController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("/decks")
public List<MindMatchDeckView> decks() {
return mindMatchService.activeDecks();
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MindMatchController {
@GetMapping("/mindmatch")
public String showMindMatch() {
return "mindmatch"; // lädt templates/mindmatch.html
}
}
@@ -0,0 +1,209 @@
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;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal;
import java.util.UUID;
@Controller
public class ProfileController {
private static final Logger log = LoggerFactory.getLogger(ProfileController.class);
@Autowired
private UserService userService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/";
@GetMapping("/profile")
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
String username = principal.getName();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
if (Boolean.TRUE.equals(success)) {
model.addAttribute("message", "Profil erfolgreich aktualisiert!");
}
return "profile";
}
@PostMapping("/profile/delete-picture")
public String deleteProfilePicture(Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
String profilePicture = existingUser.getProfilePicture();
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
if (Files.exists(picturePath)) {
Files.delete(picturePath);
log.info("Profilbild gelöscht: {}", picturePath.getFileName());
} else {
log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath());
}
} catch (IOException e) {
log.error("Fehler beim Löschen der Profilbild-Datei", e);
}
} else {
log.debug("Kein Profilbild gesetzt, nichts zu löschen.");
}
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
userService.save(existingUser);
}
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();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
return "knowledge";
}
@PostMapping("/profile/update")
public String updateProfile(@RequestParam("email") String email,
@RequestParam(value = "profilePicture", required = false) MultipartFile file,
Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
existingUser.setEmail(email); // Email aktualisieren
if (file != null && !file.isEmpty()) {
try {
BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) {
// Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern.
log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
} else {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400;
int w = original.getWidth();
int h = original.getHeight();
double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund
g.fillRect(0, 0, nw, nh);
g.drawImage(original, 0, 0, nw, nh, null);
g.dispose();
// Altes Profilbild löschen (falls vorhanden)
if (existingUser.getProfilePicture() != null && !existingUser.getProfilePicture().isEmpty()) {
Path oldFilePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", existingUser.getProfilePicture());
Files.deleteIfExists(oldFilePath);
}
// Normalisiert als JPEG speichern
String fileName = UUID.randomUUID() + ".jpg";
Path uploadPath = Paths.get(UPLOAD_DIR);
Files.createDirectories(uploadPath);
ImageIO.write(scaled, "jpg", uploadPath.resolve(fileName).toFile());
existingUser.setProfilePicture(fileName);
}
} catch (IOException e) {
log.error("Fehler beim Speichern des Profilbilds", e);
}
}
userService.save(existingUser);
}
return "redirect:/profile?success=true";
}
@PostMapping("/profile/change-password")
public String changePassword(@RequestParam("oldPassword") String oldPassword,
@RequestParam("newPassword") String newPassword,
Principal principal,
RedirectAttributes redirectAttributes) {
String username = principal.getName();
User user = userService.findByUsername(username);
if (user == null) {
redirectAttributes.addFlashAttribute("error", "Benutzer nicht gefunden.");
return "redirect:/profile/settings";
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
redirectAttributes.addFlashAttribute("error", "Das aktuelle Passwort ist falsch.");
return "redirect:/profile/settings";
}
user.setPassword(passwordEncoder.encode(newPassword));
userService.save(user);
redirectAttributes.addFlashAttribute("message", "Passwort erfolgreich geändert.");
return "redirect:/profile/settings";
}
@PostMapping("/profile/delete")
public String deleteProfile(Principal principal, Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
String username = principal.getName();
// Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion).
User user = userService.findByUsername(username);
if (user != null) {
String profilePicture = user.getProfilePicture();
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
Files.deleteIfExists(picturePath);
} catch (IOException e) {
log.error("Fehler beim Löschen des Profilbilds", e);
}
}
}
userService.deleteByUsername(username);
new SecurityContextLogoutHandler().logout(request, response, authentication);
return "redirect:/?logout";
}
}
@@ -0,0 +1,47 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameProgress;
import com.example.mindflux.dto.ProgressData;
import com.example.mindflux.service.ProgressService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class ProgressController {
@Autowired
private ProgressService progressService;
@GetMapping("/progress") // <-- hier geändert!
public String showProgress(Model model, Principal principal) {
String username = principal.getName(); // aktueller eingeloggter Benutzer
ProgressData progress = progressService.getOverallProgress(username);
List<GameProgress> games = progressService.getGameProgress(username);
if (progress == null) {
// Falls keine Fortschrittsdaten vorhanden sind, Dummy-Daten setzen
model.addAttribute("correctAnswers", 0);
model.addAttribute("currentXp", 0);
model.addAttribute("xpForNextLevel", 1000);
model.addAttribute("userLevel", 1);
model.addAttribute("xpPercentage", 0);
} else {
model.addAttribute("correctAnswers", progress.getCorrectAnswers());
model.addAttribute("currentXp", progress.getCurrentXp());
model.addAttribute("xpForNextLevel", progress.getXpForNextLevel());
model.addAttribute("userLevel", progress.getUserLevel());
model.addAttribute("xpPercentage", progress.getProgressPercent());
}
model.addAttribute("gameProgress", games != null ? games : List.of()); // leere Liste, falls null
return "progress"; // ruft progress.html auf
}
}
@@ -0,0 +1,39 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.RegistrationResult;
import com.example.mindflux.service.UserService;
import org.springframework.stereotype.Controller;
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 {
private final UserService userService;
public RegisterController(UserService userService) {
this.userService = userService;
}
@GetMapping("/register")
public String showRegistrationForm() {
// Die eigenständige Registrierungsseite wurde entfernt – das Formular lebt auf der Startseite.
return "redirect:/";
}
@PostMapping("/register")
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,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SozialesController {
@GetMapping("/soziales")
public String soziales() {
return "soziales"; // -> templates/soziales.html
}
}
@@ -0,0 +1,41 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.SyntaxSagaCheckRequest;
import com.example.mindflux.dto.SyntaxSagaCheckResult;
import com.example.mindflux.dto.SyntaxSagaFinishRequest;
import com.example.mindflux.dto.SyntaxSagaResult;
import com.example.mindflux.dto.SyntaxSagaStoryView;
import com.example.mindflux.service.SyntaxSagaService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
/** JSON-API für Syntax Saga. Server-autoritativ: Lösungen werden nie ausgeliefert. */
@RestController
@RequestMapping("/api/syntaxsaga")
public class SyntaxSagaApiController {
private final SyntaxSagaService syntaxSagaService;
public SyntaxSagaApiController(SyntaxSagaService syntaxSagaService) {
this.syntaxSagaService = syntaxSagaService;
}
/** Missionen einer Stufe (oder stufenübergreifend) – ohne Lösungen. */
@GetMapping("/story")
public SyntaxSagaStoryView story(@RequestParam(required = false) String difficulty) {
return syntaxSagaService.buildStory(difficulty);
}
/** Eine Mission serverseitig prüfen; mit {@code reveal=true} auch die Auflösung. */
@PostMapping("/check")
public SyntaxSagaCheckResult check(@RequestBody SyntaxSagaCheckRequest request) {
return syntaxSagaService.check(request);
}
/** Durchlauf abschließen: Score wird serverseitig neu berechnet und gespeichert. */
@PostMapping("/finish")
public SyntaxSagaResult finish(@RequestBody SyntaxSagaFinishRequest request, Principal principal) {
return syntaxSagaService.finish(principal.getName(), request);
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SyntaxSagaController {
@GetMapping("/syntaxsaga")
public String show() {
return "syntaxsaga"; // Lädt templates/syntaxsaga.html
}
}
@@ -0,0 +1,51 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.*;
import com.example.mindflux.service.TechtacularService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.HashSet;
import java.util.Set;
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
@RestController
@RequestMapping("/api/techtacular")
public class TechtacularApiController {
private final TechtacularService techtacularService;
public TechtacularApiController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
@GetMapping("/board")
public TechtacularBoardView board(@RequestParam(required = false) String exclude) {
return techtacularService.buildBoard(parseIds(exclude));
}
private Set<Long> parseIds(String csv) {
Set<Long> ids = new HashSet<>();
if (csv != null && !csv.isBlank()) {
for (String part : csv.split(",")) {
try {
ids.add(Long.parseLong(part.trim()));
} catch (NumberFormatException ignored) {
// ungültige IDs einfach überspringen
}
}
}
return ids;
}
@PostMapping("/answer")
public AnswerResult answer(@RequestBody AnswerRequest request) {
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId());
}
@PostMapping("/finish")
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) {
return techtacularService.finish(principal.getName(), request);
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TechtacularController {
@GetMapping("/techtacular")
public String showTechtacular() {
return "techtacular"; // lädt templates/techtacular.html
}
}
@@ -0,0 +1,72 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
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;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
public UserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// 📥 User erstellen (Validierung, Eindeutigkeit und Encoding zentral im UserService)
@PostMapping
public User createUser(@RequestBody User user) {
return userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
// 📋 Alle User anzeigen
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
// 📍 Einzelnen User anzeigen
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
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 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(userService.validateRole(updatedUser.getRole()));
userService.save(user);
return ResponseEntity.ok(user);
}).orElseGet(() -> ResponseEntity.notFound().build());
}
// 🗑️ User löschen (inkl. zugehöriger Scores/Achievements)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userService.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
userService.deleteById(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,24 @@
package com.example.mindflux.dto;
/** Eine Zeile in der TechTacular-Kategorienübersicht (ohne alle Fragen zu laden). */
public class AdminCategoryRow {
private final Long id;
private final String slug;
private final String title;
private final boolean active;
private final long questionCount;
public AdminCategoryRow(Long id, String slug, String title, boolean active, long questionCount) {
this.id = id;
this.slug = slug;
this.title = title;
this.active = active;
this.questionCount = questionCount;
}
public Long getId() { return id; }
public String getSlug() { return slug; }
public String getTitle() { return title; }
public boolean isActive() { return active; }
public long getQuestionCount() { return questionCount; }
}
@@ -0,0 +1,13 @@
package com.example.mindflux.dto;
/** Eine vom Spieler gewählte Antwort. */
public class AnswerRequest {
private Long questionId;
private Long answerId;
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long questionId) { this.questionId = questionId; }
public Long getAnswerId() { return answerId; }
public void setAnswerId(Long answerId) { this.answerId = answerId; }
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Serverseitige Bewertung einer einzelnen Antwort (instant feedback). */
public class AnswerResult {
private final boolean correct;
private final Long correctAnswerId;
private final String explanation;
private final int points;
public AnswerResult(boolean correct, Long correctAnswerId, String explanation, int points) {
this.correct = correct;
this.correctAnswerId = correctAnswerId;
this.explanation = explanation;
this.points = points;
}
public boolean isCorrect() { return correct; }
public Long getCorrectAnswerId() { return correctAnswerId; }
public String getExplanation() { return explanation; }
public int getPoints() { return points; }
}
@@ -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; }
}
@@ -0,0 +1 @@
package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; public class EscapeCheckRequest {private Long puzzleId;private boolean reveal;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public boolean isReveal(){return reveal;}public void setReveal(boolean v){reveal=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}}
@@ -0,0 +1 @@
package com.example.mindflux.dto; public class EscapeDoorRequest {private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}}
@@ -0,0 +1 @@
package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; import java.util.*; public class EscapeFinishRequest {private String slug;private int durationSeconds;private boolean escaped;private List<Item> items=new ArrayList<>();private List<Door> doors=new ArrayList<>();public String getSlug(){return slug;}public void setSlug(String v){slug=v;}public int getDurationSeconds(){return durationSeconds;}public void setDurationSeconds(int v){durationSeconds=v;}public boolean isEscaped(){return escaped;}public void setEscaped(boolean v){escaped=v;}public List<Item> getItems(){return items;}public void setItems(List<Item> v){items=v;}public List<Door> getDoors(){return doors;}public void setDoors(List<Door> v){doors=v;}public static class Item{private Long puzzleId;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}}public static class Door{private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}}}
@@ -0,0 +1 @@
package com.example.mindflux.dto; public class EscapeHintRequest {private Long puzzleId;private int level;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public int getLevel(){return level;}public void setLevel(int v){level=v;}}
@@ -0,0 +1 @@
package com.example.mindflux.dto; public record EscapeResult(int score,int baseScore,int maxScore,int timeBonus,int solvedPuzzles,int roomsOpened,boolean escaped,int stars,int highscore,boolean newHighscore,long plays){}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
import java.util.List;
/** Abschluss einer TechTacular-Runde: alle gewählten Antworten + Dauer. Score wird serverseitig recomputed. */
public class FinishRequest {
private int durationSeconds;
private List<AnswerRequest> answers;
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
public List<AnswerRequest> getAnswers() { return answers; }
public void setAnswers(List<AnswerRequest> answers) { this.answers = answers; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
public class GameProgress {
private String name;
private int progressPercent;
private int highscore;
private int plays;
private LocalDate lastPlayed;
// Getter
public String getName() { return name; }
public int getProgressPercent() { return progressPercent; }
public int getHighscore() { return highscore; }
public int getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
// Setter
public void setName(String name) { this.name = name; }
public void setProgressPercent(int progressPercent) { this.progressPercent = progressPercent; }
public void setHighscore(int highscore) { this.highscore = highscore; }
public void setPlays(int plays) { this.plays = plays; }
public void setLastPlayed(LocalDate lastPlayed) { this.lastPlayed = lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
/** Zusammengefasste Statistik eines Spielers für ein Spiel. */
public class GameStats {
private final int highscore;
private final long plays;
private final LocalDate lastPlayed;
public GameStats(int highscore, long plays, LocalDate lastPlayed) {
this.highscore = highscore;
this.plays = plays;
this.lastPlayed = lastPlayed;
}
public int getHighscore() { return highscore; }
public long getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.util.List;
/** Ein aktives Deck mit seinen Paaren fürs Spiel-API. */
public class MindMatchDeckView {
private final String slug;
private final String title;
private final List<MindMatchPairView> pairs;
public MindMatchDeckView(String slug, String title, List<MindMatchPairView> pairs) {
this.slug = slug;
this.title = title;
this.pairs = pairs;
}
public String getSlug() { return slug; }
public String getTitle() { return title; }
public List<MindMatchPairView> getPairs() { return pairs; }
}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
/** Ein Paar fürs Spiel-API. Feld heißt "match" (passend zur bisherigen JS-Logik). */
public class MindMatchPairView {
private final String term;
private final String match;
public MindMatchPairView(String term, String match) {
this.term = term;
this.match = match;
}
public String getTerm() { return term; }
public String getMatch() { return match; }
}
@@ -0,0 +1,58 @@
package com.example.mindflux.dto;
public class ProgressData {
private int correctAnswers;
private int currentXp;
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;
}
public void setCorrectAnswers(int correctAnswers) {
this.correctAnswers = correctAnswers;
}
public int getCurrentXp() {
return currentXp;
}
public void setCurrentXp(int currentXp) {
this.currentXp = currentXp;
}
public int getXpForNextLevel() {
return xpForNextLevel;
}
public void setXpForNextLevel(int xpForNextLevel) {
this.xpForNextLevel = xpForNextLevel;
}
public int getUserLevel() {
return userLevel;
}
public void setUserLevel(int userLevel) {
this.userLevel = userLevel;
}
public int getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(int progressPercent) {
this.progressPercent = progressPercent;
}
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Antwort nach dem Speichern eines Ergebnisses. */
public class ScoreResult {
private final int score;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public ScoreResult(int score, int highscore, boolean newHighscore, long plays) {
this.score = score;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
/**
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
*/
public class ScoreSubmission {
private String deck;
private int pairs;
private int moves;
private int durationSeconds;
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getPairs() { return pairs; }
public void setPairs(int pairs) { this.pairs = pairs; }
public int getMoves() { return moves; }
public void setMoves(int moves) { this.moves = moves; }
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
}
@@ -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,66 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Das Jeopardy-Board fürs Frontend: Spalten (Kategorien) mit Kacheln (Fragen).
* Bewusst OHNE Information darüber, welche Antwort korrekt ist – geprüft wird serverseitig.
*/
public class TechtacularBoardView {
private final List<Category> categories;
private final int maxScore;
public TechtacularBoardView(List<Category> categories, int maxScore) {
this.categories = categories;
this.maxScore = maxScore;
}
public List<Category> getCategories() { return categories; }
public int getMaxScore() { return maxScore; }
public static class Category {
private final String title;
private final List<Tile> tiles;
public Category(String title, List<Tile> tiles) {
this.title = title;
this.tiles = tiles;
}
public String getTitle() { return title; }
public List<Tile> getTiles() { return tiles; }
}
public static class Tile {
private final Long questionId;
private final int points;
private final String text;
private final List<Option> answers;
public Tile(Long questionId, int points, String text, List<Option> answers) {
this.questionId = questionId;
this.points = points;
this.text = text;
this.answers = answers;
}
public Long getQuestionId() { return questionId; }
public int getPoints() { return points; }
public String getText() { return text; }
public List<Option> getAnswers() { return answers; }
}
public static class Option {
private final Long id;
private final String text;
public Option(Long id, String text) {
this.id = id;
this.text = text;
}
public Long getId() { return id; }
public String getText() { return text; }
}
}
@@ -0,0 +1,31 @@
package com.example.mindflux.dto;
/** Ergebnis einer abgeschlossenen TechTacular-Runde. */
public class TechtacularResult {
private final int score;
private final int maxScore;
private final int correctCount;
private final int totalCount;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public TechtacularResult(int score, int maxScore, int correctCount, int totalCount,
int highscore, boolean newHighscore, long plays) {
this.score = score;
this.maxScore = maxScore;
this.correctCount = correctCount;
this.totalCount = totalCount;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getMaxScore() { return maxScore; }
public int getCorrectCount() { return correctCount; }
public int getTotalCount() { return totalCount; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,51 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "achievements")
public class Achievement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String title;
private String description;
private String icon; // Dateiname des Icons (z.B. "star.png")
private LocalDateTime dateAchieved;
// 🔥 Neuer Konstruktor für schnelle Dummy-Erstellung (ohne username/dateAchieved)
public Achievement(String title, String description, String icon, boolean unlocked) {
this.title = title;
this.description = description;
this.icon = icon;
this.dateAchieved = unlocked ? LocalDateTime.now() : null;
}
// ⚡ Standard-Konstruktor (wichtig für JPA!)
public Achievement() {
}
// Getter und Setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public LocalDateTime getDateAchieved() { return dateAchieved; }
public void setDateAchieved(LocalDateTime dateAchieved) { this.dateAchieved = dateAchieved; }
}
@@ -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,7 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
@Entity @Table(name="escape_puzzles") public class EscapePuzzle {
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="room_id") private EscapeRoom room;
@Column(name="puzzle_type") private String type; private String prompt; private int points=100; @Column(name="code_fragment") private String codeFragment; @Column(name="sort_order") private int sortOrder; private String payload;
public EscapePuzzle(){} public EscapePuzzle(String t,String p,int pts,String f,int o,String j){type=t;prompt=p;points=pts;codeFragment=f;sortOrder=o;payload=j;} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeRoom getRoom(){return room;} public void setRoom(EscapeRoom v){room=v;} public String getType(){return type;} public void setType(String v){type=v;} public String getPrompt(){return prompt;} public void setPrompt(String v){prompt=v;} public int getPoints(){return points;} public void setPoints(int v){points=v;} public String getCodeFragment(){return codeFragment;} public void setCodeFragment(String v){codeFragment=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public String getPayload(){return payload;} public void setPayload(String v){payload=v;}
}
@@ -0,0 +1,9 @@
package com.example.mindflux.model;
import jakarta.persistence.*; import java.util.*;
@Entity @Table(name="escape_rooms")
public class EscapeRoom {
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="scenario_id") private EscapeScenario scenario;
private String title; private String intro; @Column(name="door_prompt") private String doorPrompt; @Column(name="door_code") private String doorCode; @Column(name="success_text") private String successText; @Column(name="sort_order") private int sortOrder;
@OneToMany(mappedBy="room",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List<EscapePuzzle> puzzles=new ArrayList<>();
public EscapeRoom(){} public EscapeRoom(String t,String i,String p,String c,String s,int o){title=t;intro=i;doorPrompt=p;doorCode=c;successText=s;sortOrder=o;} public void addPuzzle(EscapePuzzle p){p.setRoom(this);puzzles.add(p);} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeScenario getScenario(){return scenario;} public void setScenario(EscapeScenario v){scenario=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getIntro(){return intro;} public void setIntro(String v){intro=v;} public String getDoorPrompt(){return doorPrompt;} public void setDoorPrompt(String v){doorPrompt=v;} public String getDoorCode(){return doorCode;} public void setDoorCode(String v){doorCode=v;} public String getSuccessText(){return successText;} public void setSuccessText(String v){successText=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public List<EscapePuzzle> getPuzzles(){return puzzles;}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.model;
import jakarta.persistence.*; import java.util.*;
@Entity @Table(name="escape_scenarios")
public class EscapeScenario {
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id;
@Column(unique=true,nullable=false) private String slug; private String title; private String difficulty;
@Column(name="exam_scope") private String examScope="AP1"; private String description;
@Column(name="time_limit_seconds") private int timeLimitSeconds=900;
@Column(name="sort_order") private int sortOrder; private boolean active=true;
@OneToMany(mappedBy="scenario",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List<EscapeRoom> rooms=new ArrayList<>();
public EscapeScenario(){} public EscapeScenario(String slug,String title,String difficulty,String examScope,String description,int limit,int order){this.slug=slug;this.title=title;this.difficulty=difficulty;this.examScope=examScope;this.description=description;this.timeLimitSeconds=limit;this.sortOrder=order;}
public void addRoom(EscapeRoom r){r.setScenario(this);rooms.add(r);} public Long getId(){return id;} public void setId(Long v){id=v;} public String getSlug(){return slug;} public void setSlug(String v){slug=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getDifficulty(){return difficulty;} public void setDifficulty(String v){difficulty=v;} public String getExamScope(){return examScope;} public void setExamScope(String v){examScope=v;} public String getDescription(){return description;} public void setDescription(String v){description=v;} public int getTimeLimitSeconds(){return timeLimitSeconds;} public void setTimeLimitSeconds(int v){timeLimitSeconds=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public boolean isActive(){return active;} public void setActive(boolean v){active=v;} public List<EscapeRoom> getRooms(){return rooms;}
}
@@ -0,0 +1,75 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* Ein einzelnes Spielergebnis. Bewusst generisch gehalten, damit alle
* Mindflux-Spiele (MindMatch, TechTacular, ...) dieselbe Tabelle nutzen können.
* Welches Spiel das Ergebnis erzeugt hat, steht in {@link #game}.
*/
@Entity
@Table(name = "game_scores")
public class GameScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
/** Spiel-Kennung, z.B. "mindmatch" (entspricht der Route im Dashboard). */
private String game;
/** Optionales Deck / Level innerhalb des Spiels, z.B. "netzwerk". */
private String deck;
private int score;
/** Anzahl der Zug-Versuche (kann je nach Spiel null sein). */
private Integer moves;
@Column(name = "duration_seconds")
private Integer durationSeconds;
@Column(name = "played_at")
private LocalDateTime playedAt;
public GameScore() {
}
public GameScore(String username, String game, String deck, int score,
Integer moves, Integer durationSeconds, LocalDateTime playedAt) {
this.username = username;
this.game = game;
this.deck = deck;
this.score = score;
this.moves = moves;
this.durationSeconds = durationSeconds;
this.playedAt = playedAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getGame() { return game; }
public void setGame(String game) { this.game = game; }
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public Integer getMoves() { return moves; }
public void setMoves(Integer moves) { this.moves = moves; }
public Integer getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(Integer durationSeconds) { this.durationSeconds = durationSeconds; }
public LocalDateTime getPlayedAt() { return playedAt; }
public void setPlayedAt(LocalDateTime playedAt) { this.playedAt = playedAt; }
}
@@ -0,0 +1,63 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Ein MindMatch-Deck (Kategorie) mit seinen Begriffspaaren. Von Admins pflegbar. */
@Entity
@Table(name = "mindmatch_decks")
public class MindMatchDeck {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "deck", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id ASC")
private List<MindMatchPair> pairs = new ArrayList<>();
public MindMatchDeck() {
}
public MindMatchDeck(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
/** Fügt ein Paar hinzu und hält die Rückreferenz konsistent. */
public void addPair(MindMatchPair pair) {
pair.setDeck(this);
this.pairs.add(pair);
}
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 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<MindMatchPair> getPairs() { return pairs; }
public void setPairs(List<MindMatchPair> pairs) { this.pairs = pairs; }
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Ein Begriffspaar innerhalb eines {@link MindMatchDeck}: Begriff + zugehörige Bedeutung. */
@Entity
@Table(name = "mindmatch_pairs")
public class MindMatchPair {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deck_id")
private MindMatchDeck deck;
private String term;
/** Spalte heißt "match_text", da MATCH in SQL ein reserviertes Wort ist. */
@Column(name = "match_text")
private String matchText;
public MindMatchPair() {
}
public MindMatchPair(String term, String matchText) {
this.term = term;
this.matchText = matchText;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public MindMatchDeck getDeck() { return deck; }
public void setDeck(MindMatchDeck deck) { this.deck = deck; }
public String getTerm() { return term; }
public void setTerm(String term) { this.term = term; }
public String getMatchText() { return matchText; }
public void setMatchText(String matchText) { this.matchText = matchText; }
}
@@ -0,0 +1,112 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/**
* Eine Lücke in einer {@link SyntaxSagaMission}. Die {@link #position} entspricht der
* Markernummer {@code {{n}}} im Codetemplate. Eine Lücke kann mehrere gültige Lösungen
* haben (z.B. {@code 192.168.0.1} oder {@code localhost}). Die Prüfung passiert
* serverseitig über {@link #matches(String)} – das Frontend kennt die Lösung nie.
*/
@Entity
@Table(name = "syntax_saga_gaps")
public class SyntaxSagaGap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mission_id")
private SyntaxSagaMission mission;
/** 1-basierte Position, = Markernummer {@code {{n}}} im Codetemplate. */
@Column(name = "gap_position")
private int position;
private int points = 100;
/** Groß-/Kleinschreibung relevant? (true für Java-Bezeichner, false z.B. für SQL-Keywords). */
@Column(name = "case_sensitive")
private boolean caseSensitive = true;
private String hint;
/** Kurzlabel/Platzhalter im Eingabefeld, z.B. "Methodenname". */
private String placeholder;
@OneToMany(mappedBy = "gap", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC, id ASC")
private List<SyntaxSagaSolution> solutions = new ArrayList<>();
public SyntaxSagaGap() {
}
public SyntaxSagaGap(int position, int points, boolean caseSensitive, String hint, String placeholder) {
this.position = position;
this.points = points;
this.caseSensitive = caseSensitive;
this.hint = hint;
this.placeholder = placeholder;
}
public void addSolution(SyntaxSagaSolution solution) {
solution.setGap(this);
this.solutions.add(solution);
}
/** Normalisiert (trim + interne Whitespace zu einem Space). */
private static String normalize(String value) {
return value == null ? "" : value.trim().replaceAll("\\s+", " ");
}
/** Prüft eine Eingabe serverseitig gegen alle gültigen Lösungen. */
public boolean matches(String typed) {
if (typed == null) {
return false;
}
String t = normalize(typed);
if (t.isEmpty()) {
return false;
}
for (SyntaxSagaSolution solution : solutions) {
String sol = normalize(solution.getText());
boolean equal = caseSensitive ? t.equals(sol) : t.equalsIgnoreCase(sol);
if (equal) {
return true;
}
}
return false;
}
/** Kanonische Lösung (für die "Auflösung anzeigen"-Funktion). */
public String firstSolution() {
return solutions.isEmpty() ? "" : solutions.get(0).getText();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public SyntaxSagaMission getMission() { return mission; }
public void setMission(SyntaxSagaMission mission) { this.mission = mission; }
public int getPosition() { return position; }
public void setPosition(int position) { this.position = position; }
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public boolean isCaseSensitive() { return caseSensitive; }
public void setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; }
public String getHint() { return hint; }
public void setHint(String hint) { this.hint = hint; }
public String getPlaceholder() { return placeholder; }
public void setPlaceholder(String placeholder) { this.placeholder = placeholder; }
public List<SyntaxSagaSolution> getSolutions() { return solutions; }
public void setSolutions(List<SyntaxSagaSolution> solutions) { this.solutions = solutions; }
}
@@ -0,0 +1,114 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/**
* Eine Syntax-Saga-Mission (ein Kapitel): Story + optionaler Dialog + Codeblock mit
* Lücken. Im {@code codeTemplate} markieren {@code {{1}}}, {@code {{2}}} … die Lücken
* (Nummer = {@link SyntaxSagaGap#getPosition() position}). Gespielt wird je
* Schwierigkeitsgrad als eigene lineare Saga.
*/
@Entity
@Table(name = "syntax_saga_missions")
public class SyntaxSagaMission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
/** "junior" | "professional" | "senior". */
private String difficulty;
/** Erzähltext / Szene. */
private String narrative;
/** Optionales Zitat ("Bitte… repariere mich…"). */
private String dialog;
/** Sprache des Codes für Badge/Darstellung, z.B. "java", "sql", "bash". */
@Column(name = "code_language")
private String language;
/** Code mit {@code {{n}}}-Markern an den Lücken. */
@Column(name = "code_template")
private String codeTemplate;
/** Fortsetzung nach dem Lösen ("Das Terminal erwacht…"). */
@Column(name = "success_text")
private String successText;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "mission", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC, id ASC")
private List<SyntaxSagaGap> gaps = new ArrayList<>();
public SyntaxSagaMission() {
}
public SyntaxSagaMission(String slug, String title, String difficulty, String narrative,
String dialog, String language, String codeTemplate,
String successText, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.difficulty = difficulty;
this.narrative = narrative;
this.dialog = dialog;
this.language = language;
this.codeTemplate = codeTemplate;
this.successText = successText;
this.sortOrder = sortOrder;
this.active = active;
}
public void addGap(SyntaxSagaGap gap) {
gap.setMission(this);
this.gaps.add(gap);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDifficulty() { return difficulty; }
public void setDifficulty(String difficulty) { this.difficulty = difficulty; }
public String getNarrative() { return narrative; }
public void setNarrative(String narrative) { this.narrative = narrative; }
public String getDialog() { return dialog; }
public void setDialog(String dialog) { this.dialog = dialog; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getCodeTemplate() { return codeTemplate; }
public void setCodeTemplate(String codeTemplate) { this.codeTemplate = codeTemplate; }
public String getSuccessText() { return successText; }
public void setSuccessText(String successText) { this.successText = successText; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<SyntaxSagaGap> getGaps() { return gaps; }
public void setGaps(List<SyntaxSagaGap> gaps) { this.gaps = gaps; }
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Eine gültige Antwort zu einer {@link SyntaxSagaGap} (eine Lücke kann mehrere haben). */
@Entity
@Table(name = "syntax_saga_solutions")
public class SyntaxSagaSolution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "gap_id")
private SyntaxSagaGap gap;
@Column(name = "solution_text")
private String text;
@Column(name = "sort_order")
private int sortOrder;
public SyntaxSagaSolution() {
}
public SyntaxSagaSolution(String text, int sortOrder) {
this.text = text;
this.sortOrder = sortOrder;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public SyntaxSagaGap getGap() { return gap; }
public void setGap(SyntaxSagaGap gap) { this.gap = gap; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
}
@@ -0,0 +1,49 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Eine Antwortmöglichkeit zu einer {@link TechtacularQuestion}. */
@Entity
@Table(name = "techtacular_answers")
public class TechtacularAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
private TechtacularQuestion question;
@Column(name = "answer_text")
private String text;
private boolean correct;
@Column(name = "sort_order")
private int sortOrder;
public TechtacularAnswer() {
}
public TechtacularAnswer(String text, boolean correct, int sortOrder) {
this.text = text;
this.correct = correct;
this.sortOrder = sortOrder;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularQuestion getQuestion() { return question; }
public void setQuestion(TechtacularQuestion question) { this.question = question; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public boolean isCorrect() { return correct; }
public void setCorrect(boolean correct) { this.correct = correct; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
}
@@ -0,0 +1,62 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine TechTacular-Kategorie (Spalte im Jeopardy-Board) mit ihren Fragen. */
@Entity
@Table(name = "techtacular_categories")
public class TechtacularCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("points ASC, id ASC")
private List<TechtacularQuestion> questions = new ArrayList<>();
public TechtacularCategory() {
}
public TechtacularCategory(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
public void addQuestion(TechtacularQuestion question) {
question.setCategory(this);
this.questions.add(question);
}
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 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<TechtacularQuestion> getQuestions() { return questions; }
public void setQuestions(List<TechtacularQuestion> questions) { this.questions = questions; }
}
@@ -0,0 +1,67 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine Frage mit ihren Antwortmöglichkeiten (genau eine ist korrekt) und Punktwert. */
@Entity
@Table(name = "techtacular_questions")
public class TechtacularQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private TechtacularCategory category;
@Column(name = "question_text")
private String text;
private String explanation;
private int points;
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC, id ASC")
private List<TechtacularAnswer> answers = new ArrayList<>();
public TechtacularQuestion() {
}
public TechtacularQuestion(String text, String explanation, int points) {
this.text = text;
this.explanation = explanation;
this.points = points;
}
public void addAnswer(TechtacularAnswer answer) {
answer.setQuestion(this);
this.answers.add(answer);
}
/** Liefert die als korrekt markierte Antwort (oder null). */
public TechtacularAnswer correctAnswer() {
return answers.stream().filter(TechtacularAnswer::isCorrect).findFirst().orElse(null);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularCategory getCategory() { return category; }
public void setCategory(TechtacularCategory category) { this.category = category; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getExplanation() { return explanation; }
public void setExplanation(String explanation) { this.explanation = explanation; }
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public List<TechtacularAnswer> getAnswers() { return answers; }
public void setAnswers(List<TechtacularAnswer> answers) { this.answers = answers; }
}
@@ -0,0 +1,54 @@
package com.example.mindflux.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
private String email;
private String role;
@Column(name = "profile_picture")
private String profilePicture;
public User() {}
public User(String username, String password, String email, String role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
// —————————————
// Getter / Setter
// —————————————
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String u) { this.username = u; }
public String getPassword() { return password; }
public void setPassword(String p) { this.password = p; }
public String getEmail() { return email; }
public void setEmail(String e) { this.email = e; }
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
}
@@ -0,0 +1,13 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.Achievement;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AchievementRepository extends JpaRepository<Achievement, Long> {
List<Achievement> findByUsername(String username);
/** Alle Achievements eines Spielers löschen (z. B. beim Löschen des Kontos). */
void deleteByUsername(String username);
}
@@ -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);
}
@@ -0,0 +1 @@
package com.example.mindflux.repository; import com.example.mindflux.model.EscapePuzzle; import org.springframework.data.jpa.repository.JpaRepository; public interface EscapePuzzleRepository extends JpaRepository<EscapePuzzle,Long>{long countByRoomId(Long id);}

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