Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc6c8f75c | |||
| 9ebdb7ed3e | |||
| b1671aa29a | |||
| 1bfbd53062 | |||
| 983e57cd8d | |||
| f8f068150e | |||
| 501ecc0c1e | |||
| 3a1bd759c7 | |||
| 0b3286af4c | |||
| e853ca8e93 | |||
| e52cada445 | |||
| c5009d1718 | |||
| 860b1ffba9 | |||
| bb1773e32e | |||
| 2b86466a7e | |||
| 2612d4eaec | |||
| 56e455c45d | |||
| ce64427008 | |||
| 742f19db8c | |||
| 664e3adaeb | |||
| 4a07ac8dbf | |||
| 565cf1e23c | |||
| 5abaa6d933 | |||
| dbe95809c2 | |||
| e33569f1ae | |||
| c31438806c | |||
| e875c9a7d3 | |||
| 305e3aa4b6 | |||
| 7f47261c78 | |||
| f3ac6ed252 | |||
| 5fcbdfb8c8 | |||
| ce1975b47a | |||
| 80f556b1d0 | |||
| 3f23f223ce | |||
| ad94c232f6 | |||
| ac5e6e6496 | |||
| 42d0342981 | |||
| f2ea8936c3 | |||
| f9e6516124 | |||
| fb487be037 | |||
| 85e8647cdd | |||
| 49d588b609 | |||
| a484416ea0 | |||
| d5d4add293 | |||
| 7a97e6f24e | |||
| bd76cd11de | |||
| 56cd17520e | |||
| f9ee3092a3 | |||
| 2e01583d8f | |||
| 95df002e24 | |||
| f90f21589d | |||
| 3f32fcd6c1 | |||
| b8f830dce0 | |||
| 27c409bfb3 | |||
| 010a294151 | |||
| c665857ce8 | |||
| 07f6693954 | |||
| d718a09561 | |||
| b0d8dd48db | |||
| 73e74a2859 | |||
| 3eef7cdb40 | |||
| 6d243c8987 | |||
| 6bde27a241 | |||
| 0f08c93a67 | |||
| abc9e0e96d | |||
| 64234ad84c | |||
| 48dfde17eb | |||
| dbd1960424 |
@@ -25,6 +25,7 @@ jobs:
|
|||||||
MINDFLUX_DB_PASSWORD: ${{ secrets.MINDFLUX_DB_PASSWORD }}
|
MINDFLUX_DB_PASSWORD: ${{ secrets.MINDFLUX_DB_PASSWORD }}
|
||||||
MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }}
|
MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }}
|
||||||
MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }}
|
MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
test -n "$MINDFLUX_DB_PASSWORD" || { echo "MINDFLUX_DB_PASSWORD fehlt" >&2; exit 1; }
|
test -n "$MINDFLUX_DB_PASSWORD" || { echo "MINDFLUX_DB_PASSWORD fehlt" >&2; exit 1; }
|
||||||
docker exec -i -e NEW_DB_PASSWORD="$MINDFLUX_DB_PASSWORD" postgres \
|
docker exec -i -e NEW_DB_PASSWORD="$MINDFLUX_DB_PASSWORD" postgres \
|
||||||
@@ -33,4 +34,7 @@ jobs:
|
|||||||
ALTER USER mindflux WITH PASSWORD :'new_db_password';
|
ALTER USER mindflux WITH PASSWORD :'new_db_password';
|
||||||
SQL
|
SQL
|
||||||
docker compose -f docker-compose.yml pull
|
docker compose -f docker-compose.yml pull
|
||||||
docker compose -f docker-compose.yml up -d
|
docker compose -f docker-compose.yml up -d --wait --wait-timeout 90 || {
|
||||||
|
docker logs --tail 200 mindflux >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|||||||
+281
-1
@@ -1,9 +1,289 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Alle nennenswerten Änderungen an **Mindflux** werden hier dokumentiert.
|
Alle nennenswerten Änderungen an **SysnaptiK Neural** werden hier dokumentiert.
|
||||||
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
|
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
|
||||||
Versionierung [semantisch](https://semver.org/lang/de/).
|
Versionierung [semantisch](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unreleased] – 2026-07-21 · Gesamt-Audit: Light Mode, Barrierefreiheit, AP2-Inhalte, DSGVO
|
||||||
|
|
||||||
|
### Light Mode & Design
|
||||||
|
- **Light-Mode-Fundament:** zentrale UI-Effekte sind jetzt als Theme-Primitives
|
||||||
|
organisiert. Dark Mode behält Neon-Emissionen; Light Mode nutzt warme Elevation,
|
||||||
|
entfernt Text-/Icon-Halos und erhält einen sichtbaren Scrollbar-Thumb. Native
|
||||||
|
Controls folgen über `color-scheme` dem gewählten Theme.
|
||||||
|
- **Theme-Start & FinalProtocol:** Erstbesucher folgen der OS-Farbpräferenz, die
|
||||||
|
Browser-Chrome erhält ein passendes `theme-color` und FinalProtocol lädt keinen
|
||||||
|
ungenutzten Legacy-Style mehr; die statischen Lernseiten erhalten daraus nur noch
|
||||||
|
eine minimale Reset-Basis.
|
||||||
|
- **Kritische Light-Mode-Fälle behoben:** Login-Overlay, SyntaxSaga-Bühne,
|
||||||
|
CodeEscape-Terminal, NetWorker-Guide und die Farbenblind-Palette sind in heller
|
||||||
|
Darstellung wieder lesbar und kontraststark. A11y-Widget und FinalProtocol
|
||||||
|
verwenden ebenfalls themefähige Farben.
|
||||||
|
- **Ansichtswechsel abgesichert:** zentrale SPA-Übergänge starten oben, ohne laufendes
|
||||||
|
Antwortfeedback zu versetzen. FinalProtocol scrollt Ergebnisse und Neustarts gezielt
|
||||||
|
ins Sichtfeld; SyntaxSaga und DevLab fokussieren ohne Scrollsprung.
|
||||||
|
- **Echter Light Mode statt Invertierung:** Light-Tokens auf WCAG AA nachgeschärft
|
||||||
|
(Akzent, Muted, Sekundär, Spielakzente), neues `--warn`-Token. Die Spielkarten der
|
||||||
|
Plattform-Hülle erhalten im Light Mode farbton-treu abgedunkelte Akzente statt
|
||||||
|
durchschlagender Neon-Werte.
|
||||||
|
- **ServiceClash, NetWorker und BugHunter** haben jetzt eine echte helle Variante
|
||||||
|
(zuvor dauerhaft dunkel): alle Farben laufen über scoped CSS-Custom-Properties auf
|
||||||
|
der Body-Klasse, Dark bleibt pixelgleich, die Light-Paletten sind handentworfen und
|
||||||
|
vollständig WCAG-AA-geprüft; der Theme-Umschalter wurde im Header ergänzt. Der bei
|
||||||
|
NetWorker fälschlich globale `var(--text)`/`var(--muted)`-Zugriff ist behoben.
|
||||||
|
- **Header-Icons vereinheitlicht:** alle Icon-Buttons einheitlich 42×42 px, Radius 10,
|
||||||
|
Glyphe 20×20, Outline-Stil (Surface-Fill, Akzent nur über Farbe/Rahmen).
|
||||||
|
|
||||||
|
### Barrierefreiheit
|
||||||
|
- **Widget perfektioniert:** Fokus-Falle im Panel, `aria-modal`, Schließen per Klick
|
||||||
|
außerhalb; der Textgrößen-Button nutzt ein dynamisches `aria-label` statt eines
|
||||||
|
falschen `aria-pressed`, der Reset-Button ist als Aktion ausgezeichnet; Hochkontrast
|
||||||
|
ist als „immer dunkel" kenntlich gemacht. Toter Legacy-Dock-Code entfernt.
|
||||||
|
- **Spielanleitungen & Hints:** gemeinsames „So funktioniert's"-Overlay mit ?-Button
|
||||||
|
und automatischem Erstaufruf für sieben Spiele (NetWorker behält seinen Inline-Guide);
|
||||||
|
MindMatch erhält einen „Paar-Blitz"-Tipp, der kurz ein Paar aufdeckt und dafür einen
|
||||||
|
Zug kostet (rein clientseitig, keine Backend-Änderung).
|
||||||
|
|
||||||
|
### FinalProtocol – Abschlussprüfung Teil 2
|
||||||
|
- **200 neue Prüfungsfragen:** je fünf Themen für Systemintegration (Netzwerkplanung,
|
||||||
|
IT-Sicherheit, Virtualisierung, Monitoring & Backup, Skripting) und Anwendungs-
|
||||||
|
entwicklung (SQL, UML, OOP, Algorithmen, Testverfahren) mit je 20 Fragen, fünf
|
||||||
|
Kategorien und ausgewogener Antwortverteilung. Titel und Navigation decken jetzt
|
||||||
|
AP1 **und** AP2 ab.
|
||||||
|
|
||||||
|
### DSGVO & Recht
|
||||||
|
- **Rechtslinks von jeder Seite erreichbar:** das bislang ungenutzte Footer-Fragment
|
||||||
|
wird jetzt auf allen Seiten inkl. Spiel-, Fehler- und Anmeldeseiten verwendet
|
||||||
|
(26 duplizierte Inline-Footer ersetzt, 11 fehlende ergänzt).
|
||||||
|
- **Datenschutzerklärung** um NeuralNet-Nachrichten, E-Mail-Token (SHA-256, 24 h),
|
||||||
|
SMTP-Versand und konkrete Speicherfristen ergänzt; **Impressum** nach § 5 DDG
|
||||||
|
strukturiert (auffällig markierte Pflichtfeld-Platzhalter).
|
||||||
|
- **Datenexport (Art. 20):** `/profile/export` liefert alle Kontodaten (ohne Passwort)
|
||||||
|
als JSON-Download.
|
||||||
|
- **Demo-Konto „NeuralTest"** wird per Migration aus produktiven Datenbanken entfernt.
|
||||||
|
|
||||||
|
### Technik & Qualität
|
||||||
|
- **E-Mail-Versand diagnostizierbar:** Bestätigungs- und Reset-Mails schreiben jetzt
|
||||||
|
datensparsame, korrelierbare Ereignislogs für Token-Erstellung, SMTP-Übergabe,
|
||||||
|
Fehler, erneute Anforderung und Verifikation. Roh-Tokens und Bestätigungslinks
|
||||||
|
werden nie protokolliert; eine SMTP-Annahme wird nicht als Postfachzustellung
|
||||||
|
ausgegeben.
|
||||||
|
- **Legacy aufgeräumt:** die nicht referenzierten Styles für Achievements, Progress,
|
||||||
|
Settings und Soziales sowie `finalflux-check.js` entfernt; der Styleguide dokumentiert
|
||||||
|
die neuen Effekt-Primitives.
|
||||||
|
- **Chart.js lokal ausgeliefert** statt vom CDN – das einzige externe Asset entfällt,
|
||||||
|
wodurch die Datenschutzerklärung wieder zur Realität passt.
|
||||||
|
- **Security-Header** ergänzt: Content-Security-Policy (self-hosted), Referrer-Policy;
|
||||||
|
HSTS bleibt auf HTTPS beschränkt.
|
||||||
|
- **Umlaute** in den Spielinhalten flächendeckend korrigiert (wort-/morphemgenau,
|
||||||
|
Slugs und Code-Bezeichner geschont), mit Reseed-Migration für bereits befüllte
|
||||||
|
Datenbanken.
|
||||||
|
- **AvatarService** aus dem ProfileController extrahiert (Bildverarbeitung und
|
||||||
|
Pfad-Logik zentralisiert); **Bean Validation** eingeführt (Start mit ScoreSubmission).
|
||||||
|
- Aufräumen: totes Scaffolding (`DummyController`, `dummy.html`, `hello.txt`) und
|
||||||
|
obsoleter CSS-Code entfernt.
|
||||||
|
|
||||||
|
## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand
|
||||||
|
|
||||||
|
### TechTacular
|
||||||
|
- **50:50-Joker:** Pro Runde kann im Frage-Overlay ein Joker zwei garantiert falsche
|
||||||
|
Antworten ausblenden und deaktivieren. Die Lösung bleibt vollständig serverseitig;
|
||||||
|
ein verwendeter Joker halbiert die Punkte der betreffenden Frage nur, wenn der
|
||||||
|
serverseitige Rundenzustand den Einsatz bestätigt.
|
||||||
|
|
||||||
|
### Konto & Sicherheit
|
||||||
|
- **E-Mail-Bestätigung:** Neue Accounts erhalten einen einmaligen, 24 Stunden
|
||||||
|
gültigen Bestätigungslink und werden erst nach erfolgreicher Verifikation zum
|
||||||
|
Login freigeschaltet. Bestehende Accounts bleiben ohne Unterbrechung bestätigt.
|
||||||
|
- **Passwort-Reset per E-Mail:** Über „Passwort vergessen?“ können bestätigte
|
||||||
|
Nutzerinnen und Nutzer einen zeitlich begrenzten Reset-Link anfordern und ihr
|
||||||
|
Passwort selbst neu vergeben. Token werden ausschließlich gehasht gespeichert.
|
||||||
|
- **Admin-Reset vereinheitlicht:** Manuelle Passwort-Resets im Adminbereich
|
||||||
|
verwenden jetzt verdeckte Eingabefelder und dieselbe Mindestlänge von acht Zeichen.
|
||||||
|
- **Zustellungsweg repariert:** Mindflux verwendet für SMTP den Zertifikatsnamen
|
||||||
|
`mail.sysnaptik.de`, der im internen `backend_network` direkt auf den
|
||||||
|
Mailserver aufgelöst wird. STARTTLS prüft damit den korrekten Hostnamen,
|
||||||
|
ohne die interne Container-Verbindung zu verlassen.
|
||||||
|
- **Deployment verifiziert:** Der Deployment-Workflow wartet jetzt bis zu 90
|
||||||
|
Sekunden auf den Container-Healthcheck und bricht mit den App-Logs ab, falls
|
||||||
|
die Anwendung nicht gesund startet. Dadurch werden fehlerhafte Deployments
|
||||||
|
nicht mehr fälschlich als erfolgreich angezeigt.
|
||||||
|
- **Registrierung kompakter:** „Bestätigungslink erneut senden“ ist nun eine
|
||||||
|
einklappbare Aktion; das Eingabefeld erscheint erst auf Klick. Bei längeren
|
||||||
|
Formularen bleibt der Registrierungsbereich innerhalb des Panels scrollbar.
|
||||||
|
- **Registrierung eindeutig bestätigt:** Nach einer erfolgreichen Anmeldung
|
||||||
|
bleibt der Registrierungsbereich geöffnet und zeigt die Versandbestätigung
|
||||||
|
direkt am Formular, statt unauffällig zum Login zu wechseln.
|
||||||
|
- **Registrierungsroute korrigiert:** Der direkte Aufruf von `/register` öffnet
|
||||||
|
nun wieder den Registrierungsmodus statt auf dem Login zu landen.
|
||||||
|
- **Passwort-Reset sichtbar verlinkt:** „Passwort vergessen?“ steht jetzt direkt
|
||||||
|
unter dem Passwortfeld im Login-Bereich.
|
||||||
|
- **Token-Schema nachgeschärft:** Folge-Migrationen richten UTC-Zeitstempel und
|
||||||
|
den Token-Hash-Typ an das JPA-Modell aus, damit Schema-Validierung und
|
||||||
|
Container-Start zuverlässig funktionieren.
|
||||||
|
|
||||||
|
### Marke & Plattform
|
||||||
|
- **Neural Lightmode & Kontrast:** Die helle Plattformvariante ist wieder ein
|
||||||
|
vollständiges Mint-/Blau-Neural-Theme, inklusive Sidebar, Karten und Login.
|
||||||
|
Hochkontrast nutzt jetzt dieselbe Mint-/Cyan-Sprache statt der alten Amber-Palette.
|
||||||
|
- **NeuralNet aufgeräumt:** Der leere Gesprächshinweis verschwindet beim Öffnen
|
||||||
|
wirklich, die letzte Verbindung wird pro Konto gespeichert und beim nächsten
|
||||||
|
Besuch direkt wieder geöffnet. Die Sprechblase bleibt neutral und invertiert
|
||||||
|
nur bei ungelesenen Nachrichten.
|
||||||
|
- **NeuralNet-Benachrichtigung:** Die Dashboard-Glocke ist jetzt eine
|
||||||
|
Sprechblase. Sie zeigt den echten Zähler ungelesener Direktnachrichten und
|
||||||
|
leuchtet in der Neural-Akzentfarbe, bis die Nachrichten gelesen sind.
|
||||||
|
- **NeuralNet-Chatlayout geschlossen:** Chatkopf, Nachrichtenliste und Eingabefeld
|
||||||
|
bilden nun eine feste Flex-Spalte im Panel. Der Footer kann nicht mehr zwischen
|
||||||
|
Gespräch und Eingabe rutschen.
|
||||||
|
- **NeuralNet-Buttons direkt gebunden:** Kontakt- und Chat-Buttons erhalten nach
|
||||||
|
jedem Rendern eigene Handler. Damit öffnet eine Verbindung unabhängig von der
|
||||||
|
Ereignisweitergabe des umgebenden Panels zuverlässig den Nachrichtenbereich.
|
||||||
|
- **NeuralNet-Chat öffnet sofort:** Der Klick auf eine Verbindung schaltet das
|
||||||
|
Gespräch samt Eingabefeld nun unabhängig vom anschließenden Verlaufsabruf frei;
|
||||||
|
Chat-Aktionen sind explizit als Buttons markiert.
|
||||||
|
- **NeuralNet-Chat repariert:** Leere, erfolgreiche API-Antworten werden nicht mehr
|
||||||
|
fälschlich als JSON gelesen. Kontaktaktionen aktualisieren die Oberfläche wieder
|
||||||
|
zuverlässig und bestätigte Verbindungen lassen sich direkt im Chat öffnen.
|
||||||
|
- **NeuralNet-Testkonto:** Ein klar benanntes, für NeuralNet aktiviertes Dummykonto
|
||||||
|
erleichtert das Prüfen von Kontaktanfragen und privaten Nachrichten.
|
||||||
|
- **NeuralNet-MVP:** Der frühere Soziales-Platzhalter ist jetzt ein Opt-in-Hub für
|
||||||
|
Kontaktanfragen, Annahme/Ablehnung/Rückzug und private 1:1-Nachrichten. Ohne
|
||||||
|
bestätigte Verbindung lässt der Server keine Nachrichten zu.
|
||||||
|
- **NeuralNet geplant:** Der kommende soziale Bereich heißt NeuralNet und priorisiert
|
||||||
|
private Lernverbindungen: Kontaktanfragen müssen erst ausdrücklich angenommen werden,
|
||||||
|
bevor private Nachrichten möglich sind.
|
||||||
|
- **Farbschema ohne Aufblitzen:** Die Neural-Hülle wird nun statisch mit dem
|
||||||
|
Grundtheme geladen und ihre Klasse bereits im `<head>` gesetzt. Theme- und
|
||||||
|
Bootstrap-Dateien tragen neue Versionsparameter, damit Login, Logout und jede
|
||||||
|
Navigation direkt im SysnaptiK-Neural-Schema zeichnen.
|
||||||
|
- **Fortschritt & Barrierefreiheit repariert:** Die Diagramme warten jetzt auf die
|
||||||
|
Neural-Plattformklasse und erscheinen dadurch in Mint statt im alten Orange.
|
||||||
|
Hochkontrast und Farbenblind-Modus überschreiben nun auch lokale Plattform- und
|
||||||
|
Spieltokens; der Farbenblind-Modus nutzt eine klar unterscheidbare Palette.
|
||||||
|
- **Echter Lernnetz-Status:** Der Dashboard-Status prüft nun serverseitig den
|
||||||
|
Fortschrittsspeicher und zeigt nachvollziehbar „bereit“ oder „eingeschränkt“ samt
|
||||||
|
Zeitstempel und registrierten Lernmodulen. Der dekorative 100%-Ring wurde entfernt.
|
||||||
|
- **Akzente klar getrennt:** MindMatch ist nun Magenta, SyntaxSaga ein kühleres Violett,
|
||||||
|
SystemEscape Gelb-Bernstein und FinalProtocol Koralle. Die Plattformkarten sind damit
|
||||||
|
auf einen Blick unterscheidbar, ohne die Farben in den Spielen selbst anzutasten.
|
||||||
|
- **Fortschrittsdiagramme korrigiert:** Donut- und Wochenchart lesen den Neural-Akzent
|
||||||
|
nun von der Plattformhülle statt den alten globalen Orange-Token zu verwenden.
|
||||||
|
- **Alte Platzhalter-Copy entfernt:** Die Registrierungsrakete und der Zeitreise-Spruch
|
||||||
|
auf der Vorbereitungsseite wurden durch neutrale SysnaptiK-Neural-Texte ersetzt.
|
||||||
|
- **Admin-Umfang transparent:** Das Admin-Dashboard benennt jetzt klar, welche
|
||||||
|
Inhaltsverwaltungen vorhanden sind und dass ServiceClash, NetWorker und FinalProtocol
|
||||||
|
noch direkt mit der Anwendung ausgeliefert werden.
|
||||||
|
- **Bestehendes Emblem bewahrt:** Die kurzfristig ergänzte CSS-Knotenmarke wurde
|
||||||
|
zurückgenommen. Das vorhandene Emblem bleibt als Wiedererkennungszeichen bestehen,
|
||||||
|
weil es die neue neuronale Metapher bereits passend transportiert.
|
||||||
|
- **Sichtbarer Markenwechsel:** Mindflux heißt auf der Plattform nun **SysnaptiK Neural**;
|
||||||
|
FinalFlux wurde zu **FinalProtocol**. Bestehende technische Routen, Datenbanktabellen,
|
||||||
|
Spiel-Keys und Paketnamen bleiben für Kompatibilität unverändert.
|
||||||
|
- **Neue Plattformwelt:** Die bisherige Zeitreise-/BTTF-Anmutung wurde durch ein dunkles
|
||||||
|
neuronales Netzwerk mit Knoten, Verbindungen, Petrolflächen und grünen Glows ersetzt.
|
||||||
|
Login/Registrierung, Dashboard, Profilseiten, Admin- und Rechtsseiten nutzen dieselbe Hülle.
|
||||||
|
- **Dashboard nach Spielknoten:** Jede Plattform-Spielkarte trägt einen eigenen Akzent
|
||||||
|
(u. a. DevLab Mint, SyntaxSaga Violett, BugHunter Crimson, NetWorker Blau,
|
||||||
|
FinalProtocol Orange) – ohne die internen Spieloberflächen zu verändern.
|
||||||
|
- **Zeitreise-Copy entfernt:** Die sichtbare Pausenfunktion heißt nun **NeuralPause**;
|
||||||
|
BTTF-Referenzen und Sprüche wurden durch neutrale Lernpausen-Texte ersetzt.
|
||||||
|
- **FinalProtocol durchgängig benannt:** Hub, Lernseiten, Navigation, Rücklinks und
|
||||||
|
Seitentitel zeigen den neuen Namen, während die bestehende FinalFlux-URL als kompatibler
|
||||||
|
technischer Pfad erhalten bleibt.
|
||||||
|
|
||||||
|
## [Unreleased] – 2026-07-13 · BugHunter, NetWorker & echter Lernfortschritt
|
||||||
|
|
||||||
|
### Planung
|
||||||
|
- **S3-Sicherheitsstand nachgezogen:** Der Zukunftsplan markiert die serverseitige
|
||||||
|
Spielwertung nun korrekt als umgesetzt: alle vier neuen Spiele halten Lösungen,
|
||||||
|
Reveal- und Versuchsstatus serverseitig und berechnen Ergebnisse unabhängig vom Client.
|
||||||
|
|
||||||
|
### Eigenständige Spielwelten
|
||||||
|
- **Bestandsspiele gestalterisch getrennt:** MindMatch erhielt ein Violett-/Mint-
|
||||||
|
„Neural Archive“, TechTacular eine Magenta-/Blaue Cyber-Quizshow, SyntaxSaga ein
|
||||||
|
phosphoriges Terminal und FinalFlux eine ruhige Gold-/Indigo-Prüfungszentrale.
|
||||||
|
- **DevLab als Werkstatt:** Petrol/Orange, Schaltkreisraster und Build-Pipeline-Akzente
|
||||||
|
schärfen die Oberfläche nach. Die schwebende Kolben-/dlFloat-Animation bleibt
|
||||||
|
unverändert erhalten.
|
||||||
|
- **Zugänglichkeit bleibt stimmig:** Das Accessibility-Widget erkennt und verwendet nun
|
||||||
|
auch für diese fünf Lernwelten automatisch deren lokalen Akzent; Statusfarben und
|
||||||
|
Spielmechaniken bleiben unverändert.
|
||||||
|
|
||||||
|
### ServiceClash
|
||||||
|
- **Auswertung repariert:** Nach dem letzten Ticket wurde die Abschlussansicht nicht
|
||||||
|
geöffnet, weil der Renderer noch auf ein nicht vorhandenes Folgeticket zugriff. Die
|
||||||
|
Abschlussprüfung erfolgt nun vor jeder Ticketzustands-Abfrage.
|
||||||
|
- **Letztes geplantes Spiel ausgeliefert:** drei Service-Desk-Schichten mit insgesamt
|
||||||
|
**24 kuratierten Tickets** aus Incident-, Request-, Change-, Monitoring-, Backup- und
|
||||||
|
Security-Szenarien. Jede Aufgabe führt verständlich durch Art, Priorität und passende
|
||||||
|
Erstmaßnahme.
|
||||||
|
- **Echte Wertung und Fortschritt:** V15-Datenmodell, lösungsfreie Sessions und
|
||||||
|
serverseitige Prüfung bewerten jeden der drei Schritte samt Versuchsleiter. Score,
|
||||||
|
Sterne, Highscore, Dauer und Fortschritt werden gespeichert; „Leitstand-Einsatz“ und
|
||||||
|
der fehlerfreie „Incident Commander“ sind als Achievements integriert.
|
||||||
|
- **Eigenständiger Leitstand:** Cyanfarbene Queue, Wellen, Fortschrittsanzeige,
|
||||||
|
Ticket-Debriefs sowie bewusstes Weiterklicken nach einer Lösung. Die Lobby erklärt
|
||||||
|
Incident vs. Anfrage und die Priorisierung vor der ersten Schicht.
|
||||||
|
- **Integration abgeschlossen:** Dashboard-Karte öffnet `/serviceclash`, die API ist
|
||||||
|
CSRF-konform eingebunden und das Accessibility-Widget übernimmt den ServiceClash-Akzent.
|
||||||
|
|
||||||
|
### BugHunter
|
||||||
|
- **Vollständiges Fallakten-Spiel ausgeliefert:** serverautoritatives Drei-Phasen-Debugging
|
||||||
|
(Orten → Klassifizieren → Fixen), Score, Combo, Sterne, Highscore, Admin-Pflege und
|
||||||
|
lösungsfreie Sessions über eigene V12-Tabellen, Seeder und API.
|
||||||
|
- **Inhalte ausgebaut und nach Schwierigkeit sortiert:** 12 schlüssige Fallakten von Junior
|
||||||
|
über Senior bis Principal mit längeren Briefings, Mehrdatei-Fällen und mehreren Bugs.
|
||||||
|
- **Archiv und Ablauf verfeinert:** Die Lobby ist ein Aktenschrank statt eines unlesbaren
|
||||||
|
Kartenstapels; Akten lassen sich klar öffnen. Nach jeder Prüfung bleibt das Debriefing
|
||||||
|
stehen und der nächste Schritt erfolgt bewusst per Klick.
|
||||||
|
- **Wiederholbarkeit:** Eine fehlerfrei gelöste Akte erhält einen diagonalen „GELÖST“-Stempel.
|
||||||
|
Ein Reset startet eine passende Variantenlage, sodass Fehler und Hinweise nicht immer an
|
||||||
|
derselben Stelle liegen.
|
||||||
|
- **Sprache und Gestaltung bereinigt:** Fallakten- statt „Investigation Bureau“-Vokabular,
|
||||||
|
korrigierte Schreibfehler sowie Crimson-Akzent ohne auffällig eingefärbte Dashboard-Karte.
|
||||||
|
|
||||||
|
### NetWorker
|
||||||
|
- **Neues Netzwerk-Puzzle ausgeliefert:** V14-Datenmodell, Seeder, Server-API und
|
||||||
|
serverseitiger `NetSimulator` prüfen IPv4-/CIDR-, Host-/Broadcast-, Gateway-,
|
||||||
|
Duplikat-IP- und Routing-Fälle unabhängig vom Client.
|
||||||
|
- **15 Aufgaben in drei Welten:** adressieren, diagnostizieren und routen; Ergebnisse,
|
||||||
|
Highscores und Fortschritt werden wie bei den übrigen Spielen gespeichert.
|
||||||
|
- **Blaupausen-Optik nachgeschärft:** korrekte Schreibweise „NetWorker“, sichtbares Raster
|
||||||
|
auf Seiten- und Netzwerkboard-Hintergrund sowie blaues Accessibility-Widget.
|
||||||
|
- **Einsteigerführung ergänzt:** Lobby, Weltstart und die Levelansicht erklären Ablauf,
|
||||||
|
IP-Adresse, Präfix, Gateway und Ping. Je Aufgabentyp gibt es konkrete Handlungsanweisungen
|
||||||
|
samt Beispielen für `/24`, Hostadressen und statische Routen.
|
||||||
|
- **Direkter Rückweg:** Die Kopfzeile führt mit „NETZPLÄNE“ zurück zur NetWorker-Übersicht,
|
||||||
|
ohne den Umweg über das Dashboard.
|
||||||
|
|
||||||
|
### Navigation & Fehlerbehebung
|
||||||
|
- **BugHunter und NetWorker:** „AKTENSCHRANK“ bzw. „NETZPLÄNE“ brechen einen laufenden,
|
||||||
|
noch nicht gespeicherten Durchgang bewusst ab und öffnen direkt die jeweilige Übersicht.
|
||||||
|
- **Fortschrittsseite repariert:** Der JavaScript-Inliner in `progress.html` konnte zwei
|
||||||
|
Ausdrücke in einer Rechenoperation nicht parsen und erzeugte deshalb einen Fehler 500.
|
||||||
|
Die Werte werden nun erst in JavaScript-Konstanten übergeben.
|
||||||
|
|
||||||
|
### Fortschritt, Achievements & Knowledge Core
|
||||||
|
- **Spielbezogene Achievements ergänzt:** MindMatch, TechTacular, DevLab, SyntaxSaga,
|
||||||
|
SystemEscape, BugHunter und NetWorker können nun passende Erfolge auslösen; zusätzliche
|
||||||
|
Spezialerfolge belohnen perfekte SystemEscape-/BugHunter-/NetWorker-Runden.
|
||||||
|
- **Dashboard und Statistik von Platzhaltern befreit:** Wochen-XP, Verlauf und Top-Spiele
|
||||||
|
kommen aus `game_scores`; die Statistik zeigt den realen XP-Fortschritt und die gemessene
|
||||||
|
Dauer abgeschlossener Runden. Nicht messbare Runden werden bewusst nicht als Spielzeit
|
||||||
|
behauptet.
|
||||||
|
- **Bezeichnungen präzisiert:** Das Level ist transparent ein XP-Level (1.000 XP je Stufe),
|
||||||
|
und die frühere Zahl „korrekte Antworten“ heißt nun „erfasste Spielaktionen“.
|
||||||
|
- **Knowledge Core aufgebaut:** Lernzentrale mit AP1-, Netzwerk-/Systemintegration- sowie
|
||||||
|
Debugging-/Security-Pfaden, Verweisen auf passende Module und persönlichem, datenbasiertem
|
||||||
|
Status statt vorgetäuschter Themenanalyse.
|
||||||
|
|
||||||
|
### Barrierefreiheit & Qualität
|
||||||
|
- Accessibility-Steuerung übernimmt in SystemEscape, BugHunter und NetWorker den jeweiligen
|
||||||
|
Spielakzent und bleibt in der Kopfzeile reserviert.
|
||||||
|
- Zusätzliche Tests sichern den Wochenaggregator und den kompletten `/progress`-Renderpfad;
|
||||||
|
insgesamt **46 Tests** grün.
|
||||||
|
|
||||||
## [Unreleased] – 2026-07-12 · Großer Verbesserungs-Sweep (Sicherheit, Daten, Navigation)
|
## [Unreleased] – 2026-07-12 · Großer Verbesserungs-Sweep (Sicherheit, Daten, Navigation)
|
||||||
|
|
||||||
Weitgehende Umsetzung des Verbesserungskatalogs aus `mindfluxfuture.md` (Teil 2;
|
Weitgehende Umsetzung des Verbesserungskatalogs aus `mindfluxfuture.md` (Teil 2;
|
||||||
|
|||||||
+18
-11
@@ -36,17 +36,18 @@ Theme-Umschaltung: über das Attribut `<html data-theme="dark|light">` (gesetzt/
|
|||||||
| `--surface-2` | `#1b1610` | `#fbf4e6` | erhöhte Fläche, Hover, Felder |
|
| `--surface-2` | `#1b1610` | `#fbf4e6` | erhöhte Fläche, Hover, Felder |
|
||||||
| `--border` | `#2a2117` | `#ece1cf` | Standard-Rahmen |
|
| `--border` | `#2a2117` | `#ece1cf` | Standard-Rahmen |
|
||||||
| `--line` | `#221b12` | `#f1e8d8` | feine Linien, Scrollbar, Chart-Achsen |
|
| `--line` | `#221b12` | `#f1e8d8` | feine Linien, Scrollbar, Chart-Achsen |
|
||||||
| `--border-accent` | `rgba(240,168,26,.45)` | `rgba(214,81,22,.40)` | akzentuierte Rahmen |
|
| `--border-accent` | `rgba(240,168,26,.45)` | `rgba(194,65,12,.40)` | akzentuierte Rahmen |
|
||||||
| `--accent` | `#f0a81a` | `#d65116` | **Primärakzent**: Buttons, aktive Nav, Icons, Headline-Akzent |
|
| `--accent` | `#f0a81a` | `#c2410c` | **Primärakzent**: Buttons, aktive Nav, Icons, Headline-Akzent |
|
||||||
| `--accent-2` | `#f7dc11` | `#e4801d` | Sekundär-Akzent für Verläufe (Gelb-Glow) |
|
| `--accent-2` | `#f7dc11` | `#b45309` | Sekundär-Akzent für Verläufe (Gelb-Glow) |
|
||||||
| `--accent-ink` | `#1c1100` | `#ffffff` | Textfarbe **auf** Akzentflächen |
|
| `--accent-ink` | `#1c1100` | `#ffffff` | Textfarbe **auf** Akzentflächen |
|
||||||
| `--secondary` | `#2d9cce` | `#1f86b8` | **Flux-Blau**: Links + „richtig" |
|
| `--secondary` | `#2d9cce` | `#146b96` | **Flux-Blau**: Links + „richtig" |
|
||||||
| `--secondary-2` | `#5cc4e8` | `#2d9cce` | helleres Blau (Hover/Variante) |
|
| `--secondary-2` | `#5cc4e8` | `#0f5a80` | helleres Blau (Hover/Variante) |
|
||||||
| `--text` | `#ece3d2` | `#33271c` | Fließtext |
|
| `--text` | `#ece3d2` | `#33271c` | Fließtext |
|
||||||
| `--muted` | `#8c8170` | `#8a7d6c` | gedämpfter Text, Labels, Captions |
|
| `--muted` | `#8c8170` | `#6b5f4c` | gedämpfter Text, Labels, Captions |
|
||||||
| `--heading` | `#ffffff` | `#241a12` | Überschriften |
|
| `--heading` | `#ffffff` | `#241a12` | Überschriften |
|
||||||
| `--success` | `#2d9cce` | `#1f86b8` | **„richtig"** (= Flux-Blau, bewusst kein Grün) |
|
| `--success` | `#2d9cce` | `#146b96` | **„richtig"** (= Flux-Blau, bewusst kein Grün) |
|
||||||
| `--danger` | `#ff4d6d` | `#be123c` | **„falsch"** / Fehler / Löschen (kühles Crimson) |
|
| `--danger` | `#ff4d6d` | `#be123c` | **„falsch"** / Fehler / Löschen (kühles Crimson) |
|
||||||
|
| `--warn` | `#edcb4c` | `#8a6a06` | **„eingeschränkt"** / Warnstatus (Light-Wert WCAG-AA) |
|
||||||
| `--glow-s` | `rgba(240,168,26,.55)` | `rgba(214,81,22,.40)` | **starker** Glow (Text-/Drop-Shadow auf Akzent) |
|
| `--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 |
|
| `--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 |
|
| `--g-soft` | `0 0 18px var(--glow-w)` | `0 6px 18px var(--glow-w)` | Standard-Panel-Schatten |
|
||||||
@@ -94,6 +95,10 @@ Muster (siehe z. B. `syntaxsaga.css` / `techtacular.css`):
|
|||||||
- **Panel-Schatten:** `box-shadow: var(--g-soft)`.
|
- **Panel-Schatten:** `box-shadow: var(--g-soft)`.
|
||||||
- **Radien (Konvention):** Badges/Buttons ~`7–9px`, Inputs/Icon-Buttons `10px`, Nav `11px`,
|
- **Radien (Konvention):** Badges/Buttons ~`7–9px`, Inputs/Icon-Buttons `10px`, Nav `11px`,
|
||||||
Panels `13–14px`, Standalone-Karten `16–18px`, Pills/Bars `99px`.
|
Panels `13–14px`, Standalone-Karten `16–18px`, Pills/Bars `99px`.
|
||||||
|
- **Header-Icon-Buttons** (`.mf-iconbtn`, `.mf-theme`, `.mf-a11y-trigger`, `.ft-trigger`):
|
||||||
|
einheitlich **`42×42px`**, Radius `10px`, **Outline-Stil** (Surface-Fill, 1px Border,
|
||||||
|
Akzent nur über `color`/`border-color`), Glyphe **`20×20px`**. Der `.mf-theme`-Toggle behält
|
||||||
|
Akzentfarbe + Glow als Erkennungsmerkmal.
|
||||||
- **Spielkarten** (`.mf-card`) haben eine **abgeschrägte Ecke** via `clip-path` (`--n:18px`) statt
|
- **Spielkarten** (`.mf-card`) haben eine **abgeschrägte Ecke** via `clip-path` (`--n:18px`) statt
|
||||||
rundem Radius – ein bewusstes „HUD"-Detail.
|
rundem Radius – ein bewusstes „HUD"-Detail.
|
||||||
|
|
||||||
@@ -157,16 +162,18 @@ Feedback-Konvention (Blau/Rot) einhalten.
|
|||||||
## Do / Don't
|
## Do / Don't
|
||||||
|
|
||||||
**Do**
|
**Do**
|
||||||
- Farben/Schatten/Glow ausschließlich über Tokens.
|
- Farben, Schatten und Effekte ausschließlich über Tokens. Für Effekte die
|
||||||
|
Primitives `--g-text`, `--g-icon`, `--g-strong`, `--g-amb`, `--g-card`,
|
||||||
|
`--g-card-hover`, `--g-overlay` und `--g-focus` verwenden; keine direkten
|
||||||
|
Glow-Geometrien in Komponenten schreiben.
|
||||||
- Auf Akzentflächen Text in `--accent-ink`.
|
- Auf Akzentflächen Text in `--accent-ink`.
|
||||||
- Erfolg = Blau, Fehler = Rot (nie Grün/Orange für „richtig/falsch").
|
- Erfolg = Blau, Fehler = Rot (nie Grün/Orange für „richtig/falsch").
|
||||||
- Neue Komponenten an den `.mf-*`-Bausteinen orientieren.
|
- Neue Komponenten an den `.mf-*`-Bausteinen orientieren.
|
||||||
|
|
||||||
**Don't**
|
**Don't**
|
||||||
- Keine hart kodierten Hex-/RGBA-Werte in Templates oder Spiele-CSS.
|
- Keine hart kodierten Hex-/RGBA-Werte in Templates oder Spiele-CSS.
|
||||||
- Nicht die **Legacy-Dateien** als Stil-Referenz nehmen: `achievements.css`, `progress.css`,
|
- Keine direkten `0 0 … var(--glow-*)`-, `text-shadow`- oder `drop-shadow`-Werte
|
||||||
`settings.css`, `soziales.css` (ungenutzt) sowie alte Schrift-Deklarationen
|
in Komponenten. Maßgeblich ist `theme.css`.
|
||||||
(`'Segoe UI'`/`'Poppins'`) entsprechen **nicht** dem aktuellen System. Maßgeblich ist `theme.css`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -9,12 +9,10 @@ services:
|
|||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb
|
||||||
SPRING_DATASOURCE_USERNAME: mindflux
|
SPRING_DATASOURCE_USERNAME: mindflux
|
||||||
SPRING_DATASOURCE_PASSWORD: "${MINDFLUX_DB_PASSWORD:?MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein}"
|
SPRING_DATASOURCE_PASSWORD: "${MINDFLUX_DB_PASSWORD:?MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein}"
|
||||||
|
|
||||||
# --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) ---
|
# --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) ---
|
||||||
SERVER_PORT: "8080"
|
SERVER_PORT: "8080"
|
||||||
MINDFLUX_PUBLIC_URL: "https://mindflux.sysnaptik.de"
|
MINDFLUX_PUBLIC_URL: "https://mindflux.sysnaptik.de"
|
||||||
MINDFLUX_COOKIE_SECURE: "true"
|
MINDFLUX_COOKIE_SECURE: "true"
|
||||||
|
|
||||||
# --- Bootstrap-Admins: Passwörter kommen aus Gitea-Secrets (siehe Workflow) ---
|
# --- Bootstrap-Admins: Passwörter kommen aus Gitea-Secrets (siehe Workflow) ---
|
||||||
MINDFLUX_ADMIN_USERNAME: "AliceDarkest"
|
MINDFLUX_ADMIN_USERNAME: "AliceDarkest"
|
||||||
MINDFLUX_ADMIN_EMAIL: "AliceDarkest@gmail.com"
|
MINDFLUX_ADMIN_EMAIL: "AliceDarkest@gmail.com"
|
||||||
@@ -22,6 +20,13 @@ services:
|
|||||||
MINDFLUX_ADMIN2_USERNAME: "KevCipher"
|
MINDFLUX_ADMIN2_USERNAME: "KevCipher"
|
||||||
MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me"
|
MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me"
|
||||||
MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}"
|
MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}"
|
||||||
|
# --- Mailversand ---
|
||||||
|
# Der Zertifikatsname des SMTP-Servers; der interne Docker-Alias "mailserver"
|
||||||
|
# würde die STARTTLS-Hostname-Prüfung scheitern lassen.
|
||||||
|
MAIL_HOST: mail.sysnaptik.de
|
||||||
|
MAIL_PORT: "587"
|
||||||
|
MAIL_USERNAME: "noreply@sysnaptik.de"
|
||||||
|
MAIL_PASSWORD: "${MAIL_PASSWORD}"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fs", "http://localhost:8080/actuator/health"]
|
test: ["CMD", "curl", "-fs", "http://localhost:8080/actuator/health"]
|
||||||
|
|||||||
+191
-45
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
## Kontext
|
## 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:
|
Das Dashboard von Mindflux (`mindflux2`, Spring Boot 3.4.4 / Java 21 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier Spiele waren bei Erstellung dieses Plans Platzhalter-Karten. **Alle vier – SystemEscape, BugHunter, NetWorker und ServiceClash – sind inzwischen umgesetzt.**
|
||||||
|
|
||||||
1. **SystemEscape** – „Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System."
|
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!"
|
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."
|
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."
|
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.
|
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`. Dieser Plan dokumentiert den erreichten Stand und bleibt Referenz für spätere Content- und Visual-Vertiefungen.
|
||||||
|
|
||||||
**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:
|
**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:
|
||||||
|
|
||||||
@@ -45,11 +45,13 @@ Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Visuelle Identität: vier Spiele, vier Welten
|
## Visuelle Identität: neun Lernwelten
|
||||||
|
|
||||||
> ◐ **Fundament umgesetzt:** Die Dark-/Light-Akzent-Tokens für alle vier Spiele sind in
|
> ◐ **Stand 2026-07-13:** Die Dark-/Light-Akzent-Tokens für alle vier Spiele sind in
|
||||||
> `theme.css` angelegt. Offen bleiben die konkreten Karten-Designs, Spiel-Layouts, Illustrationen
|
> `theme.css` angelegt. SystemEscape, BugHunter und NetWorker haben eigene Spiel-Layouts;
|
||||||
> und Animationen; diese entstehen jeweils zusammen mit dem betreffenden Spiel.
|
> BugHunter nutzt Fallakten-/Aktenschrank-Optik, NetWorker ein sichtbares Blaupausenraster.
|
||||||
|
> ServiceClash ist nun als eigenständiger cyanfarbener Leitstand umgesetzt; optionale
|
||||||
|
> Echtzeit-SLA-Animationen bleiben ein möglicher Ausbau.
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
@@ -66,13 +68,101 @@ Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr d
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
### Eigenständige Skins für die Bestandsmodule ✅ umgesetzt
|
||||||
|
|
||||||
|
Die gemeinsame Mindflux-Grundlage bleibt erhalten: Typografie, Fokuszustände, Buttons,
|
||||||
|
Fehler-/Erfolgsfarben und das Accessibility-Widget werden nicht pro Spiel neu erfunden.
|
||||||
|
Jede Lernwelt erhält aber eigene Akzent-Tokens, Hintergrundstruktur und eine passende
|
||||||
|
Oberflächenmetapher – im Dark- wie im Light-Theme.
|
||||||
|
|
||||||
|
| Modul | Skin | Akzent & Metapher | Umsetzungshinweis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **MindMatch** | Neural Archive | Violett/Mint, Synapsenlinien und schwebende Gedächtniskarten | Paare bleiben klar lesbar; Verbindungen sind dekorativ, nicht alleiniger Status. |
|
||||||
|
| **TechTacular** | Cyber-Quizshow | Magenta/Blau, LED-Raster und Punktetafel | Das Jeopardy-Board wird zur Show-Bühne, ohne die Kachelkontraste zu verlieren. |
|
||||||
|
| **DevLab** | Werkstatt & Build-Pipeline | Petrol/Orange, Werkbank- und Schaltkreisstruktur | **Der schwebende Kolben samt \`dlFloat\`-Animation bleibt unverändert erhalten.** Nur Farben, Umgebung und Panels werden nachgeschärft. |
|
||||||
|
| **SyntaxSaga** | Textadventure-Terminal | Phosphorgrün/Bernstein, dezente CRT-Scanlines und Log-Charakter | Grün dient ausschließlich der Atmosphäre; richtig bleibt Plattform-Blau, falsch bleibt Rot. |
|
||||||
|
| **FinalFlux** | Prüfungszentrale | Gold/Indigo, ruhige Sternkarten-/Konstellationsstruktur | Bewusst weniger spielig: die Lernzentrale verbindet alle Welten und priorisiert Lesbarkeit. |
|
||||||
|
|
||||||
|
**Technische Leitplanken:** zentrale Token in \`theme.css\`, lokale Überschreibung nur am
|
||||||
|
jeweiligen Seiten-Root (\`mm-\`, \`tt-\`, \`dl-\`/\`dlg-\`, \`ss-\`, \`finalflux-\`), keine
|
||||||
|
Änderung der Spielmechanik; Cache-Busting bei jeder CSS-Auslieferung. Das
|
||||||
|
Accessibility-Widget soll anschließend jeweils den lokalen Akzent übernehmen.
|
||||||
|
|
||||||
|
> ✅ **Stand 2026-07-13:** Die fünf Skins sind als lokale Token-Überschreibungen mit
|
||||||
|
> Cache-Busting umgesetzt. MindMatch, TechTacular, DevLab samt Aufgabenraum, SyntaxSaga
|
||||||
|
> und FinalFlux haben jeweils eigene Hintergründe und Akzente; das Accessibility-Widget
|
||||||
|
> übernimmt die neue Farbe. Die bestehende Kolben-/dlFloat-Animation in DevLab blieb
|
||||||
|
> unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plattform-Rebrand: SysnaptiK Neural ⏳ in Umsetzung
|
||||||
|
|
||||||
|
> ✅ **Stand 2026-07-14:** Die sichtbare Plattformhülle ist auf SysnaptiK Neural
|
||||||
|
> umgestellt: zentrale Neural-Schicht, Wortmarke, Login/Registrierung, Dashboard,
|
||||||
|
> Plattformseiten und FinalProtocol. Die Spielwurzeln sind explizit vom Plattform-Scope
|
||||||
|
> ausgeschlossen und behalten ihre jeweiligen internen Skins. NeuralPause ersetzt die
|
||||||
|
> sichtbare FluxTimer-/Zeitreise-Copy. Domain-/DNS-Umzug und technische Paket-/Tabellennamen
|
||||||
|
> bleiben bewusst separate Schritte. Der Dashboard-Status ist als echter Lernnetz-Status
|
||||||
|
> umgesetzt: Er prüft den Fortschrittsspeicher statt eine feste Verfügbarkeit vorzutäuschen.
|
||||||
|
> Die Plattformdiagramme und Accessibility-Modi berücksichtigen die Neural-Tokens auch
|
||||||
|
> dann, wenn einzelne Seiten oder Spiele eigene lokale Akzente setzen.
|
||||||
|
> Die Hülle ist zudem renderblockierend und mit früher HTML-Klasse eingebunden, damit beim
|
||||||
|
> Navigationwechsel kein früheres Plattformfarbschema aufblitzt.
|
||||||
|
|
||||||
|
### Ziel und Abgrenzung
|
||||||
|
|
||||||
|
Mindflux wird vor dem öffentlichen Launch als **SysnaptiK Neural** positioniert. Die Plattform
|
||||||
|
wechselt vom bisherigen Zeitreise-/Back-to-the-Future-Anklang zu einer eigenständigen
|
||||||
|
neuronalen Netzwerk-Metapher. Das Lernmodul **FinalFlux** heißt künftig **FinalProtocol**.
|
||||||
|
|
||||||
|
Der Rebrand betrifft ausschließlich die Plattformhülle: Startseite, Login/Registrierung,
|
||||||
|
Dashboard, Profil, Fortschritt, Erfolge, Einstellungen, Knowledge Core, FinalProtocol,
|
||||||
|
Admin- und Rechtsseiten, Seitentitel sowie öffentliche Metadaten. Die eigenständigen
|
||||||
|
Spielwelten und ihre internen Farben bleiben unverändert.
|
||||||
|
|
||||||
|
### Visuelles System
|
||||||
|
|
||||||
|
- **Grundwelt:** tiefes Petrol/Schwarz, leise Netzwerklinien, Knoten, weiche Glows und
|
||||||
|
klare technische Panels statt Zeitreise-, DeLorean-, Fluxkompensator- oder BTTF-Verweise.
|
||||||
|
- **Marke:** CSS-/SVG-Wortmarke „SYSNAPTIK NEURAL“ mit abstraktem neuronalen Knoten statt
|
||||||
|
einer abgeleiteten Popkultur-Ästhetik.
|
||||||
|
- **Statusfarben:** Erfolg bleibt Plattform-Blau, Fehler Rot; Knotenstatus wird zusätzlich
|
||||||
|
über Symbol, Text und Form vermittelt.
|
||||||
|
- **Knotenfarben auf der Plattform:** MindMatch Magenta/Violett, TechTacular Gelb,
|
||||||
|
DevLab Mint, SyntaxSaga Violett, SystemEscape Bernstein, ServiceClash Türkis,
|
||||||
|
BugHunter Crimson, NetWorker Blau und FinalProtocol Orange. Diese Zuordnung gilt für
|
||||||
|
Dashboard, Lernnetz, Fortschritt und Erfolge – nicht als Änderung der Spieloberflächen.
|
||||||
|
|
||||||
|
### Umsetzungsphasen
|
||||||
|
|
||||||
|
1. **Fundament:** zentrale Neural-Tokens und Markenbausteine in theme.css, BTTF-/Zeitreise-
|
||||||
|
Copy aus Plattform-Tokens und FluxTimer entfernen; Accessibility auf die neutrale
|
||||||
|
Plattformfarbe ausrichten.
|
||||||
|
2. **Referenzoberflächen:** Start/Authentifizierung und Dashboard als neuraler Leitstand:
|
||||||
|
neue Sidebar, Spielknoten-Karten, Systemstatus, echter Fortschritt und ein
|
||||||
|
Lernnetz statt dekorativer Platzhalter.
|
||||||
|
3. **Plattformseiten:** Profil, Fortschritt, Erfolge, Einstellungen, Knowledge Core,
|
||||||
|
Fehler-, Rechts- und Admin-Seiten auf das gemeinsame Layout und die Wortmarke ziehen.
|
||||||
|
4. **FinalProtocol:** Anzeigename, Hub, Lernseiten, Navigation, Titel und Verweise von
|
||||||
|
FinalFlux auf FinalProtocol umstellen; URLs bleiben zunächst kompatibel bei
|
||||||
|
/finalflux, um bestehende Links nicht zu brechen.
|
||||||
|
5. **Öffentliche Identität & Rollout:** Dokumentation, Titel/Meta-/OpenGraph-Texte,
|
||||||
|
Datenschutz/Impressum und Konfiguration auf SysnaptiK Neural umstellen. Ein möglicher
|
||||||
|
Domainwechsel auf neural.sysnaptik.de erfolgt separat zusammen mit DNS, nginx,
|
||||||
|
Cookie-Domain und einer Weiterleitung von mindflux.sysnaptik.de.
|
||||||
|
6. **Abnahme:** Dark/Light, Mobilansicht, Tastatur-/Lesemodus, Kontraste und alle
|
||||||
|
Spielrouten prüfen. Technische Paketnamen, Datenbanktabellen, Score-Keys und Secrets
|
||||||
|
bleiben im ersten Schritt bewusst stabil; eine spätere technische Umbenennung ist ein
|
||||||
|
separates Migrationsprojekt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Das gemeinsame Architektur-Rezept (gilt für alle 4 Spiele)
|
## 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`):
|
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.
|
1. **Flyway-Migration** ab `db/migration/V11__…` (V9/V10 sind durch User-/Datenintegrität belegt; `ddl-auto=validate` ⇒ Migration ist Pflicht). Umgesetzt: V11 SystemEscape, V12 BugHunter, V13 BugHunter-Fortschritt, V14 NetWorker und V15 ServiceClash. 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).
|
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`).
|
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.
|
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.
|
||||||
@@ -154,7 +244,12 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Spiel 2: BugHunter (Key: `bughunter`, Migration V12)
|
### Spiel 2: BugHunter (Key: `bughunter`, Migration V12) ✅ umgesetzt und ausgebaut
|
||||||
|
|
||||||
|
> ✅ **Stand 2026-07-13:** Das serverautoritative Spiel, Seeder, Admin-CRUD und Dashboard-Route
|
||||||
|
> sind umgesetzt. Der Katalog umfasst jetzt **12 Fallakten**, nach Junior/Senior/Principal
|
||||||
|
> gruppiert. Die Akten liegen im Aktenschrank; nach einer perfekten Lösung erscheint ein
|
||||||
|
> „GELÖST“-Stempel. Ein Reset startet eine inhaltlich passende Fallvariante.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -167,7 +262,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bughunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
|
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bughunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
|
||||||
|
|
||||||
**Datenmodell (`V12__bughunter.sql`):**
|
**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_hunts` (id, slug UNIQUE, title, code_language, difficulty DEFAULT 'junior', exam_scope, 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_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"}`
|
- `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.
|
- Indizes auf beide FKs.
|
||||||
@@ -179,17 +274,24 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
|
|
||||||
**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}`).
|
**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).
|
**Frontend:** `templates/bughunter.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.
|
**Content & Seeder:** `BugHunterSeeder` liest `resources/bughunter/content.json`; der komplette Bug-Knoten wird wie bei `DevLabSeeder` per `toString()` zum Payload. Der Katalog enthält **12 Fälle** in den Stufen Junior, Senior und Principal mit mehreren Dateien und mehreren Bugs pro Akte. Neue Seed-Akten werden idempotent auch in eine bereits bestehende Datenbank nachgeladen.
|
||||||
|
|
||||||
**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.
|
**Admin:** `AdminBugHunterController` (`/admin/bughunter`), 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).
|
**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)
|
### Spiel 3: ServiceClash (Key: `serviceclash`, Migration V15) ✅ umgesetzt
|
||||||
|
|
||||||
|
> ✅ **Stand 2026-07-13:** V15-Datenmodell, Entities, Repository, Seeder, API und ein
|
||||||
|
> serverautoritatives Drei-Schritte-Scoring sind umgesetzt. Das Spiel enthält drei
|
||||||
|
> Schichten mit je acht Tickets (24 insgesamt), eine einsteigerfreundliche Briefing-Ansicht,
|
||||||
|
> Queue-/Wellen-Leitstand, Debriefs, Score-/Highscore-Speicherung und die Achievements
|
||||||
|
> „Leitstand-Einsatz“ sowie „Incident Commander“. Die technische Echtzeit-SLA-Simulation,
|
||||||
|
> zufällige Seed-Pläne und Admin-CRUD bleiben bewusste Ausbauschritte.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -214,28 +316,52 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
- Jeder Klick geht an `POST /check` (sofortiges ✓/✗-Feedback; Lösungen verlassen den Server nie).
|
- 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.
|
- `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`):**
|
**Datenmodell (`V15__serviceclash.sql`, umgesetzt):**
|
||||||
- `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_shifts` (id, slug UNIQUE, title, description, difficulty, exam_scope,
|
||||||
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index
|
sort_order, active)
|
||||||
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt).
|
- `service_clash_tickets` (id, shift_id FK CASCADE, title, wave, points, sort_order,
|
||||||
|
payload VARCHAR(8000)) + FK-Index
|
||||||
|
- Ticket-Payload enthält Antragsteller, Sachverhalt, Optionen und die ausschließlich
|
||||||
|
serverseitig ausgewerteten Felder `category`, `priority`, `solution`, `explanation`.
|
||||||
|
|
||||||
**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.
|
**Backend (umgesetzt):** Entities `ServiceClashShift`/`ServiceClashTicket` und
|
||||||
|
`ServiceClashShiftRepository`; `ServiceClashService` baut den Katalog und eine lösungsfreie
|
||||||
|
Session, prüft `classify|priority|solve` ausschließlich gegen den serverseitig gehaltenen
|
||||||
|
Payload und berechnet beim Abschluss Score, Sterne und Highscore mit
|
||||||
|
`recordScore(user, "serviceclash", slug, score, gelöste, sekunden)`.
|
||||||
|
|
||||||
**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]}`.
|
**API (`/api/serviceclash`, umgesetzt):** `GET /catalog`, `GET /session/{slug}` (lösungsfreie
|
||||||
|
Ticket-Instanzen mit Welle und Auswahlmöglichkeiten), `POST /check` sowie `POST /finish` →
|
||||||
|
`{score, maxScore, stars, resolved, total, health, highscore, newHighscore, plays}`.
|
||||||
|
|
||||||
**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)`.
|
**Frontend (umgesetzt):** `templates/serviceclash.html`, `static/js/serviceclash.js` und
|
||||||
|
`static/css/serviceclash.css` (Präfix `sc-`). Die Zustände Katalog → Briefing → Leitstand →
|
||||||
|
Debrief zeigen links Queue und Wellenfortschritt, in der Mitte den dreistufigen Ticket-Flow
|
||||||
|
und am Ende eine klare Score-Auswertung. Eine Echtzeit-SLA-Uhr ist als späterer Ausbau notiert.
|
||||||
|
|
||||||
**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).
|
**Schwierigkeitskurve (umgesetzt):** „Frühschicht: Stabiler Start“ vermittelt Routinefälle,
|
||||||
|
„Tagesschicht: Prioritäten unter Druck“ ergänzt Phishing und Monitoring, „Bereitschaft:
|
||||||
|
Kritische Lage“ behandelt Ransomware, Changes, Backups, Zertifikate und DNS. Jede Schicht
|
||||||
|
enthält acht Tickets in drei Wellen; ein Reset startet die Wertung neu.
|
||||||
|
|
||||||
**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).
|
**Content & Seeder (umgesetzt):** `ServiceClashSeeder` versorgt beim ersten Start drei
|
||||||
|
Schichten mit jeweils acht kuratierten Tickets (**24 insgesamt**). Enthalten sind u. a.
|
||||||
|
Drucker-Queue, Azubi-Onboarding, ERP-Totalausfall, Phishing, Change-Management,
|
||||||
|
Backup-/Zertifikats- und DNS-Störungen.
|
||||||
|
|
||||||
**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`).
|
**Admin:** Die Admin-Pflege bleibt ein Ausbau; der produktive Startkatalog wird aktuell über
|
||||||
|
den idempotenten Seeder versorgt.
|
||||||
|
|
||||||
**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".
|
**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)
|
### Spiel 4: NetWorker (Key: `networker`, Migration V14) ✅ umgesetzt
|
||||||
|
|
||||||
|
> ✅ **Stand 2026-07-13:** V14-Datenmodell, Entities, Repositories, Seeder, API,
|
||||||
|
> serverautoritativer `NetSimulator`, Ergebnis-/Score-Integration und 15 Aufgaben in drei Welten
|
||||||
|
> sind umgesetzt. Das UI nutzt eine SVG-Topologie mit blauem Blaupausenraster. Erweiterungen wie
|
||||||
|
> VLAN, DHCP, Sandbox und visueller Editor bleiben bewusst als Ausbau markiert.
|
||||||
|
|
||||||
**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?").
|
**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?").
|
||||||
|
|
||||||
@@ -243,28 +369,28 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
**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).
|
**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)`.
|
**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, "networker", weltSlug, score, gelöste, sekunden)`.
|
||||||
|
|
||||||
**Datenmodell (`V13__networker.sql`):**
|
**Datenmodell (`V14__networker.sql`):**
|
||||||
- `netzwerker_worlds` (id, slug UNIQUE, title, description, difficulty INT, sort_order, active)
|
- `network_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
|
- `network_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):**
|
**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)`.
|
- 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`.
|
- `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.
|
- 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.**
|
**`NetworkerService`:** `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(...)`.
|
||||||
|
|
||||||
**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.
|
**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.
|
**Frontend:** `templates/networker.html`, `static/js/networker.js` (State-Machine `WORLD_SELECT → LEVEL_INTRO → EDITING ⇄ CHECKING → LEVEL_DONE → SUMMARY`), `static/css/networker.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 aktuelle Umsetzung zeigt die Topologie mit sichtbarem Raster, Ping-Prüfung und Diagnose-Panel; Paket-Animation, Kabelmodus und Hinweis-Toggle bleiben Ausbau. 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".
|
||||||
|
|
||||||
**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".
|
**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).
|
**Content & Seeder:** `NetworkerSeeder` liest `resources/networker/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.
|
**Admin:** Admin-Pflege ist als Ausbau vorgesehen; der produktive Spielkatalog wird aktuell über den Seeder versorgt.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -275,13 +401,15 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
0. **Vorarbeiten ✅:** Namensschema, Dashboard-Namen und Keys, `devlab` in `ProgressService`, K2,
|
0. **Vorarbeiten ✅:** Namensschema, Dashboard-Namen und Keys, `devlab` in `ProgressService`, K2,
|
||||||
W8, V11–V14-Migrationsreservierung, `exam_scope`-Konvention, S3-Sicherheitsvertrag und
|
W8, V11–V14-Migrationsreservierung, `exam_scope`-Konvention, S3-Sicherheitsvertrag und
|
||||||
Spiel-Akzent-Tokens in `theme.css` sind umgesetzt.
|
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
|
1. **FluxTimer ✅** – risikoarmer, rein lokaler Ausbau; validiert den globalen UI-Bootstrap und belegt die Glocke vor NeuralNet. ≈ 1,5–2 PT
|
||||||
2. **SystemEscape (V11) ✅** – serverautoritatives Spiel mit 3 Szenarien, 24 Räumen, 69 Rätseln
|
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,
|
und vier zufälligen Räumen pro Run; Admin-CRUD, `exam_scope`, festgeschriebene Run-Auswahl,
|
||||||
dynamische Wertung und Anti-Cheat-Negativtests umgesetzt.
|
dynamische Wertung und Anti-Cheat-Negativtests umgesetzt.
|
||||||
3. **BugHunter (V12)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 2. ≈ 4,5 PT
|
3. **BugHunter (V12) ✅** – Fallakten, serverautoritatives Drei-Phasen-Debugging, Combo-Wertung, Admin-CRUD und zwölf Fälle mit Reset-Varianten umgesetzt.
|
||||||
4. **NetWorker (V13)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
|
4. **NetWorker (V14) ✅** – testgetriebener Simulator, SVG-Frontend, drei Welten mit 15 Aufgaben, Score-/Achievement-Integration und Blaupausenraster umgesetzt.
|
||||||
5. **ServiceClash (V14)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT
|
5. **ServiceClash (V15) ✅** – drei Schichten, 24 Tickets, serverautoritatives Triage-Scoring,
|
||||||
|
Score-/Achievement-Integration und cyanfarbener Leitstand sind umgesetzt. Echtzeit-SLA,
|
||||||
|
Seed-Mischung und Admin-Pflege bleiben als Ausbau markiert.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -289,7 +417,14 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
## Teil 2: Verbesserungen & Optimierungen am Bestand
|
## 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).
|
> ✅ **Umgesetzt am 2026-07-12/13** (K1–K3, W1–W8, N1–N6, S1–S8, S10 – Details im CHANGELOG). Offen bleiben nur S9/i18n (bewusst zurückgestellt) sowie die schrittweise Fragment-Migration der restlichen Spiel-/Admin-Templates. **S3 ist für SystemEscape, BugHunter, ServiceClash und NetWorker umgesetzt:** lösungsfreie Sessions, serverseitige Checks, Reveal-/Versuchsprotokollierung und serverseitige Abschlusswertung sind integriert. **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).
|
||||||
|
|
||||||
|
> ✅ **Ergänzung am 2026-07-13:** Dashboard-Wochenwerte, Verlauf und Top-Spiele sind aus
|
||||||
|
> `game_scores` abgeleitet; die Statistik zeigt ausschließlich gemessene Spielzeit.
|
||||||
|
> Spielbezogene Achievements für die vorhandenen Spiele sowie Spezialerfolge für SystemEscape,
|
||||||
|
> BugHunter und NetWorker sind verdrahtet. Der Knowledge Core ist als datenbasierte Lernzentrale
|
||||||
|
> mit AP1-, Netzwerk- und Debugging-/Security-Pfaden umgesetzt. Das Accessibility-Widget nutzt
|
||||||
|
> in SystemEscape, BugHunter und NetWorker jeweils den passenden Spielakzent.
|
||||||
|
|
||||||
### 2.1 Kritisch (vor/mit dem nächsten Release beheben)
|
### 2.1 Kritisch (vor/mit dem nächsten Release beheben)
|
||||||
|
|
||||||
@@ -338,7 +473,7 @@ Der Verdacht „Profilbutton fehlt / Einstellungen nicht im Profil / irgendwas i
|
|||||||
|---|-------|----------|
|
|---|-------|----------|
|
||||||
| 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). |
|
| 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). |
|
| 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. |
|
| S3 ✅ | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Der verbindliche Sicherheitsvertrag ist für SystemEscape, BugHunter, ServiceClash und NetWorker umgesetzt: lösungsfreie Sessions, serverseitige Checks sowie serverseitige Score-/Reveal-Auswertung. Weiterführende Negativtests werden bei künftigen Spieltypen ergänzt. |
|
||||||
| 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). |
|
| 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). |
|
| 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). |
|
| S6 ✅ | **Logout per GET** (`SecurityConfig.java:36`) – kleiner Logout-CSRF-Vektor | Auf POST-Logout mit CSRF-Token umstellen (kleines Formular im Sidebar-Footer). |
|
||||||
@@ -349,7 +484,18 @@ Der Verdacht „Profilbutton fehlt / Einstellungen nicht im Profil / irgendwas i
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Teil 3: FluxNet – Community & Soziales
|
## Teil 3: NeuralNet – private Lernverbindungen & Nachrichten ⏳ in Umsetzung
|
||||||
|
|
||||||
|
> ✅ **MVP-Stand 2026-07-14:** Opt-in, Kontaktanfragen (annehmen, ablehnen,
|
||||||
|
> zurückziehen) und private Nachrichten nach bestätigter Verbindung sind umgesetzt.
|
||||||
|
> Leere Erfolgsantworten des API-Backends werden im Client robust verarbeitet.
|
||||||
|
> Der Gesprächsbereich öffnet unmittelbar beim Auswählen einer bestätigten Verbindung.
|
||||||
|
> Die dynamisch gerenderten Kontakt- und Chat-Aktionen werden direkt gebunden.
|
||||||
|
> Der Chatbereich hält Kopf, Verlauf und Eingabe als zusammenhängende Spalte.
|
||||||
|
> Die Dashboard-Sprechblase zeigt im 30-Sekunden-Takt echte ungelesene Nachrichten.
|
||||||
|
> Die zuletzt geöffnete bestätigte Verbindung wird pro Konto lokal wiederhergestellt.
|
||||||
|
> Der Plattform-Lightmode und der Hochkontrastmodus nutzen die Neural-Palette.
|
||||||
|
> Blockieren, Melden/Moderation, Duelle und Lernlobby bleiben die nächsten Ausbauschritte.
|
||||||
|
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
@@ -357,8 +503,8 @@ Der Verdacht „Profilbutton fehlt / Einstellungen nicht im Profil / irgendwas i
|
|||||||
|
|
||||||
| Funktion | Beschreibung |
|
| Funktion | Beschreibung |
|
||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
| **Freundschaften** | Anfrage per Username-Suche senden → Gegenseite bestätigt/lehnt ab. Nur bestätigte Freunde sehen einander im FluxNet. |
|
| **Kontaktanfragen** | Anfrage per Username-Suche senden → Gegenseite nimmt ausdrücklich an oder lehnt ab. Nur bestätigte Verbindungen sehen einander im NeuralNet. Gesendete Anfragen lassen sich zurückziehen. |
|
||||||
| **Interner Messenger** | 1:1-Direktnachrichten zwischen bestätigten Freunden. Ungelesen-Badge am Glocken-Icon der Topbar. Kein Gruppenchat in Phase 1. |
|
| **Privatnachrichten** | 1:1-Direktnachrichten ausschließlich zwischen bestätigten Verbindungen. 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. |
|
| **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. |
|
| **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). |
|
| **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). |
|
||||||
@@ -445,14 +591,14 @@ Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = re
|
|||||||
| **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. |
|
| **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). |
|
| **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. |
|
| **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. |
|
| **Transparenz (Art. 13)** | `datenschutz.html` um den Abschnitt NeuralNet 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. |
|
| **Betroffenenschutz** | Blockieren ist still (kein „X hat dich blockiert"); Melden einer Nachricht legt nur DIESE Nachricht dem Admin vor. |
|
||||||
|
|
||||||
### 3.6 Frontend
|
### 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ü).
|
- `soziales.html` wird zum **NeuralNet-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".
|
- 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).
|
- Neue Dateien: `static/js/neuralnet.js` (IIFE, Polling-Logik, Render-Funktionen), `static/css/neuralnet.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
|
### 3.7 Phasen & Aufwand
|
||||||
|
|
||||||
@@ -466,7 +612,7 @@ Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = re
|
|||||||
|
|
||||||
> ✅ **MVP umgesetzt:** lokaler Zustand, globaler Countdown, Einstellungen, eigener Uhr-mit-Blitz-
|
> ✅ **MVP umgesetzt:** lokaler Zustand, globaler Countdown, Einstellungen, eigener Uhr-mit-Blitz-
|
||||||
> Schnellzugriff in der Topbar, In-App-Overlay, Snooze, Probelauf sowie optionale Browser-
|
> Schnellzugriff in der Topbar, In-App-Overlay, Snooze, Probelauf sowie optionale Browser-
|
||||||
> Benachrichtigung und Web-Audio-Signal. Die Glocke bleibt für FluxNet-/Systembenachrichtigungen
|
> Benachrichtigung und Web-Audio-Signal. Die Glocke bleibt für NeuralNet-/Systembenachrichtigungen
|
||||||
> reserviert. Eine serverseitige Synchronisation bleibt bewusst optional.
|
> 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.
|
> 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.
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Mindflux / SysnaptiK Neural — Gesamt-Audit & Umsetzungsplan
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Vollständiges Audit der Plattform (Spring Boot 3.4.4, Java 21, Thymeleaf, PostgreSQL, Flyway) mit Fokus auf: Light Mode, Barrierefreiheits-Widget, Header-Icons, Spielanleitungen/Hints, AP2-Inhalte in FinalProtocol, Umlaute, DSGVO und Best Practices. Ziel: ein Light Mode, der wie ein „Geschwister" des Dark Mode wirkt (gleiche Farbidentität, aber für hell entworfen statt invertiert), konsistente UI, Hilfen in jedem Spiel, prüfungstauglicher AP2-Umfang für **beide** Fachrichtungen, saubere Umlaute, DSGVO-Konformität.
|
||||||
|
|
||||||
|
**Entscheidungen:** ServiceClash/BugHunter/Networker bekommen **echte Light-Varianten** (nicht dauerhaft dunkel). AP2: **5 Themen je Fachrichtung** à 20 Fragen. Impressum bleibt **strukturierter Platzhalter** (Daten werden vor Launch geliefert).
|
||||||
|
|
||||||
|
**Audit-Kurzantworten:**
|
||||||
|
- **A11y-Widget:** Gut (9 Funktionen, ARIA, Escape, Fokus-Rückgabe), aber nicht perfekt: kein Focus-Trap, falsche `aria-pressed`-Semantik am 4-stufigen Font-Button, toter Legacy-CSS-Code, spielfremde Regeln in `accessibility-placement.css`.
|
||||||
|
- **Spiele ohne Hilfe:** MindMatch + TechTacular haben **weder Anleitung noch Hints**. SyntaxSaga, DevLab, CodeEscape haben Hints, aber keine Anleitung. BugHunter nur Auflösen-Button. Networker ist Vorbild (Guide-Panel + Lexikon).
|
||||||
|
- **AP2:** Nur je 1 Thema / 6 Fragen pro Richtung (vs. ~742 Fragen AP1/Wirtschaft). Der Ordner `systemintegration/` ist AP1-Stoff („Systeme & Netzwerke"), nicht AP2.
|
||||||
|
- **Umlaute:** `static/finalflux/questions/usv.json` und `devlab/content.json` systematisch ohne Umlaute; Einzelfälle in `zahlensysteme.html:78`, `techtacular/content.json:4250`; Mojibake (`für`) in `style.css`-Kommentaren (Z. 19/29/220).
|
||||||
|
- **DSGVO:** Chart.js von jsDelivr-CDN (`progress.html:9`) = einziges externes Asset, widerspricht eigener Datenschutzerklärung. Impressum = Platzhalter. NeuralNet-Messaging fehlt in der Datenschutzerklärung. Kein Datenexport (Art. 20). Footer mit Rechtslinks fehlt auf allen Spielseiten + Error/Auth-Seiten. Demo-User `NeuralTest` wird per Flyway (V17) in Prod eingespielt. Positiv: kein Tracking, keine Cookies außer Session, Secrets sauber, Löschkonzept (Art. 17) vorhanden.
|
||||||
|
- **Best Practices:** `@Valid`/Bean-Validation nirgends genutzt (obwohl Starter + Handler verdrahtet), keine CSP/HSTS, Spring Boot 3.4.4 veraltet, Avatar-Logik im Controller statt Service, Footer 26× dupliziert statt Fragment, `DummyController`/`hello.txt`-Reste. Positiv: kein IDOR, saubere Schichten sonst, 51 Tests, Flyway, Brute-Force-Schutz.
|
||||||
|
|
||||||
|
Basis-Pfad: `RES` = `src\main\resources`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umsetzungsstatus (Stand 2026-07-21, auf `main`)
|
||||||
|
|
||||||
|
- ✅ **AP 0** Quick Wins — vollständig (inkl. V21-Reseed-Migration für Alt-DBs)
|
||||||
|
- ✅ **AP 1** Light Mode — vollständig, alle Kontraste WCAG-AA rechnerisch geprüft. ⚠️ **Nachtrag (2026-07-21):** Glow-Effekte wirken im Light Mode störend → Alternative nötig, s. AP 1e.
|
||||||
|
- ✅ **AP 2** Header-Icons — vollständig
|
||||||
|
- ✅ **AP 3** Barrierefreiheits-Widget — vollständig
|
||||||
|
- ✅ **AP 4** Spielanleitungen & Hints — vollständig (AP 4c TechTacular-Joker war bereits erledigt)
|
||||||
|
- ✅ **AP 5** AP2-Ausbau — vollständig (10 Themen × 20 = 200 Fragen)
|
||||||
|
- ✅ **AP 6** DSGVO — vollständig (Footer auf 35 Seiten, Datenschutz/Impressum, Export, V22)
|
||||||
|
- 🟡 **AP 7** Best Practices — **teilweise**: Security-Header/CSP, AvatarService-Extraktion und Bean-Validation-Start erledigt. **Offen:** Validierung auf alle Request-DTOs + NeuralNet-`Map`→DTO, Spring-Boot-Patch-Bump, Cookie-basiertes CSRF, Web-Layer-Test für `/profile/export`.
|
||||||
|
|
||||||
|
Automatisiert verifiziert: `mvn test` 54/54 grün, Build grün, Umlaut-QA sauber, alle 200 AP2-Fragen strukturell validiert. **Noch nicht durchgeführt (braucht laufende App + DB + Browser):** visuelle Light-Mode-Prüfung aller Spiele hell/dunkel, A11y-Widget-Handtest, Guide-Auto-Open, Export-Download- und DSGVO-Netzwerk-Check; die 8 Vollbild-Spielseiten haben nun ebenfalls einen Footer (ggf. schlanke CSS-Anpassung). Vor Launch offen: echte Impressumsdaten, Support-/Feedback-Zieladresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 0 — Quick Wins (zuerst, alles klein)
|
||||||
|
|
||||||
|
1. **Chart.js lokal vendoren**: nach `RES\static\js\vendor\chart.umd.min.js` laden, `RES\templates\progress.html:9` auf lokalen Pfad umstellen. Schließt das einzige DSGVO-Datenleck.
|
||||||
|
2. **Scaffolding löschen**: `controller/DummyController.java`, `templates/dummy.html`, `RES\static\hello.txt`.
|
||||||
|
3. **Umlaute fixen**:
|
||||||
|
- `RES\static\finalflux\questions\usv.json` — kompletter Durchgang (fuer→für, ueberbrueckt→überbrückt, heisst→heißt, …).
|
||||||
|
- `RES\devlab\content.json` — ~30 Stellen (hints/instructions/successText). **Achtung:** DevLab wird per `DevLabSeeder` in die DB geseedet (existenzgeprüft = idempotent) → prüfen, ob Fixes im JSON bestehende DB-Zeilen erreichen; falls nicht: Seeder auf Upsert umstellen **oder** Flyway-Migration `V21__fix_devlab_umlauts.sql`.
|
||||||
|
- `RES\static\finalflux\ap1\zahlensysteme.html:78` (`fuer`), `RES\techtacular\content.json:4250` (`zusaetzlich`, gleiche Seeder-Prüfung), `RES\static\css\style.css` Z. 19/29/220 (Mojibake-Kommentare).
|
||||||
|
- QA-Grep danach: `fuer|ueber|heisst|koennen|moechte|zurueck|loeschen|zusaetzlich` über resources → nur URLs/Slugs/IDs dürfen übrig bleiben.
|
||||||
|
4. **FinalProtocol-Titel**: `RES\templates\finalflux.html:5` „FinalProtocol AP1" → „FinalProtocol", Hero (Z. 75–83) auf AP1 **und** AP2 umformulieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 1 — Light Mode (Kern des Redesigns)
|
||||||
|
|
||||||
|
Architektur bleibt: Tokens via `data-theme` auf `<html>`, zwei Ebenen (`theme.css` Amber-Palette, `platform-neural.css` Grün-Override für Plattform-Shell). Alle Werte unten sind arithmetisch auf WCAG AA geprüft (≥4,5:1 Text, ≥3:1 UI).
|
||||||
|
|
||||||
|
### 1a. `RES\static\css\theme.css` — Light-Block (Z. 33–54) nachschärfen
|
||||||
|
Kein Token-Split (`--accent-text`), stattdessen Light-Akzente abdunkeln, bis ein Token Text + Grafik + weiße Ink trägt:
|
||||||
|
|
||||||
|
| Token | Alt | Neu | Kontrast |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--accent` | #d65116 | **#c2410c** | 5,18 (auch weiße Ink darauf: 5,18) |
|
||||||
|
| `--accent-2` | #e4801d | **#b45309** | 5,02 (heilt `.login-box h2`) |
|
||||||
|
| `--muted` | #8a7d6c | **#6b5f4c** | 6,24 / 5,79 auf #fbf6ec |
|
||||||
|
| `--secondary` (+`--success`) | #1f86b8 | **#146b96** | 5,88 / 5,46 |
|
||||||
|
| `--secondary-2` | #2d9cce | **#0f5a80** | passt |
|
||||||
|
| NEU `--warn` | — | hell **#8a6a06** / dunkel **#edcb4c** | 5,07 |
|
||||||
|
|
||||||
|
Spiel-Tokens im selben Block, nur die Durchfaller: `--esc-accent`→#a85408, `--sc-accent`→#0e7c86, `--dl-accent`→#9c4d0f, `--ss-secondary`→#8a5710, `--ff-accent`→#8c5e10. Zugehörige `*-glow`-rgba nachziehen.
|
||||||
|
|
||||||
|
### 1b. `RES\static\css\platform-neural.css` — Light-Kartenakzente (der Hauptbug)
|
||||||
|
Z. 41–49 leaken Neon-Akzente auf fast-weiße Karten. Neuer Block `html[data-theme="light"].sn-platform body .mf-card-<name>` mit farbton-treu abgedunkelten Akzenten (alle ≥4,65:1 auf Kartengrund):
|
||||||
|
mindmatch **#a62c74**, techtacular **#67700a**, devlab **#167a52**, syntaxsaga **#6742d6**, codeescape **#8a6a06**, serviceclash **#0e7c86**, bughunter **#c22736**, networker **#1668a8**, finalprotocol **#b5490f** — je mit `--glow-s: rgba(<hex>,.22)` und hellerem `--accent-2` für Grafik-Deko. Dazu Light-Variante des finalflux-Shell-Overrides (Z. 57–60).
|
||||||
|
|
||||||
|
### 1c. Hardcodes
|
||||||
|
`RES\templates\dashboard.html:19–20`: `#edcb4c` → `var(--warn)` + `color-mix(in srgb, var(--warn) 48%, transparent)`.
|
||||||
|
|
||||||
|
### 1d. Echte Light-Varianten für die 3 hardgecodeten Spiele
|
||||||
|
Vorgehen je Spiel: alle Roh-Hexwerte in scoped Custom Properties überführen (`.sc-body{--sc-*}` usw. auf `<body>`-Klasse, damit A11y-Skin-Variablen sie sehen), Dark-Defaults = heutige Werte, Light-Block `[data-theme="light"] .xx-body{…}` mit handentworfener Palette gleicher Farbidentität:
|
||||||
|
|
||||||
|
- **ServiceClash** (`serviceclash.css`, heute bg #071015 / accent #2bb6d7): Light = kühles NOC-Hell: bg **#eef6f8**, surface #ffffff, surface-2 #e2eff2, text **#10333b**, muted #52707a (4,6:1), accent **#0e7c86**, border #cfe3e8. Zusätzlich: `RES\templates\serviceclash.html` bekommt den fehlenden `.mf-theme`-Toggle in den Header (`.sc-nav`).
|
||||||
|
- **Networker** (`networker.css`, dunkelblauer Gradient + fälschlich `var(--text)`): Light = helles Netz-Blau: bg **#eef4fb**, surface #ffffff, text **#16324a**, muted #54708c, accent **#1668a8**, border #d3e2f0. Alle `var(--text)`/`var(--muted)`-Nutzungen auf `--nw-text`/`--nw-muted` umstellen.
|
||||||
|
- **BugHunter** (`bughunter.css`, Holz-Braun #231b18/#3a2d28): Light = Papier/Archiv-Look: bg **#f7f1e8**, surface #fffaf2, Schubladen helles Holz #e8d9c4/#d9c5a8, text **#33261d**, muted #6f5f4e, accent **#c22736**, border #e2d3bd. Die BugHunter/Networker-Regeln aus `accessibility-placement.css:21–23` in die jeweiligen Spiel-CSS verschieben (ohne `!important`).
|
||||||
|
|
||||||
|
**Check:** jeder Spiel-Header, dessen Spiel jetzt theme-fähig ist, braucht den `.mf-theme`-Toggle (theme.js verdrahtet ihn automatisch); mindestens serviceclash.html fehlt er.
|
||||||
|
|
||||||
|
### 1e. Nachtrag: Glow im Light Mode überarbeiten (offen, 2026-07-21)
|
||||||
|
**Befund:** Die aus dem Dark-Mode-Neon-Look übernommenen Glow-Effekte wirken im Light Mode störend (auf fast-weißem Grund lesen sie sich als Schmutz-/Haze-Rand statt als Akzent). Betrifft die durchgereichten Glow-Tokens der Kartenakzente (`--glow-s: rgba(<hex>,.22)`, s. 1b), die spielspezifischen `*-glow`-rgba (s. 1a) und den Glow am `.mf-theme`-Toggle (s. AP 2, `theme.css:120–121`).
|
||||||
|
**Wir brauchen eine Alternative** für den hellen Modus — noch zu entscheiden, z. B. Glow-Alpha im Light-Block deutlich reduzieren/auf 0 setzen, oder den Neon-Glow durch ein dezentes Mittel ersetzen (weicher Schatten / abgesetzter Border / leichter Farbverlauf), das die Erkennungsfunktion behält, ohne den hellen Grund zu trüben. Dark Mode bleibt unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 2 — Header-Icons vereinheitlichen
|
||||||
|
|
||||||
|
Eine Spezifikation für alle: **42×42 px, border-radius 10px, Glyphe 20×20, Outline-Stil** (Surface-Fill, 1px Border, Akzent nur über `color`/`border-color`):
|
||||||
|
1. `theme.css:112` `.mf-iconbtn` → 42×42; `:114` svg → 20×20.
|
||||||
|
2. `theme.css:120–121` `.mf-theme` → 42×42, radius 10px (Ellipse weg), svg 20×20; Akzentfarbe/Glow bleibt als Erkennungsmerkmal.
|
||||||
|
3. `accessibility-placement.css:2–3` Trigger → 42×42, svg 20×20; Z. 16: gefüllten Gradient entfernen → `background:var(--surface); color:var(--mf-a11y-accent); border-color:color-mix(…)`.
|
||||||
|
4. `accessibility.css:26` Legacy-56px-Kreis-Trigger löschen, saubere Basisregel im `.mf-iconbtn`-Look (Fokus-Outline behalten).
|
||||||
|
5. `fluxtimer.css:3` `!important` entfernen; `.ft-trigger-icon` 20×20, `stroke-width:1.5`.
|
||||||
|
6. Sichtprüfung: `.nn-unread`-Badge-Position bei 42px, `.mf-search`-Höhe angleichen. `STYLEGUIDE.md` aktualisieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 3 — Barrierefreiheits-Widget perfektionieren
|
||||||
|
|
||||||
|
In `RES\static\js\accessibility.js` / `accessibility.css`:
|
||||||
|
1. **Focus-Trap** im Panel (Tab/Shift+Tab zyklisch über Close + 9 Options-Buttons), `aria-modal="true"`, Klick-außerhalb schließt. Escape + Fokus-Rückgabe existieren schon.
|
||||||
|
2. **Font-Button**: boolean `aria-pressed` ersetzen durch dynamisches `aria-label` („Textgröße ändern – aktuell 115 %") + `.is-active`-Klasse ab >100 %; `aria-pressed` vom Reset-Button entfernen (Aktion, kein Toggle).
|
||||||
|
3. **Toten Dock-CSS löschen** (`accessibility.css:25` body-padding-72px, `.mf-a11y-dock`, Fixed-Trigger) und die dadurch obsoleten Resets in `accessibility-placement.css` einkürzen.
|
||||||
|
4. **Hochkontrast bleibt bewusst dunkel** auch im Light Mode (maximaler Kontrast, deterministisch) — im Button-Untertitel kenntlich machen („immer dunkel") + CSS-Kommentar.
|
||||||
|
5. Cache-Buster bumpen (theme.js: accessibility.css v6, placement v10, accessibility.js v9, fluxtimer.css v5; theme.css v4 in allen Templates).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 4 — Spielanleitungen & Hints
|
||||||
|
|
||||||
|
### 4a. Gemeinsames Guide-Modul „So funktioniert's"
|
||||||
|
Neu: `RES\static\js\game-guide.js` + `RES\static\css\game-guide.css`, nach Vorbild `networker-guide.js` (IIFE, deutsches Inline-Registry als Template-Literals, kein Fetch). Injektion über `theme.js` nur auf Game-Routen (Liste existiert dort in `isGameRoute`, Z. 15) — null Template-Edits, wie FluxTimer.
|
||||||
|
- „?"-Button (`aria-label="Spielanleitung öffnen"`) in die jeweilige Header-Tools-Leiste; Erstbesuch öffnet Overlay automatisch (localStorage `mf-guide-seen:<slug>`), danach per „?" reopenbar; Escape/Backdrop schließt.
|
||||||
|
- API `window.MindfluxGuide = {open, close, registerHintProvider({label, remaining, onHint})}` für einheitliche Tipp-Buttons.
|
||||||
|
- Rollout: MindMatch, TechTacular, SyntaxSaga, DevLab-Game, CodeEscape, BugHunter, ServiceClash (kondensiert, Intro-Screen bleibt). **Networker bleibt unangetastet** (Inline-Guide ist stärker).
|
||||||
|
|
||||||
|
### 4b. MindMatch-Hint „Paar-Blitz"
|
||||||
|
Button „Tipp (3)" in `.mm-bar-group` (`mindmatch.html` ~Z. 35): deckt ein ungematchtes Paar ~1 s auf (wenn `firstCard` liegt: dessen Partner), kostet **+1 Zug** → Score/Sterne degradieren über die bestehende Formel, **null Backend-Änderungen** (Server rechnet aus `moves`). Hooks in `mindmatch.js`: State Z. 27–35, `newGame` Z. 241–265 (Reset auf 3), `finish`/`cancelGame` verstecken. CSS `.mm-card.hinted`-Glow.
|
||||||
|
|
||||||
|
### 4c. TechTacular „50:50-Joker" ✅ umgesetzt (2026-07-19)
|
||||||
|
`GET /api/techtacular/hint?questionId=` liefert ausschließlich bis zu zwei falsche Antwort-IDs; die korrekte ID verlässt den Service nie. Der Joker ist serverseitig pro Runde auf einen Einsatz begrenzt, markiert die konkrete Frage in der Session und wird beim Laden eines neuen Boards bzw. nach dem Abschluss zurückgesetzt. Im Antwort-Overlay werden die entfernten Optionen deaktiviert und durchgestrichen. `hintUsed` ist Teil der Answer-/Finish-DTOs; die Halbierung wird jedoch nur gewertet, wenn die Session den Joker-Einsatz für genau diese Frage bestätigt. Ein Endpoint-Test sichert JSON-Form und Weiterleitung ab.
|
||||||
|
|
||||||
|
### 4d. Optional (Phase 2): BugHunter progressive Hinweise (2 Stufen vor „Auflösen", nach CodeEscape-Vorbild).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 5 — FinalProtocol: AP2-Ausbau (5 Themen je Richtung, je 20 Fragen)
|
||||||
|
|
||||||
|
Skelett exakt von `RES\static\finalflux\ap2-fisi\hochverfuegbarkeit.html` übernehmen (Lernfokus mit 3 Zielen, 2–4 Inhaltssektionen mit `key-list` + Merksätzen, Prüfungsmodus-Block verbatim, `finalflux-test.js` mit `data-questions-src`). JSON-Schema wie bestehende Dateien: `{category, hint, text, answers[3], correct (rotierend), explanation (nennt auch die Distraktor-Fallen)}`, 4–6 Kategorien pro Datei, „du"-Ansprache, **echte Umlaute (UTF-8 ohne BOM)**.
|
||||||
|
|
||||||
|
**AP2 Systemintegration** (`RES\static\finalflux\ap2-fisi\` + `questions\`):
|
||||||
|
| Seite | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| netzwerkplanung | Subnetting/VLSM, IPv6, WLAN-Planung, Routing |
|
||||||
|
| it-sicherheit | Firewall/DMZ, VPN (IPsec/TLS), sym./asym. Verschlüsselung, Zertifikate, CIA, 2FA |
|
||||||
|
| virtualisierung | Typ-1/2-Hypervisor, Container vs. VM, Snapshots, IaaS/PaaS/SaaS |
|
||||||
|
| monitoring-backup | SNMP/Schwellwerte, 3-2-1-Regel, Generationenprinzip, Wiederherstellungs-Szenarien, Serverdimensionierung |
|
||||||
|
| skripting | Shell/PowerShell-Konstrukte, Pseudocode, Cron/Aufgabenplanung, Exit-Codes |
|
||||||
|
|
||||||
|
**AP2 Anwendungsentwicklung** (`ap2-fiae\`): **uml** (Klassen-/Sequenz-/Aktivitätsdiagramm, Multiplizitäten), **sql** (Joins, GROUP BY/HAVING, DDL/DML, Normalisierung), **oop** (Kapselung, Vererbung vs. Komposition, Polymorphie, Interfaces), **algorithmen** (Pseudocode/Struktogramm, Sortieren/Suchen, O-Notation, Rekursion), **testverfahren** (Blackbox/Whitebox, Äquivalenzklassen/Grenzwerte, Testebenen; + Kategorie REST/JSON/XML).
|
||||||
|
|
||||||
|
→ je Richtung ~106 Fragen inkl. Bestand. Navigation: `RES\templates\finalflux.html` Z. 62–70 um je 5 Links erweitern; optional „AP2-Kompass"-Panel neben dem AP1-Kompass. `systemintegration/`-Sektion bleibt als AP1 „Systeme & Netzwerke" unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 6 — DSGVO
|
||||||
|
|
||||||
|
1. **Footer-Fragment rollout**: `fragments/layout.html:87–94` (`th:fragment="footer"`) existiert, wird 0× genutzt → alle ~26 Inline-Footer durch `th:replace="~{fragments/layout :: footer}"` ersetzen **und** auf die 7 Spielseiten + `error.html` + `forgot-password.html`/`reset-password.html` bringen (Spielseiten ggf. schlanke CSS-Anpassung, damit der Look nicht bricht). Rechtslinks damit von jeder Seite erreichbar.
|
||||||
|
2. **Datenschutzerklärung vervollständigen** (`templates/legal/datenschutz.html`): NeuralNet-Messaging als Verarbeitungstätigkeit (Inhalte, Sender/Empfänger, Zeitstempel, Lesebestätigungen, Löschung via Kaskade), E-Mail-Token (SHA-256, 24 h TTL) + SMTP-Versand über `mail.sysnaptik.de` als Empfänger, konkrete Speicherfristen (Konto, Logs, Login-Versuche, Nachrichten), Drittland-Aussage nach CDN-Entfernung wieder korrekt.
|
||||||
|
3. **Impressum strukturieren** (`templates/impressum.html`): saubere Struktur mit klar markierten Pflichtfeldern (Name, Anschrift, E-Mail, Vertretung) als auffällige Platzhalter — werden vor Launch ausgefüllt.
|
||||||
|
4. **Support/Feedback-Links** (`fragments/layout.html:88–89`, `href="#"`): auf `mailto:`-Platzhalter konsistent zum Impressum umstellen oder bis dahin entfernen (Betroffenenrechte brauchen einen Kanal).
|
||||||
|
5. **Datenexport Art. 20**: `GET /profile/export` in `ProfileController` → Service sammelt User-Stammdaten, Scores, Achievements, Fortschritt, NeuralNet-Daten → JSON-Download. Test ergänzen.
|
||||||
|
6. **Demo-User `NeuralTest` entschärfen**: neue Migration `V22__remove_neuraltest_demo_user.sql` (Löschen, Kaskaden räumen auf); falls für Dev nötig, als profile-gebundenen Runner (`@Profile("dev")`) neu anlegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP 7 — Best Practices
|
||||||
|
|
||||||
|
1. **Bean Validation aktivieren**: `@Valid` + `@NotBlank/@Size/@Email` auf Request-DTOs (`ScoreSubmission`, BugHunter/Techtacular-Requests); die rohen `Map<String,String>`-Bodies in `NeuralNetApiController` durch typisierte DTOs ersetzen. Handler (`ApiExceptionHandler:45–51`) ist schon verdrahtet.
|
||||||
|
2. **Security-Header** (`SecurityConfig.java`): `.headers()`-Block mit CSP (nach Chart.js-Vendoring machbar; pragmatisch starten mit `default-src 'self'` + `style-src 'self' 'unsafe-inline'` wegen Inline-Styles in Templates), HSTS (nur Prod), Referrer-Policy.
|
||||||
|
3. **Spring Boot bumpen**: 3.4.4 → aktuellstes 3.4.x-Patch (`pom.xml:11`), Testlauf.
|
||||||
|
4. **Avatar-Logik aus `ProfileController` (Z. 115–172) in einen `AvatarService`** extrahieren (Bild-Dekodierung/Skalierung/Datei-I/O, Pfad-Logik heute 3× dupliziert).
|
||||||
|
5. **CSRF** (niedrige Prio): Blanket-Exemption für `/api/**` (SecurityConfig:42–46) durch Cookie-basierten CSRF-Token für JSON ersetzen.
|
||||||
|
6. **Tests**: Web-Layer-Tests für neue Endpoints (Techtacular-Hint, Profil-Export).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
1. AP 0 Quick Wins → 2. AP 1 Light Mode (Tokens → Kartenakzente → 3 Spiele) → 3. AP 2 Icons → 4. AP 3 Widget → 5. AP 4 Guides/Hints → 6. AP 5 AP2-Content (parallelisierbar zu 2–5) → 7. AP 6 DSGVO → 8. AP 7 Best Practices → 9. Gesamtverifikation.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- **Build/Run**: `mvn spring-boot:run` in `mindflux2` (oder `docker-compose up`); `mvn test` muss grün bleiben.
|
||||||
|
- **Light Mode visuell**: Screenshots hell **und** dunkel: Dashboard (Kartengrid + `.mf-play`-Hover), Login, Settings, Progress, Soziales und **alle 9 Spiele** (insb. ServiceClash/Networker/BugHunter in beiden Themes); Hochkontrast- und Farbenblind-Modus je einmal gegenprüfen.
|
||||||
|
- **Icons**: Topbar Dashboard + ein Spiel-Header, beide Themes; Badge-Positionen.
|
||||||
|
- **Widget manuell**: Tab-Zyklus im Panel, Escape, Fokus-Rückgabe, Screenreader-Label des Font-Buttons.
|
||||||
|
- **Guides**: `localStorage` `mf-guide-seen:*` löschen → jedes Spiel: Auto-Open einmalig, „?" reopenbar, `/networker` unverändert. MindMatch: Tipp zählt Zug hoch, Server-Score stimmt. TechTacular: Joker eliminiert Falsche, halbe Punkte bei `hintUsed`, `/hint` leakt nie die richtige ID.
|
||||||
|
- **AP2**: alle 10 neuen Links erreichbar, je ein kompletter Quiz-Durchlauf (antworten, überspringen, neu starten, Kategorien-Summary), JSON-Validierung (`python -m json.tool`), `0 <= correct < answers.length`.
|
||||||
|
- **Umlaut-QA**: Grep-Muster (s. AP 0) ohne Treffer in sichtbaren Texten; Browser-Darstellung stichprobenartig.
|
||||||
|
- **DSGVO**: Netzwerk-Tab auf `/progress` → keine externen Requests; Footer-Links auf jeder Seite; Export-Download enthält alle Datenkategorien; `NeuralTest` nach Migration weg.
|
||||||
|
|
||||||
|
## Offene Punkte (vor Launch)
|
||||||
|
|
||||||
|
- Impressumsdaten (Name, Anschrift, E-Mail) liefern → Impressum + §1 Datenschutzerklärung.
|
||||||
|
- Support-/Feedback-Zieladresse bestätigen.
|
||||||
|
- Gesamtumfang ist groß (v. a. AP 5 Content-Arbeit) — bei Bedarf über mehrere Sitzungen umsetzen; die Arbeitspakete sind unabhängig genug.
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Mindflux — Refine 2: Light-Mode-Konzept, Scroll-Verhalten, Restpunkte
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Folgeplan zu `mindfluxrefine.md`. Die Code-Verifikation (2026-07-21) bestätigt: **alle „vollständig"-Claims aus Refine 1 halten der Prüfung stand**; offen sind AP 1e (Glow im Light Mode), der AP-7-Rest sowie einige Zusatzfunde (s. AP R5). Dieser Plan liefert das, was Refine 1 offen ließ: ein **echtes Light-Mode-Konzept**, das alle Probleme an der Wurzel behebt (statt Glow-Alphas weiter zu dimmen), plus das neue Thema **Scroll-Verhalten** (Ergebnisanzeigen u. ä. müssen oben starten, nicht unten).
|
||||||
|
|
||||||
|
**Audit-Kurzbefunde (2026-07-21, drei unabhängige Durchgänge):**
|
||||||
|
|
||||||
|
- **Light-Mode-Strukturfehler:** Der Light-Block in `theme.css` tauscht nur *Farbwerte/Alphas* der Glow-Tokens. Die *Geometrie* der Effekte (`box-shadow:0 0 20px …`, `text-shadow:0 0 12px …`, `drop-shadow(0 0 8px …)`) steht hart in ~70 Komponentenregeln quer durch `theme.css`, alle Spiele-CSS und Template-`<style>`-Blöcke. Ein Halo mit reduziertem Alpha bleibt ein Halo — auf Fast-Weiß liest er sich als Schmutz/Haze. Einzige strukturell adaptierte Stelle heute: `--g-soft` (`0 0 18px` dark → `0 6px 18px` light, `theme.css:31/53`). Genau **eine** Regel neutralisiert einen Glow explizit im Light Mode (`auth.css:127–128`).
|
||||||
|
- **5 im Light Mode kaputte Stellen:** Login-Overlay weiß-auf-weiß (auth.css:123–127 × platform-neural.css:94), SyntaxSaga-Bühne bleibt schwarz (`syntaxsaga.css:250–251`), CodeEscape-Eingaben dunkel-auf-dunkel (`codeescape.css` `.esc-input` `#080604`), NetWorker-Guide-Panel dunkelnavy mit Light-`--muted` (`networker-guide.css`), Farbenblind-Palette × Light = Hellgelb/Hellblau auf Weiß (`accessibility.css:15–23`, `!important` schlägt die Light-Kartenakzente).
|
||||||
|
- **Theme-Bootstrap-Lücken:** `color-scheme` fehlt komplett (native Scrollbars/Form-Controls folgen dem UA-Default), kein `theme-color`-Meta, kein `prefers-color-scheme`-Fallback (Erstbesucher mit OS-Light starten dunkel), alle Templates hardcoden `data-theme="dark"`.
|
||||||
|
- **Scroll:** Im gesamten Projekt existiert **kein einziger** `scrollTo`/`scrollIntoView`/`scrollRestoration`-Aufruf (außer Chat-Autoscroll `neuralnet.js:84`, der korrekt ist). Die vier SPA-Spiele (BugHunter, CodeEscape, NetWorker, ServiceClash) ersetzen Views per `innerHTML` ohne Reset; der FinalProtocol-Quiz kollabiert am Ende einer langen Lernseite (Browser clampt ans neue Seitenende); SyntaxSaga/DevLab fokussieren Inputs ohne `preventScroll`.
|
||||||
|
- **CSS-Leichen:** `achievements.css`, `progress.css`, `settings.css`, `soziales.css` werden nirgends mehr referenziert (repo-weiter Grep leer); `style.css` wird nur noch von `finalflux.html` geladen, ~90 % davon ist toter Dark-Code.
|
||||||
|
|
||||||
|
Basis-Pfad: `RES` = `src\main\resources`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Konzept: „Licht statt Leuchten"
|
||||||
|
|
||||||
|
**Prinzip:** Dark Mode und Light Mode sind Geschwister mit derselben Farbidentität, aber unterschiedlicher *Physik*:
|
||||||
|
|
||||||
|
| | Dark Mode | Light Mode |
|
||||||
|
|---|---|---|
|
||||||
|
| Metapher | Neon in der Nacht — Elemente **emittieren** Licht | Druck/Material am Tag — Elemente **werfen Schatten** |
|
||||||
|
| Betonung | farbiger Glow (Halo) | **Elevation** (weicher, warm getönter neutraler Schatten) |
|
||||||
|
| Identität/Wiedererkennung | Glow-Farbe | **Akzentfarbe direkt am Element** (Border, Text, Icon) — ist durch `--border-accent`, Icon-Farben und Kartenakzente bereits überall vorhanden |
|
||||||
|
| Text-Halos | ja (`text-shadow`) | **nie** (liest sich als Unschärfe) |
|
||||||
|
| Fokus-Ringe | farbig, weich | farbig, **crisp** (Ring statt Blur — funktional, bleibt farbig) |
|
||||||
|
| Scrims/Overlays | dunkel | dunkel (funktional), aber leicht entschärft |
|
||||||
|
|
||||||
|
**Mechanik:** Effekte werden zu **Primitives** — komplette `box-shadow`/`filter`/`text-shadow`-*Werte* als Tokens, nicht nur Farben. Komponenten schreiben nie mehr `0 0 20px var(--glow-s)`, sondern `var(--g-strong)`. Der Light-Block tauscht dann die *Kategorie* des Effekts (Glow → Elevation), nicht nur die Farbe. Dark bleibt visuell gleich (Blur-Radien werden auf die Primitive normalisiert; ±4 px sind nicht unterscheidbar). `--glow-s`/`--glow-w` bleiben als Farb-Tokens bestehen — die Spiele-CSS remappen sie bereits per `color-mix` auf ihre Akzente, und die Primitives referenzieren sie per `var()`, sodass Spiel-Glows im Dark Mode automatisch die Spielfarbe behalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R0 — Aufräumen (erledigt 2026-07-22)
|
||||||
|
|
||||||
|
1. **Erledigt:** Verwaiste CSS gelöscht: `achievements.css`, `progress.css`, `settings.css`, `soziales.css`. Die Referenzprüfung ergab 0 Treffer; STYLEGUIDE bereinigt.
|
||||||
|
2. **Erledigt:** `style.css` auf eine vierzeilige Basis für die 40 statischen
|
||||||
|
FinalProtocol-Lernseiten reduziert; FinalProtocol-Hub lädt sie nicht mehr.
|
||||||
|
3. **Erledigt:** Nicht eingebundenes `finalflux-check.js` entfernt. `fluxtimer.css` bleibt separat offen, da die Datei in diesem Paket nicht geändert wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R1 — Effekt-Primitives (Kern des Light-Konzepts)
|
||||||
|
|
||||||
|
### R1a. Tokens in `RES\static\css\theme.css` — erledigt 2026-07-22
|
||||||
|
|
||||||
|
In den Dark-Block (Z. 11–32) und Light-Block (Z. 33–54) ergänzen. Schattenfarbe Light ist **warmes Braun** `rgb(67,48,26)` (zur Creme-Palette passend, kein totes Grau/Schwarz):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root, [data-theme="dark"] {
|
||||||
|
/* … bestehende Tokens … */
|
||||||
|
color-scheme:dark;
|
||||||
|
--g-text:0 0 12px var(--glow-s); /* Text-Halo: Logo, Headline-Akzent, *-brand b */
|
||||||
|
--g-icon:drop-shadow(0 0 8px var(--glow-s)); /* leuchtende Glyphen/Embleme */
|
||||||
|
--g-strong:0 0 20px var(--glow-s); /* starke Betonung: aktive Nav/Tabs, Primär-Btn */
|
||||||
|
--g-amb:0 0 14px var(--glow-w); /* dezenter Schein: Icon-Hover, Toggle, Burger */
|
||||||
|
--g-card:drop-shadow(0 0 7px var(--glow-w)); /* Spielkarten-Grundzustand */
|
||||||
|
--g-card-hover:drop-shadow(0 0 16px var(--glow-s));
|
||||||
|
--g-overlay:0 24px 60px rgba(0,0,0,.5); /* schwebende Panels: Guide, Drawer, A11y */
|
||||||
|
--g-focus:0 0 0 3px var(--glow-w); /* Fokus-Ring (bleibt in beiden Themes farbig) */
|
||||||
|
--scroll-thumb:var(--line);
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
/* … bestehende Tokens … */
|
||||||
|
color-scheme:light;
|
||||||
|
--bg-glow: radial-gradient(900px 420px at 60% -10%, rgba(214,81,22,.05), transparent 60%); /* statt .10 — Haze halbieren */
|
||||||
|
--g-text:none;
|
||||||
|
--g-icon:none;
|
||||||
|
--g-strong:0 2px 10px rgba(67,48,26,.18), 0 1px 3px rgba(67,48,26,.10);
|
||||||
|
--g-amb:0 1px 5px rgba(67,48,26,.10);
|
||||||
|
--g-card:drop-shadow(0 2px 5px rgba(67,48,26,.10));
|
||||||
|
--g-card-hover:drop-shadow(0 8px 18px rgba(67,48,26,.16));
|
||||||
|
--g-overlay:0 18px 44px rgba(67,48,26,.20);
|
||||||
|
--g-focus:0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
--scroll-thumb:#d9cbb2; /* --line #f1e8d8 ist auf #fbf6ec unsichtbar */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Scrollbar-Regel `theme.css:64` auf `background:var(--scroll-thumb)` umstellen.
|
||||||
|
|
||||||
|
### R1b. Mechanische Migration in `theme.css`
|
||||||
|
|
||||||
|
| Zeile | Selektor | Alt | Neu |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 79 | `.mf-logo .mark` | `drop-shadow(0 0 8px var(--glow-s))` | `filter:var(--g-icon)` |
|
||||||
|
| 80 | `.mf-logo .txt b` | `text-shadow:0 0 12px var(--glow-s)` | `text-shadow:var(--g-text)` |
|
||||||
|
| 87 | `.mf-nav a.active` | `0 0 20px var(--glow-s)` | `box-shadow:var(--g-strong)` |
|
||||||
|
| 94 | `.mf-avatar` | `0 0 14px var(--glow-s)` | `box-shadow:var(--g-strong)` (gleiche Farbstärke-Familie wie heute) |
|
||||||
|
| 102/104 | `.mf-streak .fire` / `.mf-brain` | drop-shadows | `filter:var(--g-icon)` |
|
||||||
|
| 109 | `.mf-hello h1 .nm` | `text-shadow:0 0 16px var(--glow-s)` | `text-shadow:var(--g-text)` |
|
||||||
|
| 113/120/202 | iconbtn-Hover / `.mf-theme` / `.mf-burger` | `0 0 14px var(--glow-w)` | `box-shadow:var(--g-amb)` |
|
||||||
|
| 128/130 | `.mf-card` / `:hover` | drop-shadow 7/16px | `filter:var(--g-card)` / `var(--g-card-hover)` |
|
||||||
|
| 131 | `.mf-card .ico` | drop-shadow | `filter:var(--g-icon)` |
|
||||||
|
| 136 | `.mf-card:hover .mf-play` | `0 0 16px var(--glow-s)` | `box-shadow:var(--g-strong)` |
|
||||||
|
| 146 | `.mf-panel.accent` | `0 0 20px var(--glow-w)` | `box-shadow:var(--g-amb)` |
|
||||||
|
| 151 | `.mf-bar > i` | `0 0 10px var(--glow-s)` | eigenes Primitive `--g-bar` (dark `0 0 10px var(--glow-s)` / light `none`) — der Balken trägt seine Farbe selbst, im Light braucht er keinen Hof |
|
||||||
|
| 154 | `.mf-btn` | `0 0 18px var(--glow-s)` | `box-shadow:var(--g-strong)` |
|
||||||
|
| 168 | Input-Fokus | `0 0 0 3px var(--glow-w)` | `box-shadow:var(--g-focus)` |
|
||||||
|
| 182 | `.mf-cardbox` | `0 0 30px var(--glow-w)` | Hero-Aura: Zweizeiler — Basis `box-shadow:var(--g-strong)`, plus `[data-theme="dark"] .mf-cardbox{box-shadow:0 0 30px var(--glow-w)}` |
|
||||||
|
| 223 | Mobile `.mf-sidebar` | `0 18px 50px rgba(0,0,0,.55)` | `box-shadow:var(--g-overlay)` |
|
||||||
|
| 238 | `.mf-tab.active` | `0 0 18px var(--glow-s)` | `box-shadow:var(--g-strong)` |
|
||||||
|
|
||||||
|
### R1c. `platform-neural.css`
|
||||||
|
|
||||||
|
Hat als einzige Datei schon gute Struktur-Overrides (Z. 91 ersetzt Schwarz- durch Grünschatten — Vorbild). Nachziehen: Z. 87 Logo-Drop-Shadow im Light auf `filter:none`; Z. 90 äußeren `0 0 13px`-Glow der aktiven Nav im Light streichen (Inset-Ring reicht). Die `--glow-s`-Kartenwerte (Z. 41–49/99–107) bleiben — sie speisen nach R1b nur noch Dark-Effekte.
|
||||||
|
|
||||||
|
### R1d. Spiele-CSS + Guide/Widget-CSS migrieren
|
||||||
|
|
||||||
|
Gleiches Muster in allen Dateien; die Spiel-`--glow-s`-Remaps (z. B. `mindmatch.css:364`) bleiben unverändert und speisen die Primitives automatisch mit der Spielfarbe. Betroffene Stellen (Audit-Inventar):
|
||||||
|
|
||||||
|
- **Text-Halos** → `text-shadow:var(--g-text)`: `syntaxsaga.css:40`, `mindmatch.css:53`, `techtacular.css:48`, `devlab.css` (`.dl-tube span`), `devlabgame.css` (Header-Titel), `codeescape.css` (`.esc-brand b`, `.esc-result .score`), `bughunter.css` (`.bh-brand b`, Stamp), `networker.css` (`.nw-brand b`, `.nw-final-score`), `serviceclash.css` (`.sc-brand b`, `.sc-score`), `auth.css:74` (`.auth-head b`), `style.css:89` (falls nach R0 noch vorhanden), Template-`<style>`: `progress.html:14` (Prozentzahl).
|
||||||
|
- **Box-Glows** → `var(--g-strong)`/`var(--g-amb)` nach Gewicht: `syntaxsaga.css:37/70/100/125/136/200/229/233`, `mindmatch.css:40/106/226/237/280/287/299/324`, `techtacular.css:39/75/174/207/299`, `devlab.css:102/158/173`, `devlabgame.css:10/12/23/49/61/70/78/106/108`, `codeescape.css` (Buttons/Dialog/Hero — Hero-`0 0 50px` als Dark-only-Zweizeiler wie `.mf-cardbox`), `codeescape-guide.css` (`.esc-how .active`, `.esc-door.ready`, `.esc-input.bad`), `bughunter.css`, `networker.css`, `serviceclash.css` (`.sc-meter i`), `fluxtimer.css:6/7`, `accessibility.css:29`, `accessibility-placement.css:2/16`, `auth.css:14/34/88/98` (`.neon-btn` inkl.: den `::before`-Conic-Blur-Rotor Z. 95/99 im Light per `display:none`).
|
||||||
|
- **Fokus-Ringe** → `var(--g-focus)`: `syntaxsaga.css:160`, `devlabgame.css:35/39`, CodeEscape-Inputs.
|
||||||
|
- **Schwarz-Schatten** → `var(--g-overlay)` bzw. warm getönt: `game-guide.css:72`, `accessibility.css:30`, `accessibility-placement.css:17`, `fluxtimer.css:8` (+ Z. 14 `inset`-Schwarz auf hellem Display im Light entfernen), `serviceclash.css` (`.sc-card`), `bughunter.css` (Drawer, dort auch die im Light unsichtbaren `rgba(255,255,255,.09–.16)`-Inset-Highlights auf Light-Varianten umstellen).
|
||||||
|
- **Scrims** (funktional, bleiben dunkel, aber vereinheitlichen): `mindmatch.css:309`, `techtacular.css:192`, `syntaxsaga.css:216`, `devlabgame.css:104` — im Light auf das Muster `game-guide.css:138` (`rgba(15,23,42,.5)`) wechseln, damit sich nicht zusätzlich der warme `--bg-glow`-Radial über den Scrim legt (`var(--bg-glow)` aus den Scrim-Deklarationen entfernen).
|
||||||
|
- **Hint-Glow** `game-guide.css:155/160–161` (`#ffd54a`): Light-Override `[data-theme="light"] .mm-card.hinted { box-shadow:0 0 0 2px #8a6a06, 0 4px 12px rgba(67,48,26,.18); filter:none; }` (Warn-Ton aus dem Light-Block).
|
||||||
|
|
||||||
|
### R1e. Chart/Dashboard-`<style>`-Blöcke
|
||||||
|
|
||||||
|
`dashboard.html:14/16/18/20/29/31/43/46/171`, `achievements.html:12–16`, `admin/dashboard.html:17–19`, `impressum.html:12`, `profile.html:11`: alle `text-shadow`/`drop-shadow`/`0 0 Npx`-Vorkommen auf die Primitives umstellen (Suchmuster: `grep -n "glow\|text-shadow\|drop-shadow" RES\templates -r`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R2 — Kaputte Light-Stellen (kritische Punkte erledigt 2026-07-22)
|
||||||
|
|
||||||
|
### R2a. Login/Registrierung: Overlay weiß-auf-weiß (kritisch)
|
||||||
|
|
||||||
|
Ursache: `auth.css:123–127` setzt für Light `#fff`-Text/weiße Ghost-Border (Annahme: kräftiges Orange-Panel), aber auf der Plattform übersteuert `platform-neural.css:94` den Overlay-Grund mit Fast-Weiß. Fix in `platform-neural.css` (Light-Sektion, höhere Spezifität durch `.sn-platform` gewinnt gegen auth.css):
|
||||||
|
|
||||||
|
```css
|
||||||
|
html[data-theme="light"].sn-platform body .overlay-panel h3 { color:#10291f; text-shadow:none; }
|
||||||
|
html[data-theme="light"].sn-platform body .overlay-panel p { color:#3c554a; }
|
||||||
|
html[data-theme="light"].sn-platform body .ghost-btn { color:#167a52; border-color:rgba(22,122,82,.55); }
|
||||||
|
html[data-theme="light"].sn-platform body .ghost-btn:hover { background:rgba(22,122,82,.10); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### R2b. SyntaxSaga: Bühne bleibt schwarz (kritisch)
|
||||||
|
|
||||||
|
`syntaxsaga.css:250–251` hardcodet `#07100b` + grüne Scanlines. Light-Block ergänzen (Palette: helles Bühnen-Grün zur Light-`--ss-accent` `#277d49`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="light"] body.ss-body {
|
||||||
|
background:radial-gradient(1100px 500px at 50% -10%, rgba(39,125,73,.08), transparent 60%), #eef6f0;
|
||||||
|
}
|
||||||
|
[data-theme="light"] body.ss-body::before {
|
||||||
|
background:repeating-linear-gradient(0deg, rgba(16,64,40,.05) 0 1px, transparent 1px 4px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### R2c. CodeEscape: Terminal-Eingaben & Dark-Inks (kritisch)
|
||||||
|
|
||||||
|
- `.esc-input, .esc-select` (`#080604`): Light-Override `[data-theme="light"] .esc-input, [data-theme="light"] .esc-select { background:#fffaf0; color:var(--text); border-color:var(--border); }` — Dark behält den Terminal-Look.
|
||||||
|
- Hardcodierte Dark-Inks auf Akzentflächen durch das Token ersetzen (funktioniert in beiden Themes): `.esc-btn:hover`/`.primary` `#160d00` → `var(--accent-ink)`; `codeescape-guide.css` `.esc-fragment small` `#160d00` → `var(--accent-ink)`; `.esc-how .done b` `#06120a` → `var(--accent-ink)`.
|
||||||
|
- Tote Scanlines `rgba(255,255,255,.018)` (`codeescape.css`, `theme.css:138`): im Light-Override auf `rgba(67,48,26,.04)` drehen oder streichen.
|
||||||
|
|
||||||
|
### R2d. NetWorker-Guide: dunkles Panel (kritisch)
|
||||||
|
|
||||||
|
`networker-guide.css` (`.nw-guide` `color-mix(#0b2540 74%, var(--surface))`): Light-Block ergänzen:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="light"] .nw-guide { background:var(--surface); border-color:#d3e2f0; box-shadow:var(--g-soft); }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Textfarben laufen dort über `var(--muted)`/`var(--text)` und stimmen dann automatisch.)
|
||||||
|
|
||||||
|
### R2e. Farbenblind-Palette × Light (kritisch)
|
||||||
|
|
||||||
|
`accessibility.css:15–23` erzwingt helle Okabe-Ito-Akzente per `!important` — auf weißen Karten < 1.5:1. Neuen Satz `html[data-theme="light"][data-mf-color="safe"]` mit **abgedunkelten** Okabe-Ito-Entsprechungen ergänzen (gleiche Zuordnung Spiel→Farbfamilie wie im Dark-Satz, Werte rechnerisch ≥ 4.5:1 auf Weiß, beim Umsetzen mit Kontrast-Tool gegenprüfen):
|
||||||
|
|
||||||
|
| Okabe-Ito (dark) | Light-safe |
|
||||||
|
|---|---|
|
||||||
|
| `#e69f00` Orange | `#8a5f00` |
|
||||||
|
| `#56b4e9` Himmelblau | `#19607f` |
|
||||||
|
| `#009e73` Grün | `#007054` |
|
||||||
|
| `#f0e442` Gelb | `#7a6a00` |
|
||||||
|
| `#0072b2` Blau | `#0072b2` (bleibt) |
|
||||||
|
| `#d55e00` Vermillion | `#a94500` |
|
||||||
|
| `#cc79a7` Purpur | `#9c4f79` |
|
||||||
|
|
||||||
|
Die zugehörigen Glow-Alphas entfallen im Light automatisch über die Primitives (R1).
|
||||||
|
|
||||||
|
### R2f. Kleinere Light-Fixes
|
||||||
|
|
||||||
|
1. **Cancel-Hover** `mindmatch.css:128–129`, `techtacular.css:94` (`#fff` auf Blassrosa): Light-Override `[data-theme="light"] .mm-cancel:hover, …` → `background:var(--danger); color:#fff;` (Light-Danger `#be123c`, 7:1). Dark bleibt unverändert.
|
||||||
|
2. **A11y-Widget-Inks:** `accessibility-placement.css:7–15` hardcodet dunkle `--mf-a11y-ink`-Werte — im Light sind die Akzente selbst dunkel. Eine Regel ergänzt: `html[data-theme="light"] body { --mf-a11y-ink:#fff; }` (gewinnt per Spezifität gegen die Skin-Zuweisungen). Panel-Kopf optional angleichen: `[data-theme="light"] .mf-a11y-head { background:var(--surface-2); color:var(--heading); }`.
|
||||||
|
3. **finalflux-Altlast:** `finalflux.css:345` (`#0b0d1b`-Hardcode) funktioniert nur über die fragile `background:transparent`-Kaskade aus `platform-neural.css:61`. Hardcode durch `var(--bg)`-basierte Deklaration ersetzen, damit die Seite auch ohne `sn-platform` beide Themes überlebt.
|
||||||
|
|
||||||
|
**Erledigt:** Login-Overlay, SyntaxSaga-Bühne, CodeEscape-Eingaben und Akzent-Inks,
|
||||||
|
NetWorker-Guide, Farbenblind-Palette für Light Mode, A11y-Widget-Inks sowie der
|
||||||
|
FinalProtocol-Hintergrund. Die noch offenen kleineren Cancel-Hover- und Detaileffekte
|
||||||
|
werden zusammen mit der vollständigen CSS-Migration aus R1 abgeschlossen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R3 — Theme-Bootstrap (erledigt 2026-07-22)
|
||||||
|
|
||||||
|
In `RES\static\js\theme.js`:
|
||||||
|
|
||||||
|
1. **Erledigt:** `prefers-color-scheme` wird verwendet, wenn noch keine lokale Auswahl gespeichert ist.
|
||||||
|
2. **Erledigt:** `theme-color` wird beim initialen Anwenden und Umschalten auf `#0a0806` bzw. `#fbf6ec` aktualisiert.
|
||||||
|
3. **Erledigt:** `color-scheme` wird im Dark-/Light-Tokenblock gesetzt.
|
||||||
|
4. **Teilweise erledigt:** geänderte FinalProtocol-Assets tragen neue Versionen; die übrigen Template-Referenzen werden bei ihrer jeweiligen Migration angehoben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R4 — Scroll: Ergebnisanzeigen & Ansichten starten oben (weitgehend erledigt 2026-07-22)
|
||||||
|
|
||||||
|
Es gibt projektweit keinen Scroll-Reset. Drei Schichten (zentraler Helper + gezielte Aufrufe), Chat-Autoscroll `neuralnet.js:84` bleibt unangetastet:
|
||||||
|
|
||||||
|
### R4a. Helper in `theme.js` (läuft auf jeder Seite)
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.MindfluxUX = {
|
||||||
|
toTop: function () { window.scrollTo(0, 0); },
|
||||||
|
focusQuiet: function (el) { if (el) el.focus({ preventScroll: true }); }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Umsetzungshinweis:** Der zentrale Helper ist vorhanden. Die vier SPA-Spiele setzen
|
||||||
|
`history.scrollRestoration` nur auf Spielrouten auf `manual`; ein zentraler,
|
||||||
|
ereignisbasierter Übergangs-Reset deckt Ansichtswechsel ab und lässt Antwort- bzw.
|
||||||
|
Zeilenfeedback unverändert. FinalProtocol, SyntaxSaga, DevLab, MindMatch und
|
||||||
|
TechTacular haben die zusätzlich beschriebenen gezielten Resets/Fokusoptionen.
|
||||||
|
**Offen für die visuelle E2E-Prüfung:** die asynchron gerenderten Ergebniswechsel von
|
||||||
|
CodeEscape, NetWorker und BugHunter müssen im Browser gegen die Scroll-Checkliste
|
||||||
|
validiert und bei Bedarf direkt in den jeweiligen Render-Funktionen präzisiert werden.
|
||||||
|
|
||||||
|
### R4b. SPA-Spiele — Reset bei jedem Ansichtswechsel
|
||||||
|
|
||||||
|
Muster pro Spiel: Mini-Wrapper `function setView(html){ view.innerHTML = html; window.scrollTo(0,0); }`, alle Render-Funktionen darüber. Zusätzlich **nur in diesen vier Skripten** beim Init: `if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; window.scrollTo(0,0);` — verhindert, dass der Browser nach Reload/Back die alte, untere Position auf den neu aufgebauten Katalog anwendet. (Nicht global in `theme.js`: auf Lernseiten/Dashboard ist die Back-Position erwünscht.)
|
||||||
|
|
||||||
|
| Spiel | Funktionen |
|
||||||
|
|---|---|
|
||||||
|
| BugHunter (`bughunter.js`) | `renderBriefing` :29, `renderHunt` :36 (nur bei Fallwechsel, nicht bei Zeilen-Re-Render), `showDebrief` :82, `renderResult` :88 |
|
||||||
|
| CodeEscape (`codeescape.js`) | `renderLobby` :6, `renderRoom` :8, `renderResult` :17 |
|
||||||
|
| NetWorker (`networker.js`) | `renderCatalog` :3, `renderIntro` :5, `renderLevel` :6, `finish` :11 |
|
||||||
|
| ServiceClash (`serviceclash.js`) | `renderCatalog` :2, `renderRun` :4 (**nur bei Ticket-/Indexwechsel**, nicht beim Feedback-Re-Render), `finish` :9 |
|
||||||
|
|
||||||
|
### R4c. FinalProtocol-Quiz (`finalflux-test.js`)
|
||||||
|
|
||||||
|
- `finishIfDone()` Z. 101–104: nach `testResult.hidden = false` → `elements.testResult.scrollIntoView({ block: 'start' });` (Auswertung beginnt oben im Blick, statt dass der Viewport ans geclampte Seitenende fällt).
|
||||||
|
- `restartTest()` Z. 151–158: nach dem Wiedereinblenden → `elements.testRoot.scrollIntoView({ block: 'start' });`
|
||||||
|
|
||||||
|
### R4d. Fokus ohne Scroll-Sprung
|
||||||
|
|
||||||
|
- `syntaxsaga.js`: `renderMission()` — `window.scrollTo(0,0)` vor Z. 209, dann Z. 210 `first.focus({preventScroll:true})`. (`nextBtn.focus()` Z. 297 bleibt — Nutzeraktion am selben Ort.)
|
||||||
|
- `devlabgame.js`: `renderTask()` Z. 243 — `window.scrollTo(0,0)` am Ende; Z. 83 und Z. 233 `focus({preventScroll:true})`.
|
||||||
|
- `codeescape.js:8/15` bleiben (Fokus am Türterminal ist dort korrekt).
|
||||||
|
|
||||||
|
### R4e. Niedrige Prio (nur Mobile relevant)
|
||||||
|
|
||||||
|
`mindmatch.js` `newGame()` :332 und `techtacular.js` `newGame()` :265: `window.scrollTo(0,0)` — Desktop-Bodys sind `overflow:hidden`, mobil scrollt die Seite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP R5 — Carry-over: Rest aus Refine 1 + Zusatzfunde der Verifikation
|
||||||
|
|
||||||
|
Aus Refine 1 (dort AP 7 / offen):
|
||||||
|
1. **Bean Validation:** `@Valid` + Constraints auf alle Request-DTOs — `BugHunterApiController:17–18`, `DevLabApiController:37/43`, `SyntaxSagaApiController:32/38`, `TechtacularApiController:50/75`, `CodeEscapeApiController` (4 DTOs), `NetworkerApiController` (2), `ServiceClashApiController` (2); `NeuralNetApiController:14/19` `Map<String,String>` → typisierte DTOs.
|
||||||
|
2. **Spring Boot** 3.4.4 → aktuelles 3.4.x-Patch (`pom.xml:11`), Testlauf.
|
||||||
|
3. **CSRF:** Blanket-Exemption `/api/**` (`SecurityConfig:46`) durch Cookie-basierte Tokens ersetzen.
|
||||||
|
4. **Web-Layer-Test** für `GET /profile/export`.
|
||||||
|
|
||||||
|
Zusatzfunde (neu):
|
||||||
|
5. **`UserController:25/45`** bindet die **Entity `User`** direkt als `@RequestBody` ohne Validierung → auf DTO + `@Valid` umstellen (gehört in die Runde von Punkt 1).
|
||||||
|
6. **5 Admin-Templates ohne Footer:** `admin\bughunter.html`, `admin\bughunter-hunt.html`, `admin\codeescape.html`, `admin\codeescape-room.html`, `admin\codeescape-scenario.html` → `th:replace="~{fragments/layout :: footer}"` ergänzen.
|
||||||
|
7. **HSTS** explizit für Prod konfigurieren statt Spring-Default (`SecurityConfig:56–57`).
|
||||||
|
8. Optional (unverändert Phase 2): BugHunter progressive Hinweise (Refine 1, AP 4d).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
1. **AP R0** Aufräumen → 2. **AP R1** Primitives + Migration (theme.css zuerst, dann platform-neural, dann Spiele-CSS, dann Templates) → 3. **AP R2** kaputte Light-Stellen → 4. **AP R3** Bootstrap → 5. **AP R4** Scroll (unabhängig von 1–4, parallelisierbar) → 6. **AP R5** Carry-over → 7. Gesamtverifikation. STYLEGUIDE.md (Abschnitt „Glow, Schatten & Radien" → Primitives-Tabelle) und CHANGELOG nach R1–R3 nachziehen.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- **Build:** `mvn test` bleibt grün (54/54); App-Boot gegen Wegwerf-Postgres wie gehabt.
|
||||||
|
- **Grep-Gates nach R1:** `grep -rnE "0 0 [0-9]+px var\(--" RES\static\css` → Treffer nur noch in den Primitive-Definitionen von `theme.css`; `grep -rn "text-shadow:0 0" RES\static\css RES\templates` → nur `--g-text`-Definition; `grep -rn "rgba(0,0,0,.5" RES\static\css` → nur Scrims/`--g-overlay`-Definition.
|
||||||
|
- **Visuell (hell UND dunkel):** Dashboard (Karten + Hover), Login/Registrierung (insb. Overlay-Panel!), Settings, Progress, Soziales, NeuralNet, alle 9 Spiele (insb. SyntaxSaga-Bühne, CodeEscape-Eingaben, NetWorker-Guide), FinalProtocol-Lernseite + Quiz, A11y-Panel. Dark Mode muss vor/nach R1 visuell deckungsgleich sein (Stichprobe per Screenshot-Vergleich).
|
||||||
|
- **A11y-Kombinationen:** Light × Hochkontrast (bleibt bewusst dunkel), Light × Farbenblind (neue dunkle Okabe-Ito-Werte sichtbar, Kontrast-Tool ≥ 4.5:1), Light × Mono.
|
||||||
|
- **Native Controls:** Light Mode → helle Scrollbars und helle `<select>`-Dropdowns (Chrome + Firefox); mobile Adressleiste folgt `theme-color`.
|
||||||
|
- **Erstbesuch:** localStorage leeren, OS auf Light stellen → Seite startet hell; OS Dark → dunkel; Umschalter persistiert weiter.
|
||||||
|
- **Scroll:** je SPA-Spiel: langes Board nach unten scrollen → Aktion abschließen → Ergebnis-/Folge-View beginnt **oben**; ServiceClash-Antwort-Feedback und BugHunter-Zeilenauswahl dürfen **nicht** nach oben springen; FinalProtocol: langen Lernartikel bis zum Quiz scrollen, Quiz abschließen → Auswertung oben im Viewport; Reload mitten im Spiel → Katalog startet oben; SyntaxSaga/DevLab: Missions-/Taskwechsel startet oben, Fokus springt nicht; NeuralNet-Chat scrollt weiterhin ans Ende.
|
||||||
|
|
||||||
|
## Offene Punkte (unverändert aus Refine 1, vor Launch)
|
||||||
|
|
||||||
|
- Echte Impressumsdaten (`impressum.html:44–52`) + §1 Datenschutzerklärung.
|
||||||
|
- Support-/Feedback-Adressen bestätigen (`fragments/layout.html:88–89`).
|
||||||
|
|
||||||
|
## Nachtrag 2026-07-22 — E-Mail-Diagnose (erledigt)
|
||||||
|
|
||||||
|
- Der Versand von Bestätigungs- und Reset-Links protokolliert nun korrelierbare
|
||||||
|
Ereignisse: Token-Ausstellung, SMTP-Übergabe begonnen/angenommen/fehlgeschlagen,
|
||||||
|
erneute Anforderung und erfolgreiche Verifikation. Logs enthalten User-/Token-IDs
|
||||||
|
sowie einen gekürzten E-Mail-Fingerprint, niemals den Link oder das Roh-Token.
|
||||||
|
- Wichtig für die Analyse: `SMTP_SUBMISSION_ACCEPTED` bedeutet nur, dass der
|
||||||
|
konfigurierte SMTP-Server die Nachricht angenommen hat. Spamfilter, Bounces und
|
||||||
|
die finale Postfachzustellung müssen zusätzlich im Mailserver bzw. über DSN geprüft
|
||||||
|
werden.
|
||||||
|
- Der Registrierungsabschluss meldet keinen Versand-Erfolg mehr, falls der gerade
|
||||||
|
angelegte Benutzer beim Versand nicht geladen werden kann oder bereits als bestätigt
|
||||||
|
vorliegt; der Grund erscheint als `VERIFICATION_SKIPPED` im Betriebslog.
|
||||||
@@ -60,6 +60,12 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SMTP-Versand für Bestätigungs- und Passwort-Reset-Mails -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
|
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.mindflux.config;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.BugHunterHuntRepository;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Erstbefüllung der Fallakten. Der vollständige Bug-Knoten bleibt als serverseitiger Payload erhalten. */
|
||||||
|
@Component
|
||||||
|
public class BugHunterSeeder implements CommandLineRunner {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BugHunterSeeder.class);
|
||||||
|
private final BugHunterHuntRepository hunts; private final ObjectMapper mapper;
|
||||||
|
public BugHunterSeeder(BugHunterHuntRepository hunts, ObjectMapper mapper) { this.hunts = hunts; this.mapper = mapper; }
|
||||||
|
@Override @Transactional public void run(String... args) {
|
||||||
|
try {
|
||||||
|
JsonNode root = mapper.readTree(new ClassPathResource("bughunter/content.json").getInputStream()); int order = (int) hunts.count(), count = 0, added = 0;
|
||||||
|
for (JsonNode node : root.path("hunts")) {
|
||||||
|
if (hunts.existsBySlug(node.path("slug").asText())) continue;
|
||||||
|
BugHunterHunt hunt = new BugHunterHunt(node.path("slug").asText(), node.path("title").asText(), node.path("language").asText("Java"), node.path("difficulty").asText("junior"), node.path("examScope").asText("AP1"), node.path("description").asText(""), node.path("briefing").asText(""), node.path("consoleOutput").asText(""), order++);
|
||||||
|
int fileOrder = 0; for (JsonNode file : node.path("files")) hunt.addFile(new BugHunterFile(file.path("filename").asText(), file.path("content").asText(), fileOrder++));
|
||||||
|
int bugOrder = 0; for (JsonNode bug : node.path("bugs")) { String payload = mapper.writeValueAsString(bug); if (payload.length() <= 8000) { hunt.addBug(new BugHunterBug(bug.path("points").asInt(100), bugOrder++, payload)); count++; } }
|
||||||
|
if (!hunt.getFiles().isEmpty() && !hunt.getBugs().isEmpty()) { hunts.save(hunt); added++; }
|
||||||
|
}
|
||||||
|
log.info("BugHunter: {} neue Fallakten / {} Bugs geladen.", added, count);
|
||||||
|
} catch (Exception ex) { log.error("BugHunter-Seed fehlgeschlagen", ex); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.mindflux.config;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.NetworkWorldRepository;
|
||||||
|
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 NetworkerSeeder implements CommandLineRunner {
|
||||||
|
private static final Logger log=LoggerFactory.getLogger(NetworkerSeeder.class); private final NetworkWorldRepository worlds; private final ObjectMapper mapper;
|
||||||
|
public NetworkerSeeder(NetworkWorldRepository worlds,ObjectMapper mapper){this.worlds=worlds;this.mapper=mapper;}
|
||||||
|
@Override @Transactional public void run(String...args){if(worlds.count()>0)return;try{JsonNode root=mapper.readTree(new ClassPathResource("networker/content.json").getInputStream());int wi=0,li=0;for(JsonNode world:root.path("worlds")){NetworkWorld item=new NetworkWorld(world.path("slug").asText(),world.path("title").asText(),world.path("description").asText(),world.path("difficulty").asText("junior"),world.path("examScope").asText("AP1"),wi++);int order=0;for(JsonNode level:world.path("levels")){String payload=mapper.writeValueAsString(level.path("payload"));if(payload.length()<=8000){item.addLevel(new NetworkLevel(level.path("title").asText(),level.path("type").asText("assign"),level.path("points").asInt(100),order++,payload));li++;}}worlds.save(item);}log.info("NetWorker: {} Welten / {} Level geladen.",wi,li);}catch(Exception e){log.error("NetWorker-Seed fehlgeschlagen",e);}}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|||||||
import org.springframework.security.core.userdetails.*;
|
import org.springframework.security.core.userdetails.*;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -20,7 +21,7 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
|
.requestMatchers("/", "/login", "/register", "/forgot-password", "/reset-password", "/verify-email", "/verification/resend", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
|
||||||
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
|
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/users/**").hasRole("ADMIN")
|
.requestMatchers("/api/users/**").hasRole("ADMIN")
|
||||||
@@ -42,7 +43,18 @@ public class SecurityConfig {
|
|||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
||||||
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
|
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
|
||||||
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**")
|
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**", "/api/neuralnet/**")
|
||||||
|
)
|
||||||
|
.headers(headers -> headers
|
||||||
|
// CSP: pragmatischer Start – alle Assets sind self-hosted (Chart.js lokal
|
||||||
|
// gevendort). 'unsafe-inline' bleibt vorerst nötig, weil Templates Inline-
|
||||||
|
// Styles und einzelne Inline-Skripte enthalten (später auf Nonces umstellbar).
|
||||||
|
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
||||||
|
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"))
|
||||||
|
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
|
||||||
|
// HSTS bleibt auf Spring-Default: wird nur über HTTPS gesendet, greift also
|
||||||
|
// faktisch nur in Produktion (Dev läuft über HTTP).
|
||||||
);
|
);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
@@ -63,6 +75,7 @@ public class SecurityConfig {
|
|||||||
.withUsername(user.getUsername())
|
.withUsername(user.getUsername())
|
||||||
.password(user.getPassword())
|
.password(user.getPassword())
|
||||||
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
|
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
|
||||||
|
.disabled(!user.isEmailVerified())
|
||||||
.build();
|
.build();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.example.mindflux.config;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.ServiceClashShiftRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ServiceClashSeeder implements CommandLineRunner {
|
||||||
|
private final ServiceClashShiftRepository shifts; private final ObjectMapper mapper;
|
||||||
|
public ServiceClashSeeder(ServiceClashShiftRepository shifts,ObjectMapper mapper){this.shifts=shifts;this.mapper=mapper;}
|
||||||
|
@Override @Transactional public void run(String... args) {
|
||||||
|
if(shifts.count()>0)return;
|
||||||
|
seed("fruehschicht","Frühschicht: Stabiler Start","Lerne das Triage-Prinzip: erst Art, dann Dringlichkeit, dann die passende erste Maßnahme.","Junior",0,List.of(
|
||||||
|
t("Druckerwarteschlange brennt","Buchhaltung","Drei Druckaufträge hängen seit dem Neustart fest; niemand kann Rechnungen drucken.","incident","medium","queue",1,100,"Störung: Die Warteschlange zurücksetzen und einen Testdruck starten.","queue","Druckwarteschlange prüfen und bereinigen","restart","Drucker ohne Prüfung neu starten","buy","Sofort ein neues Gerät bestellen"),
|
||||||
|
t("Azubi braucht Zugang","Ausbildung","Die neue Auszubildende startet heute und braucht Zugriff auf das Lernportal.","request","low","access",1,100,"Anfrage: Berechtigung nach Freigabe über den Standardprozess einrichten.","access","Zugang nach Freigabe einrichten","security","Sicherheitsincident ausrufen","ignore","Auf nächste Woche verschieben"),
|
||||||
|
t("WLAN im Besprechungsraum weg","Vertrieb","Seit 20 Minuten ist das Firmen-WLAN im Raum Elbe nicht sichtbar.","incident","medium","diagnose",1,110,"Störung: AP-Status und Stromversorgung prüfen, dann gezielt eskalieren.","diagnose","Access Point und Uplink prüfen","password","Alle WLAN-Passwörter zurücksetzen","replace","Das gesamte WLAN ersetzen"),
|
||||||
|
t("Neue Verteilerliste","Personal","Für das Onboarding wird eine Mailgruppe für zehn neue Kolleg:innen benötigt.","request","low","group",1,100,"Anfrage: Gruppe anhand der genehmigten Liste anlegen und Besitzer benennen.","group","Verteilerliste nach Vorlage anlegen","broadcast","An alle Mitarbeitenden mailen","escalate","Sofort an den CISO eskalieren"),
|
||||||
|
t("Zeiterfassung meldet Fehler","Produktion","Terminals zeigen beim Buchen nur noch „Dienst nicht erreichbar“.","incident","high","escalate",2,130,"Hohe Auswirkung: Incident dokumentieren, Dienst prüfen und an den Bereitschaftsdienst eskalieren.","escalate","Incident eröffnen und Bereitschaft informieren","wait","Bis morgen abwarten","delete","Terminals aus dem Verzeichnis löschen"),
|
||||||
|
t("USB-Tastatur defekt","Empfang","Eine einzelne Tastatur reagiert nicht mehr; Ersatz liegt im Schrank.","request","low","swap",2,90,"Standard-Request: Ersatzgerät ausgeben und Inventar nachführen.","swap","Ersatz ausgeben und Inventar buchen","outage","Großstörung ausrufen","format","Den Empfangs-PC formatieren"),
|
||||||
|
t("Dateiserver langsam","Konstruktion","Große CAD-Dateien öffnen seit einer Stunde extrem langsam, kleinere Dateien funktionieren.","incident","medium","monitor",2,120,"Störung: Auslastung und Netzwerkpfad messen, Befund in das Incident aufnehmen.","monitor","Last und Netzwerkpfad prüfen","reboot","Ohne Befund alle Server neu starten","close","Ticket als erledigt schließen"),
|
||||||
|
t("Meetingraum-Display buchen","Projektbüro","Für morgen soll das Display im Raum Nord für eine Präsentation reserviert werden.","request","low","booking",3,90,"Anfrage: Ressource im Buchungssystem reservieren und bestätigen.","booking","Display im Buchungssystem reservieren","incident","Display als Störung melden","handover","An die Nachtwache delegieren")
|
||||||
|
));
|
||||||
|
seed("tagesschicht","Tagesschicht: Prioritäten unter Druck","Mehr parallele Tickets: Schütze zuerst Menschen, Daten und geschäftskritische Dienste.","Fortgeschritten",1,List.of(
|
||||||
|
t("ERP nicht erreichbar","Finanzen","Alle Standorte melden beim ERP nur noch Zeitüberschreitungen. Rechnungsfreigaben stehen.","incident","high","major",1,150,"Major Incident: Lagebild eröffnen, Eigentümer informieren und Wiederherstellung koordinieren.","major","Major Incident eröffnen und eskalieren","restart","Einzelne Clients neu starten","close","Ohne Prüfung schließen"),
|
||||||
|
t("Neue Software beantragt","Marketing","Ein Team möchte eine bereits freigegebene Bildbearbeitung auf fünf Notebooks installieren.","request","medium","deploy",1,110,"Anfrage: Freigegebenes Paket über die Softwareverteilung bereitstellen.","deploy","Paket über Softwareverteilung ausrollen","admin","Allen lokale Adminrechte geben","ignore","Anfrage ignorieren"),
|
||||||
|
t("Verdächtige Phishing-Mail","Support","Mehrere Mitarbeitende erhielten eine Rechnung von einer ähnlich aussehenden Absenderdomain.","incident","high","contain",1,150,"Security-Incident: Nachricht sichern, Empfänger warnen und den Meldeprozess starten.","contain","Mail sichern, warnen und Security informieren","reply","Auf die Mail antworten","delete","Nur den eigenen Posteingang leeren"),
|
||||||
|
t("VPN für Dienstreise","Vertrieb","Eine Kollegin reist nächste Woche und benötigt den Standard-VPN-Zugang.","request","medium","vpn",1,110,"Anfrage: VPN nach Richtlinie und Freigabe aktivieren, MFA erklären.","vpn","VPN mit MFA nach Richtlinie einrichten","share","Kolleg:innen-Zugang teilen","exception","MFA dauerhaft umgehen"),
|
||||||
|
t("Monitoring meldet Platte voll","Monitoring","Das Monitoring meldet 94 % Belegung auf dem Log-Volume eines Anwendungsservers.","incident","high","capacity",2,140,"Hohe Priorität: Wachstum prüfen, sichere Bereinigung oder Erweiterung planen und Dienst beobachten.","capacity","Belegung prüfen und Kapazität sichern","rmall","Alle Logs blind löschen","silence","Alarm dauerhaft stummschalten"),
|
||||||
|
t("Namensschild-Drucker","Office Management","Für eine Veranstaltung wird ein temporärer Druckerzugang für den Empfang benötigt.","request","low","temporary",2,90,"Anfrage: zeitlich begrenzte Berechtigung dokumentiert einrichten.","temporary","Temporären Zugang dokumentiert einrichten","global","Drucker global freigeben","incident","Als Sicherheitsincident melden"),
|
||||||
|
t("CRM zeigt falsche Kundendaten","Vertrieb","Einige Kundensätze wirken veraltet, während Änderungen anderer Teams sichtbar sind.","incident","medium","investigate",2,120,"Störung: Reproduzierbarkeit, betroffene Datenquelle und letzte Synchronisation prüfen.","investigate","Datenquelle und Synchronisation prüfen","overwrite","Daten ohne Backup überschreiben","wait","Ticket ohne Rückmeldung parken"),
|
||||||
|
t("Projektablage anlegen","PMO","Ein neues Projekt braucht eine Teamablage mit den Rollen Lesen, Bearbeiten und Leitung.","request","medium","roles",3,110,"Anfrage: Ablage nach Berechtigungsvorlage erstellen und Rollen testen.","roles","Ablage mit Rollenmodell erstellen","public","Ablage öffentlich freigeben","owner","Alle zu Eigentümer:innen machen")
|
||||||
|
));
|
||||||
|
seed("bereitschaft","Bereitschaft: Kritische Lage","Für die Abschluss-Schicht kombinierst du Security, Changes und Wiederanlauf unter klarer Kommunikation.","Profi",2,List.of(
|
||||||
|
t("Ransomware-Verdacht","Monitoring","Ein Fileserver erzeugt ungewöhnlich viele umbenannte Dateien; EDR meldet verdächtige Prozesse.","incident","high","isolate",1,160,"Security-Incident: System isolieren, Beweise sichern und Incident-Response alarmieren.","isolate","System isolieren und Incident Response alarmieren","reboot","Server sofort neu starten","delete","Betroffene Dateien ohne Sicherung löschen"),
|
||||||
|
t("Firewall-Change vor Wartung","Netzwerk","Für ein genehmigtes Wartungsfenster muss eine dokumentierte Regel temporär geöffnet werden.","request","high","change",1,140,"Change: Genehmigung, Zeitfenster, Rückfallplan und Protokoll vor Umsetzung prüfen.","change","Change mit Rückfallplan kontrolliert umsetzen","permanent","Regel dauerhaft und ohne Prüfung öffnen","delegate","Anfrage ohne Dokumentation weitergeben"),
|
||||||
|
t("Backup-Job fehlgeschlagen","Backup","Der nächtliche Backup-Job für zwei Datenbanken endete mit Fehler; die Produktion läuft noch.","incident","high","restore",1,150,"Hohe Priorität: Fehlerursache und letzte erfolgreiche Sicherung prüfen, Wiederanlauf planen.","restore","Backup-Kette prüfen und Wiederanlauf planen","ignore","Bis zum nächsten Lauf warten","purge","Alte Sicherungen sofort löschen"),
|
||||||
|
t("Notfall-Handy ausgeben","Bereitschaft","Eine neu eingeteilte Person braucht das Bereitschaftshandy inklusive Rufumleitung.","request","medium","handover",1,110,"Anfrage: Gerät, Rufumleitung und Übergabe nachvollziehbar dokumentieren.","handover","Übergabe und Rufumleitung dokumentieren","share","PIN unverschlüsselt in den Chat posten","skip","Gerät ohne Einweisung übergeben"),
|
||||||
|
t("Zertifikat läuft heute ab","Webteam","Das TLS-Zertifikat des Kundenportals läuft laut Monitoring um 23:59 Uhr ab.","incident","high","renew",2,150,"Dringend: Zertifikatskette und Erneuerung prüfen, Rollback und Monitoring vorbereiten.","renew","Zertifikat erneuern und Ablauf testen","disable","HTTPS abschalten","wait","Erst nach Ablauf reagieren"),
|
||||||
|
t("Neue Adminrolle","Entwicklung","Eine Entwicklerin beantragt für einen Testzeitraum erhöhte Rechte auf einem Staging-System.","request","high","least",2,130,"Anfrage: zeitlich begrenzte Least-Privilege-Rolle mit Genehmigung einrichten.","least","Befristete Minimalrechte nach Freigabe geben","root","Dauerhafte Root-Rechte vergeben","shared","Gemeinsames Admin-Konto nutzen"),
|
||||||
|
t("DNS-Ausfall in Außenstelle","Filiale Ost","Clients können interne Namen nicht auflösen, direkte IP-Verbindungen funktionieren noch.","incident","high","dns",2,150,"Störung: DNS-Pfad und Resolver prüfen, Auswirkung kommunizieren und Eskalation steuern.","dns","Resolver und DNS-Pfad prüfen","format","Alle Clients neu installieren","close","Ticket schließen, weil IPs gehen"),
|
||||||
|
t("Wartungsfenster verschieben","Change Management","Ein freigegebener Patch kollidiert mit einem wichtigen Kunden-Workshop und soll verschoben werden.","request","medium","reschedule",3,110,"Change-Anfrage: Auswirkungen bewerten, Beteiligte informieren und neues Fenster dokumentieren.","reschedule","Change dokumentiert neu terminieren","patch","Patch trotz Konflikt ausrollen","cancel","Alle offenen Changes löschen")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
private void seed(String slug,String title,String description,String difficulty,int order,List<ServiceClashTicket> tickets){ServiceClashShift shift=new ServiceClashShift(slug,title,description,difficulty,"AP2_FISI",order);tickets.forEach(shift::addTicket);shifts.save(shift);}
|
||||||
|
private ServiceClashTicket t(String title,String requester,String text,String category,String priority,String solution,int wave,int points,String explanation,String... options){try{ObjectNode p=mapper.createObjectNode();p.put("requester",requester);p.put("text",text);p.put("category",category);p.put("priority",priority);p.put("solution",solution);p.put("explanation",explanation);var array=p.putArray("options");for(int i=0;i<options.length;i+=2){ObjectNode option=array.addObject();option.put("id",options[i]);option.put("text",options[i+1]);}return new ServiceClashTicket(title,wave,points,wave*10+options.length,mapper.writeValueAsString(p));}catch(Exception e){throw new IllegalStateException(e);}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
|
import com.example.mindflux.service.AccountMailService;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class AccountRecoveryController {
|
||||||
|
|
||||||
|
private final AccountMailService accountMailService;
|
||||||
|
|
||||||
|
public AccountRecoveryController(AccountMailService accountMailService) {
|
||||||
|
this.accountMailService = accountMailService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forgot-password")
|
||||||
|
public String forgotPassword() {
|
||||||
|
return "forgot-password";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forgot-password")
|
||||||
|
public String requestPasswordReset(@RequestParam String email) {
|
||||||
|
try {
|
||||||
|
accountMailService.requestPasswordReset(email);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Die Antwort bleibt absichtlich identisch: keine E-Mail- oder Account-Informationen preisgeben.
|
||||||
|
}
|
||||||
|
// Einheitliche Antwort, damit nicht erkennbar ist, welche Adressen registriert sind.
|
||||||
|
return "redirect:/forgot-password?sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/verification/resend")
|
||||||
|
public String resendVerification(@RequestParam String email) {
|
||||||
|
try {
|
||||||
|
accountMailService.resendVerification(email);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Auch bei einem SMTP-Fehler keine Information über einen möglichen Account verraten.
|
||||||
|
}
|
||||||
|
return "redirect:/?verificationSent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/verify-email")
|
||||||
|
public String verifyEmail(@RequestParam(required = false) String token) {
|
||||||
|
return accountMailService.verifyEmail(token)
|
||||||
|
? "redirect:/?emailVerified"
|
||||||
|
: "redirect:/?verificationInvalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reset-password")
|
||||||
|
public String showResetPassword(@RequestParam(required = false) String token, Model model) {
|
||||||
|
if (!accountMailService.isResetTokenValid(token)) {
|
||||||
|
return "redirect:/?resetInvalid";
|
||||||
|
}
|
||||||
|
model.addAttribute("token", token);
|
||||||
|
return "reset-password";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public String resetPassword(@RequestParam String token,
|
||||||
|
@RequestParam String password,
|
||||||
|
@RequestParam String confirmPassword) {
|
||||||
|
if (password.length() < 8) {
|
||||||
|
return "redirect:/reset-password?token=" + token + "&error=policy";
|
||||||
|
}
|
||||||
|
if (!password.equals(confirmPassword)) {
|
||||||
|
return "redirect:/reset-password?token=" + token + "&error=mismatch";
|
||||||
|
}
|
||||||
|
return accountMailService.resetPassword(token, password, confirmPassword)
|
||||||
|
? "redirect:/?passwordReset"
|
||||||
|
: "redirect:/?resetInvalid";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
|
import com.example.mindflux.service.BugHunterService;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Controller @RequestMapping("/admin/bughunter")
|
||||||
|
public class AdminBugHunterController {
|
||||||
|
private final BugHunterService service;
|
||||||
|
public AdminBugHunterController(BugHunterService service) { this.service = service; }
|
||||||
|
@GetMapping public String list(Model model) { model.addAttribute("hunts", service.all()); return "admin/bughunter"; }
|
||||||
|
@PostMapping("/add") public String add(@RequestParam String title, @RequestParam String language, @RequestParam String difficulty, @RequestParam String examScope, @RequestParam(required = false) String description, @RequestParam(required = false) String briefing, @RequestParam(required = false) String consoleOutput) { Long id = service.addHunt(title, language, difficulty, examScope, description, briefing, consoleOutput); return id == null ? "redirect:/admin/bughunter" : "redirect:/admin/bughunter/" + id; }
|
||||||
|
@GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { model.addAttribute("hunt", service.hunt(id)); return "admin/bughunter-hunt"; }
|
||||||
|
@PostMapping("/{id}/files") public String addFile(@PathVariable Long id, @RequestParam String filename, @RequestParam String content) { service.addFile(id, filename, content); return "redirect:/admin/bughunter/" + id; }
|
||||||
|
@PostMapping("/{id}/bugs") public String addBug(@PathVariable Long id, @RequestParam(defaultValue = "100") int points, @RequestParam String payload) { service.addBug(id, points, payload); return "redirect:/admin/bughunter/" + id; }
|
||||||
|
@PostMapping("/{id}/toggle") public String toggle(@PathVariable Long id) { service.toggle(id); return "redirect:/admin/bughunter"; }
|
||||||
|
@PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { service.deleteHunt(id); return "redirect:/admin/bughunter"; }
|
||||||
|
@PostMapping("/files/{id}/delete") public String deleteFile(@PathVariable Long id, @RequestParam Long huntId) { service.deleteFile(id); return "redirect:/admin/bughunter/" + huntId; }
|
||||||
|
@PostMapping("/bugs/{id}/delete") public String deleteBug(@PathVariable Long id, @RequestParam Long huntId) { service.deleteBug(id); return "redirect:/admin/bughunter/" + huntId; }
|
||||||
|
}
|
||||||
@@ -64,12 +64,13 @@ public class AdminUserController {
|
|||||||
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
|
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
|
||||||
@PostMapping("/users/{id}/reset-password")
|
@PostMapping("/users/{id}/reset-password")
|
||||||
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
|
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
|
||||||
if (newPassword != null && !newPassword.isBlank()) {
|
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||||
User user = userService.findById(id)
|
return "redirect:/admin/users?passwordPolicy";
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
|
|
||||||
user.setPassword(passwordEncoder.encode(newPassword.trim()));
|
|
||||||
userService.save(user);
|
|
||||||
}
|
}
|
||||||
|
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";
|
return "redirect:/admin/users";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.*;
|
||||||
|
import com.example.mindflux.service.BugHunterService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import java.security.Principal;
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/bughunter")
|
||||||
|
public class BugHunterApiController {
|
||||||
|
private final BugHunterService service;
|
||||||
|
public BugHunterApiController(BugHunterService service) { this.service = service; }
|
||||||
|
@GetMapping("/hunts") public JsonNode catalog(Principal principal) { return service.catalog(principal == null ? null : principal.getName()); }
|
||||||
|
@GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug, Principal principal, HttpSession httpSession) { return service.session(slug, principal == null ? null : principal.getName(), httpSession); }
|
||||||
|
@PostMapping("/reset/{slug}") public void reset(@PathVariable String slug, Principal principal) { service.reset(principal.getName(), slug); }
|
||||||
|
@PostMapping("/check") public JsonNode check(@RequestBody BugHunterCheckRequest request, HttpSession httpSession) { return service.check(request.getBugId(), request.getPhase(), request.isReveal(), request.getSubmission(), httpSession); }
|
||||||
|
@PostMapping("/finish") public BugHunterResult finish(@RequestBody BugHunterFinishRequest request, Principal principal, HttpSession httpSession) { return service.finish(principal.getName(), request, httpSession); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
@Controller public class BugHunterController { @GetMapping("/bughunter") public String page() { return "bughunter"; } }
|
||||||
@@ -6,6 +6,8 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.List;
|
||||||
|
import com.example.mindflux.dto.WeeklyActivity;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class DashboardController {
|
public class DashboardController {
|
||||||
@@ -20,6 +22,14 @@ public class DashboardController {
|
|||||||
public String showDashboard(Model model, Principal principal) {
|
public String showDashboard(Model model, Principal principal) {
|
||||||
// Echte XP/Level/Streak-Werte für die Sidebar-Widgets (statt Dummy-Daten im Template).
|
// Echte XP/Level/Streak-Werte für die Sidebar-Widgets (statt Dummy-Daten im Template).
|
||||||
model.addAttribute("overall", progressService.getOverallProgress(principal.getName()));
|
model.addAttribute("overall", progressService.getOverallProgress(principal.getName()));
|
||||||
|
WeeklyActivity weekly = progressService.getCurrentWeekActivity(principal.getName());
|
||||||
|
model.addAttribute("weekly", weekly != null ? weekly : emptyWeekly());
|
||||||
|
var topGames = progressService.getTopGames(principal.getName(), 4);
|
||||||
|
model.addAttribute("topGames", topGames != null ? topGames : List.of());
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WeeklyActivity emptyWeekly() {
|
||||||
|
return new WeeklyActivity(List.of(0, 0, 0, 0, 0, 0, 0), List.of(0, 0, 0, 0, 0, 0, 0), 0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import com.example.mindflux.dto.GameStats;
|
|||||||
import com.example.mindflux.dto.ScoreResult;
|
import com.example.mindflux.dto.ScoreResult;
|
||||||
import com.example.mindflux.dto.ScoreSubmission;
|
import com.example.mindflux.dto.ScoreSubmission;
|
||||||
import com.example.mindflux.service.GameScoreService;
|
import com.example.mindflux.service.GameScoreService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ public class GameScoreController {
|
|||||||
|
|
||||||
@PostMapping("/{game}/scores")
|
@PostMapping("/{game}/scores")
|
||||||
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
|
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
|
||||||
@RequestBody ScoreSubmission submission,
|
@Valid @RequestBody ScoreSubmission submission,
|
||||||
Principal principal) {
|
Principal principal) {
|
||||||
if (!KNOWN_GAMES.contains(game)) {
|
if (!KNOWN_GAMES.contains(game)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import com.example.mindflux.dto.*;
|
||||||
|
import com.example.mindflux.service.NetworkerService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.security.Principal;
|
||||||
|
@RestController @RequestMapping("/api/networker") public class NetworkerApiController { private final NetworkerService service; public NetworkerApiController(NetworkerService service){this.service=service;} @GetMapping("/catalog") public JsonNode catalog(){return service.catalog();} @GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug,HttpSession session){return service.session(slug,session);} @PostMapping("/check") public JsonNode check(@RequestBody NetworkCheckRequest request,HttpSession session){return service.check(request,session);} @PostMapping("/finish") public NetworkResult finish(@RequestBody NetworkFinishRequest request,Principal principal,HttpSession session){return service.finish(principal.getName(),request,session);} }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
@Controller public class NetworkerController { @GetMapping("/networker") public String page(){return "networker";} }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import com.example.mindflux.service.NeuralNetService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/neuralnet")
|
||||||
|
public class NeuralNetApiController {
|
||||||
|
private final NeuralNetService service;
|
||||||
|
public NeuralNetApiController(NeuralNetService service) { this.service=service; }
|
||||||
|
@GetMapping("/overview") public NeuralNetService.Overview overview(Principal p) { return service.overview(p.getName()); }
|
||||||
|
@PostMapping("/enable") public void enable(Principal p) { service.enable(p.getName()); }
|
||||||
|
@PostMapping("/requests") public void request(Principal p,@RequestBody Map<String,String> body) { service.request(p.getName(),body.get("username")); }
|
||||||
|
@PostMapping("/requests/{username}/accept") public void accept(Principal p,@PathVariable String username) { service.accept(p.getName(),username); }
|
||||||
|
@PostMapping("/requests/{username}/decline") public void decline(Principal p,@PathVariable String username) { service.decline(p.getName(),username); }
|
||||||
|
@DeleteMapping("/requests/{username}") public void cancel(Principal p,@PathVariable String username) { service.cancel(p.getName(),username); }
|
||||||
|
@GetMapping("/messages/{username}") public Object messages(Principal p,@PathVariable String username) { return service.conversation(p.getName(),username); }
|
||||||
|
@PostMapping("/messages") public Object send(Principal p,@RequestBody Map<String,String> body) { return service.send(p.getName(),body.get("recipient"),body.get("body")); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.PlatformStatus;
|
||||||
|
import com.example.mindflux.service.PlatformStatusService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class PlatformStatusController {
|
||||||
|
|
||||||
|
private final PlatformStatusService platformStatusService;
|
||||||
|
|
||||||
|
public PlatformStatusController(PlatformStatusService platformStatusService) {
|
||||||
|
this.platformStatusService = platformStatusService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/platform/status")
|
||||||
|
public ResponseEntity<PlatformStatus> status() {
|
||||||
|
PlatformStatus status = platformStatusService.currentStatus();
|
||||||
|
return ResponseEntity.status(status.ready() ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE).body(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.example.mindflux.controller;
|
|||||||
|
|
||||||
import com.example.mindflux.model.User;
|
import com.example.mindflux.model.User;
|
||||||
import com.example.mindflux.service.UserService;
|
import com.example.mindflux.service.UserService;
|
||||||
|
import com.example.mindflux.service.ProgressService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -16,17 +17,15 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
|||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
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.security.Principal;
|
||||||
import java.util.UUID;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import com.example.mindflux.dto.WeeklyActivity;
|
||||||
|
import com.example.mindflux.service.AvatarService;
|
||||||
|
import com.example.mindflux.service.ProfileExportService;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class ProfileController {
|
public class ProfileController {
|
||||||
@@ -39,7 +38,14 @@ public class ProfileController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private BCryptPasswordEncoder passwordEncoder;
|
private BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/";
|
@Autowired
|
||||||
|
private ProgressService progressService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AvatarService avatarService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProfileExportService profileExportService;
|
||||||
|
|
||||||
@GetMapping("/profile")
|
@GetMapping("/profile")
|
||||||
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
|
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
|
||||||
@@ -61,24 +67,8 @@ public class ProfileController {
|
|||||||
User existingUser = userService.findByUsername(username);
|
User existingUser = userService.findByUsername(username);
|
||||||
|
|
||||||
if (existingUser != null) {
|
if (existingUser != null) {
|
||||||
String profilePicture = existingUser.getProfilePicture();
|
avatarService.delete(existingUser.getProfilePicture());
|
||||||
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
|
existingUser.setProfilePicture(null); // auch wenn keine Datei existierte
|
||||||
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);
|
userService.save(existingUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +81,28 @@ public class ProfileController {
|
|||||||
return "settings";
|
return "settings";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DSGVO-Datenexport (Art. 20): alle Kontodaten als JSON-Download. */
|
||||||
|
@GetMapping("/profile/export")
|
||||||
|
public ResponseEntity<Map<String, Object>> exportData(Principal principal) {
|
||||||
|
Map<String, Object> data = profileExportService.export(principal.getName());
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"mindflux-datenexport.json\"")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(data);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/profile/knowledge")
|
@GetMapping("/profile/knowledge")
|
||||||
public String knowledgeCenter(Model model, Principal principal) {
|
public String knowledgeCenter(Model model, Principal principal) {
|
||||||
String username = principal.getName();
|
String username = principal.getName();
|
||||||
User user = userService.findByUsername(username);
|
User user = userService.findByUsername(username);
|
||||||
|
|
||||||
model.addAttribute("user", user);
|
model.addAttribute("user", user);
|
||||||
|
model.addAttribute("overall", progressService.getOverallProgress(username));
|
||||||
|
WeeklyActivity weekly = progressService.getCurrentWeekActivity(username);
|
||||||
|
model.addAttribute("weekly", weekly != null ? weekly : new WeeklyActivity(
|
||||||
|
List.of(0, 0, 0, 0, 0, 0, 0), List.of(0, 0, 0, 0, 0, 0, 0), 0, 0));
|
||||||
|
var topGames = progressService.getTopGames(username, 3);
|
||||||
|
model.addAttribute("topGames", topGames != null ? topGames : List.of());
|
||||||
return "knowledge";
|
return "knowledge";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,47 +116,9 @@ public class ProfileController {
|
|||||||
if (existingUser != null) {
|
if (existingUser != null) {
|
||||||
existingUser.setEmail(email); // Email aktualisieren
|
existingUser.setEmail(email); // Email aktualisieren
|
||||||
|
|
||||||
if (file != null && !file.isEmpty()) {
|
String newAvatar = avatarService.store(file, existingUser.getProfilePicture());
|
||||||
try {
|
if (newAvatar != null) {
|
||||||
BufferedImage original = ImageIO.read(file.getInputStream());
|
existingUser.setProfilePicture(newAvatar);
|
||||||
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);
|
userService.save(existingUser);
|
||||||
@@ -191,15 +159,7 @@ public class ProfileController {
|
|||||||
// Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion).
|
// Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion).
|
||||||
User user = userService.findByUsername(username);
|
User user = userService.findByUsername(username);
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
String profilePicture = user.getProfilePicture();
|
avatarService.delete(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);
|
userService.deleteByUsername(username);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import com.example.mindflux.dto.WeeklyActivity;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class ProgressController {
|
public class ProgressController {
|
||||||
@@ -41,6 +42,9 @@ public class ProgressController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model.addAttribute("gameProgress", games != null ? games : List.of()); // leere Liste, falls null
|
model.addAttribute("gameProgress", games != null ? games : List.of()); // leere Liste, falls null
|
||||||
|
WeeklyActivity weekly = progressService.getCurrentWeekActivity(username);
|
||||||
|
model.addAttribute("weekly", weekly != null ? weekly : new WeeklyActivity(
|
||||||
|
List.of(0, 0, 0, 0, 0, 0, 0), List.of(0, 0, 0, 0, 0, 0, 0), 0, 0));
|
||||||
|
|
||||||
return "progress"; // ruft progress.html auf
|
return "progress"; // ruft progress.html auf
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.example.mindflux.controller;
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
import com.example.mindflux.service.RegistrationResult;
|
import com.example.mindflux.service.RegistrationResult;
|
||||||
|
import com.example.mindflux.service.AccountMailService;
|
||||||
import com.example.mindflux.service.UserService;
|
import com.example.mindflux.service.UserService;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -10,16 +13,20 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
@Controller
|
@Controller
|
||||||
public class RegisterController {
|
public class RegisterController {
|
||||||
|
|
||||||
private final UserService userService;
|
private static final Logger log = LoggerFactory.getLogger(RegisterController.class);
|
||||||
|
|
||||||
public RegisterController(UserService userService) {
|
private final UserService userService;
|
||||||
|
private final AccountMailService accountMailService;
|
||||||
|
|
||||||
|
public RegisterController(UserService userService, AccountMailService accountMailService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.accountMailService = accountMailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/register")
|
@GetMapping("/register")
|
||||||
public String showRegistrationForm() {
|
public String showRegistrationForm() {
|
||||||
// Die eigenständige Registrierungsseite wurde entfernt – das Formular lebt auf der Startseite.
|
// Das Formular lebt auf der Startseite, muss dort aber im Registrierungsmodus erscheinen.
|
||||||
return "redirect:/";
|
return "redirect:/?signup";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -29,10 +36,22 @@ public class RegisterController {
|
|||||||
@RequestParam(required = false) String confirmPassword) {
|
@RequestParam(required = false) String confirmPassword) {
|
||||||
RegistrationResult result = userService.register(username, email, password, confirmPassword);
|
RegistrationResult result = userService.register(username, email, password, confirmPassword);
|
||||||
return switch (result) {
|
return switch (result) {
|
||||||
case SUCCESS -> "redirect:/?registerSuccess";
|
case SUCCESS -> {
|
||||||
|
try {
|
||||||
|
var user = userService.findByUsernameIgnoreCase(username.trim());
|
||||||
|
yield accountMailService.sendVerificationFor(user)
|
||||||
|
? "redirect:/?registerSuccess"
|
||||||
|
: "redirect:/?registerMailError";
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("mail_event=REGISTRATION_VERIFICATION_FAILED errorType={}",
|
||||||
|
e.getClass().getSimpleName());
|
||||||
|
yield "redirect:/?registerMailError";
|
||||||
|
}
|
||||||
|
}
|
||||||
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
|
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
|
||||||
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
|
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
|
||||||
case EMAIL_INVALID -> "redirect:/?emailInvalid";
|
case EMAIL_INVALID -> "redirect:/?emailInvalid";
|
||||||
|
case EMAIL_TAKEN -> "redirect:/?emailTaken";
|
||||||
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
|
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import com.example.mindflux.dto.*; import com.example.mindflux.service.ServiceClashService; import com.fasterxml.jackson.databind.JsonNode; import jakarta.servlet.http.HttpSession; import java.security.Principal; import org.springframework.web.bind.annotation.*;
|
||||||
|
@RestController @RequestMapping("/api/serviceclash") public class ServiceClashApiController {private final ServiceClashService service;public ServiceClashApiController(ServiceClashService service){this.service=service;}@GetMapping("/catalog") public JsonNode catalog(){return service.catalog();}@GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug,HttpSession http){return service.session(slug,http);}@PostMapping("/check") public JsonNode check(@RequestBody ServiceClashCheckRequest request,HttpSession http){return service.check(request,http);}@PostMapping("/finish") public ServiceClashResult finish(@RequestBody ServiceClashFinishRequest request,Principal principal,HttpSession http){return service.finish(principal.getName(),request,http);}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
@Controller public class ServiceClashController { @GetMapping("/serviceclash") public String page(){return "serviceclash";} }
|
||||||
@@ -7,12 +7,16 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
|
||||||
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
|
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/techtacular")
|
@RequestMapping("/api/techtacular")
|
||||||
public class TechtacularApiController {
|
public class TechtacularApiController {
|
||||||
|
|
||||||
|
private static final String JOKER_USED = "techtacular.fiftyFiftyUsed";
|
||||||
|
private static final String HINTED_QUESTION_IDS = "techtacular.hintedQuestionIds";
|
||||||
|
|
||||||
private final TechtacularService techtacularService;
|
private final TechtacularService techtacularService;
|
||||||
|
|
||||||
public TechtacularApiController(TechtacularService techtacularService) {
|
public TechtacularApiController(TechtacularService techtacularService) {
|
||||||
@@ -21,7 +25,10 @@ public class TechtacularApiController {
|
|||||||
|
|
||||||
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
|
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
|
||||||
@GetMapping("/board")
|
@GetMapping("/board")
|
||||||
public TechtacularBoardView board(@RequestParam(required = false) String exclude) {
|
public TechtacularBoardView board(@RequestParam(required = false) String exclude, HttpSession session) {
|
||||||
|
// Ein neues Board beginnt immer eine neue Runde mit genau einem Joker.
|
||||||
|
session.removeAttribute(JOKER_USED);
|
||||||
|
session.removeAttribute(HINTED_QUESTION_IDS);
|
||||||
return techtacularService.buildBoard(parseIds(exclude));
|
return techtacularService.buildBoard(parseIds(exclude));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +47,41 @@ public class TechtacularApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/answer")
|
@PostMapping("/answer")
|
||||||
public AnswerResult answer(@RequestBody AnswerRequest request) {
|
public AnswerResult answer(@RequestBody AnswerRequest request, HttpSession session) {
|
||||||
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId());
|
// Das Client-Flag dient der UI und dem Finish-Payload. Ob die Punkte halbiert
|
||||||
|
// werden, entscheidet jedoch allein der serverseitige Rundenzustand.
|
||||||
|
boolean validHint = hintedQuestionIds(session).contains(request.getQuestionId());
|
||||||
|
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId(), validHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert zwei eliminierbare, garantiert falsche Antworten ohne die Loesung offenzulegen. */
|
||||||
|
@GetMapping("/hint")
|
||||||
|
public FiftyFiftyHint hint(@RequestParam Long questionId, HttpSession session) {
|
||||||
|
if (Boolean.TRUE.equals(session.getAttribute(JOKER_USED))) {
|
||||||
|
return new FiftyFiftyHint(java.util.List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
FiftyFiftyHint hint = techtacularService.fiftyFifty(questionId);
|
||||||
|
if (!hint.removeAnswerIds().isEmpty()) {
|
||||||
|
session.setAttribute(JOKER_USED, true);
|
||||||
|
Set<Long> hinted = new HashSet<>();
|
||||||
|
hinted.add(questionId);
|
||||||
|
session.setAttribute(HINTED_QUESTION_IDS, hinted);
|
||||||
|
}
|
||||||
|
return hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/finish")
|
@PostMapping("/finish")
|
||||||
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) {
|
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal, HttpSession session) {
|
||||||
return techtacularService.finish(principal.getName(), request);
|
TechtacularResult result = techtacularService.finish(principal.getName(), request, hintedQuestionIds(session));
|
||||||
|
session.removeAttribute(JOKER_USED);
|
||||||
|
session.removeAttribute(HINTED_QUESTION_IDS);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Set<Long> hintedQuestionIds(HttpSession session) {
|
||||||
|
Object value = session.getAttribute(HINTED_QUESTION_IDS);
|
||||||
|
return value instanceof Set<?> ? (Set<Long>) value : Set.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ package com.example.mindflux.dto;
|
|||||||
public class AnswerRequest {
|
public class AnswerRequest {
|
||||||
private Long questionId;
|
private Long questionId;
|
||||||
private Long answerId;
|
private Long answerId;
|
||||||
|
private boolean hintUsed;
|
||||||
|
|
||||||
public Long getQuestionId() { return questionId; }
|
public Long getQuestionId() { return questionId; }
|
||||||
public void setQuestionId(Long questionId) { this.questionId = questionId; }
|
public void setQuestionId(Long questionId) { this.questionId = questionId; }
|
||||||
|
|
||||||
public Long getAnswerId() { return answerId; }
|
public Long getAnswerId() { return answerId; }
|
||||||
public void setAnswerId(Long answerId) { this.answerId = answerId; }
|
public void setAnswerId(Long answerId) { this.answerId = answerId; }
|
||||||
|
|
||||||
|
/** Der Client zeigt damit an, dass fuer diese Antwort der 50:50-Joker aktiv war. */
|
||||||
|
public boolean isHintUsed() { return hintUsed; }
|
||||||
|
public void setHintUsed(boolean hintUsed) { this.hintUsed = hintUsed; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
public class BugHunterCheckRequest {
|
||||||
|
private Long bugId;
|
||||||
|
private String phase;
|
||||||
|
private boolean reveal;
|
||||||
|
private JsonNode submission;
|
||||||
|
public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; }
|
||||||
|
public String getPhase() { return phase; } public void setPhase(String value) { phase = value; }
|
||||||
|
public boolean isReveal() { return reveal; } public void setReveal(boolean value) { reveal = value; }
|
||||||
|
public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Rohantworten eines BugHunter-Durchlaufs; die Wertung wird komplett serverseitig rekonstruiert. */
|
||||||
|
public class BugHunterFinishRequest {
|
||||||
|
private String slug;
|
||||||
|
private int durationSeconds;
|
||||||
|
private List<Item> items;
|
||||||
|
public String getSlug() { return slug; } public void setSlug(String value) { slug = value; }
|
||||||
|
public int getDurationSeconds() { return durationSeconds; } public void setDurationSeconds(int value) { durationSeconds = value; }
|
||||||
|
public List<Item> getItems() { return items; } public void setItems(List<Item> value) { items = value; }
|
||||||
|
public static class Item {
|
||||||
|
private Long bugId;
|
||||||
|
private Phase locate;
|
||||||
|
private Phase classify;
|
||||||
|
private Phase fix;
|
||||||
|
public Long getBugId() { return bugId; } public void setBugId(Long value) { bugId = value; }
|
||||||
|
public Phase getLocate() { return locate; } public void setLocate(Phase value) { locate = value; }
|
||||||
|
public Phase getClassify() { return classify; } public void setClassify(Phase value) { classify = value; }
|
||||||
|
public Phase getFix() { return fix; } public void setFix(Phase value) { fix = value; }
|
||||||
|
}
|
||||||
|
public static class Phase {
|
||||||
|
private JsonNode submission;
|
||||||
|
private int attempts;
|
||||||
|
private boolean revealed;
|
||||||
|
public JsonNode getSubmission() { return submission; } public void setSubmission(JsonNode value) { submission = value; }
|
||||||
|
public int getAttempts() { return attempts; } public void setAttempts(int value) { attempts = value; }
|
||||||
|
public boolean isRevealed() { return revealed; } public void setRevealed(boolean value) { revealed = value; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
public record BugHunterResult(int score, int baseScore, int maxScore, int comboBonus, int maxCombo,
|
||||||
|
int bugsSolved, int totalBugs, int stars, int highscore,
|
||||||
|
boolean newHighscore, long plays, boolean cleanSolve) { }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Antwort des 50:50-Jokers. Die korrekte Antwort-ID wird niemals uebertragen. */
|
||||||
|
public record FiftyFiftyHint(List<Long> removeAnswerIds) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
public class NetworkCheckRequest { private Long levelId; private boolean reveal; private JsonNode config; public Long getLevelId(){return levelId;} public void setLevelId(Long value){levelId=value;} public boolean isReveal(){return reveal;} public void setReveal(boolean value){reveal=value;} public JsonNode getConfig(){return config;} public void setConfig(JsonNode value){config=value;} }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
import java.util.*;
|
||||||
|
public class NetworkFinishRequest { private String slug; private int durationSeconds; private List<Long> levelIds; public String getSlug(){return slug;} public void setSlug(String value){slug=value;} public int getDurationSeconds(){return durationSeconds;} public void setDurationSeconds(int value){durationSeconds=value;} public List<Long> getLevelIds(){return levelIds;} public void setLevelIds(List<Long> value){levelIds=value;} }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
public record NetworkResult(int score, int maxScore, int solved, int total, int stars, int highscore, boolean newHighscore, long plays, boolean perfect) { }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein bewusst kleiner, nachvollziehbarer Plattformzustand für das Dashboard.
|
||||||
|
* Er behauptet keine fiktive Verfügbarkeit, sondern prüft den Speicher, der
|
||||||
|
* Spielstände und Fortschritt tatsächlich trägt.
|
||||||
|
*/
|
||||||
|
public record PlatformStatus(
|
||||||
|
boolean storageConnected,
|
||||||
|
int registeredModules,
|
||||||
|
Instant checkedAt
|
||||||
|
) {
|
||||||
|
public boolean ready() {
|
||||||
|
return storageConnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
package com.example.mindflux.dto;
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
|
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
|
||||||
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
|
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
|
||||||
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
|
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
|
||||||
|
* Die Constraints fangen offensichtlich unsinnige Eingaben schon vor der Logik ab.
|
||||||
*/
|
*/
|
||||||
public class ScoreSubmission {
|
public class ScoreSubmission {
|
||||||
|
@NotBlank @Size(max = 100)
|
||||||
private String deck;
|
private String deck;
|
||||||
|
@Min(0) @Max(1000)
|
||||||
private int pairs;
|
private int pairs;
|
||||||
|
@Min(0) @Max(100000)
|
||||||
private int moves;
|
private int moves;
|
||||||
|
@Min(0) @Max(86400)
|
||||||
private int durationSeconds;
|
private int durationSeconds;
|
||||||
|
|
||||||
public String getDeck() { return deck; }
|
public String getDeck() { return deck; }
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
public class ServiceClashCheckRequest { private Long ticketId; private String step; private String value; private boolean reveal; public Long getTicketId(){return ticketId;} public void setTicketId(Long value){ticketId=value;} public String getStep(){return step;} public void setStep(String value){step=value;} public String getValue(){return value;} public void setValue(String value){this.value=value;} public boolean isReveal(){return reveal;} public void setReveal(boolean value){reveal=value;} }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
public class ServiceClashFinishRequest { private String slug; private int durationSeconds; public String getSlug(){return slug;} public void setSlug(String value){slug=value;} public int getDurationSeconds(){return durationSeconds;} public void setDurationSeconds(int value){durationSeconds=value;} }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
public record ServiceClashResult(int score,int maxScore,int stars,int resolved,int total,int health,int highscore,boolean newHighscore,long plays,boolean flawless) { }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.mindflux.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Tatsächlich aufgezeichnete Aktivität von Montag bis Sonntag. */
|
||||||
|
public record WeeklyActivity(List<Integer> minutes, List<Integer> xp, int totalMinutes, int totalXp) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "account_email_tokens")
|
||||||
|
public class AccountEmailToken {
|
||||||
|
public enum Purpose { EMAIL_VERIFICATION, PASSWORD_RESET }
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private Purpose purpose;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "consumed_at")
|
||||||
|
private Instant consumedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public User getUser() { return user; }
|
||||||
|
public void setUser(User user) { this.user = user; }
|
||||||
|
public String getTokenHash() { return tokenHash; }
|
||||||
|
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||||
|
public Purpose getPurpose() { return purpose; }
|
||||||
|
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
|
||||||
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||||
|
public Instant getConsumedAt() { return consumedAt; }
|
||||||
|
public void setConsumedAt(Instant consumedAt) { this.consumedAt = consumedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "bughunter_bugs")
|
||||||
|
public class BugHunterBug {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt;
|
||||||
|
private int points = 100;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
@Column(length = 8000) private String payload;
|
||||||
|
public BugHunterBug() { }
|
||||||
|
public BugHunterBug(int points, int sortOrder, String payload) { this.points = points; this.sortOrder = sortOrder; this.payload = payload; }
|
||||||
|
public Long getId() { return id; } public void setId(Long value) { id = value; }
|
||||||
|
public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; }
|
||||||
|
public int getPoints() { return points; } public void setPoints(int value) { points = value; }
|
||||||
|
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||||
|
public String getPayload() { return payload; } public void setPayload(String value) { payload = value; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "bughunter_files")
|
||||||
|
public class BugHunterFile {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hunt_id", nullable = false) private BugHunterHunt hunt;
|
||||||
|
private String filename;
|
||||||
|
@Column(length = 8000) private String content;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
public BugHunterFile() { }
|
||||||
|
public BugHunterFile(String filename, String content, int sortOrder) { this.filename = filename; this.content = content; this.sortOrder = sortOrder; }
|
||||||
|
public Long getId() { return id; } public void setId(Long value) { id = value; }
|
||||||
|
public BugHunterHunt getHunt() { return hunt; } public void setHunt(BugHunterHunt value) { hunt = value; }
|
||||||
|
public String getFilename() { return filename; } public void setFilename(String value) { filename = value; }
|
||||||
|
public String getContent() { return content; } public void setContent(String value) { content = value; }
|
||||||
|
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "bughunter_hunts")
|
||||||
|
public class BugHunterHunt {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@Column(nullable = false, unique = true) private String slug;
|
||||||
|
@Column(nullable = false) private String title;
|
||||||
|
@Column(name = "code_language") private String codeLanguage;
|
||||||
|
private String difficulty;
|
||||||
|
@Column(name = "exam_scope") private String examScope = "AP1";
|
||||||
|
private String description;
|
||||||
|
@Column(length = 2000) private String briefing;
|
||||||
|
@Column(name = "console_output", length = 4000) private String consoleOutput;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
private boolean active = true;
|
||||||
|
@OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("sortOrder ASC, id ASC") private List<BugHunterFile> files = new ArrayList<>();
|
||||||
|
@OneToMany(mappedBy = "hunt", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("sortOrder ASC, id ASC") private List<BugHunterBug> bugs = new ArrayList<>();
|
||||||
|
|
||||||
|
public BugHunterHunt() { }
|
||||||
|
public BugHunterHunt(String slug, String title, String codeLanguage, String difficulty, String examScope,
|
||||||
|
String description, String briefing, String consoleOutput, int sortOrder) {
|
||||||
|
this.slug = slug; this.title = title; this.codeLanguage = codeLanguage; this.difficulty = difficulty;
|
||||||
|
this.examScope = examScope; this.description = description; this.briefing = briefing;
|
||||||
|
this.consoleOutput = consoleOutput; this.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
public void addFile(BugHunterFile file) { file.setHunt(this); files.add(file); }
|
||||||
|
public void addBug(BugHunterBug bug) { bug.setHunt(this); bugs.add(bug); }
|
||||||
|
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 getCodeLanguage() { return codeLanguage; } public void setCodeLanguage(String value) { codeLanguage = value; }
|
||||||
|
public String getDifficulty() { return difficulty; } public void setDifficulty(String value) { difficulty = value; }
|
||||||
|
public String getExamScope() { return examScope; } public void setExamScope(String value) { examScope = value; }
|
||||||
|
public String getDescription() { return description; } public void setDescription(String value) { description = value; }
|
||||||
|
public String getBriefing() { return briefing; } public void setBriefing(String value) { briefing = value; }
|
||||||
|
public String getConsoleOutput() { return consoleOutput; } public void setConsoleOutput(String value) { consoleOutput = value; }
|
||||||
|
public int getSortOrder() { return sortOrder; } public void setSortOrder(int value) { sortOrder = value; }
|
||||||
|
public boolean isActive() { return active; } public void setActive(boolean value) { active = value; }
|
||||||
|
public List<BugHunterFile> getFiles() { return files; } public List<BugHunterBug> getBugs() { return bugs; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bughunter_hunt_progress", uniqueConstraints = @UniqueConstraint(columnNames = {"username", "hunt_slug"}))
|
||||||
|
public class BugHunterHuntProgress {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@Column(nullable = false) private String username;
|
||||||
|
@Column(name = "hunt_slug", nullable = false) private String huntSlug;
|
||||||
|
@Column(name = "solved_clean", nullable = false) private boolean solvedClean;
|
||||||
|
@Column(name = "variant_index", nullable = false) private int variantIndex;
|
||||||
|
@Column(name = "solved_at") private LocalDateTime solvedAt;
|
||||||
|
|
||||||
|
public BugHunterHuntProgress() { }
|
||||||
|
public BugHunterHuntProgress(String username, String huntSlug) { this.username = username; this.huntSlug = huntSlug; }
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public String getHuntSlug() { return huntSlug; }
|
||||||
|
public boolean isSolvedClean() { return solvedClean; }
|
||||||
|
public void setSolvedClean(boolean value) { solvedClean = value; }
|
||||||
|
public int getVariantIndex() { return variantIndex; }
|
||||||
|
public void setVariantIndex(int value) { variantIndex = value; }
|
||||||
|
public LocalDateTime getSolvedAt() { return solvedAt; }
|
||||||
|
public void setSolvedAt(LocalDateTime value) { solvedAt = value; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "networker_levels")
|
||||||
|
public class NetworkLevel {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "world_id", nullable = false) private NetworkWorld world;
|
||||||
|
@Column(nullable = false) private String title;
|
||||||
|
@Column(name = "level_type") private String levelType;
|
||||||
|
private int points;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
@Column(length = 8000, nullable = false) private String payload;
|
||||||
|
public NetworkLevel() { }
|
||||||
|
public NetworkLevel(String title, String levelType, int points, int sortOrder, String payload) { this.title=title; this.levelType=levelType; this.points=points; this.sortOrder=sortOrder; this.payload=payload; }
|
||||||
|
public Long getId(){return id;} public void setId(Long value){id=value;} public NetworkWorld getWorld(){return world;} public void setWorld(NetworkWorld value){world=value;} public String getTitle(){return title;} public String getLevelType(){return levelType;} public int getPoints(){return points;} public int getSortOrder(){return sortOrder;} public String getPayload(){return payload;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "networker_worlds")
|
||||||
|
public class NetworkWorld {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@Column(nullable = false, unique = true) private String slug;
|
||||||
|
@Column(nullable = false) private String title;
|
||||||
|
private String description;
|
||||||
|
private String difficulty;
|
||||||
|
@Column(name = "exam_scope") private String examScope;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
private boolean active = true;
|
||||||
|
@OneToMany(mappedBy = "world", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("sortOrder ASC, id ASC") private List<NetworkLevel> levels = new ArrayList<>();
|
||||||
|
public NetworkWorld() { }
|
||||||
|
public NetworkWorld(String slug, String title, String description, String difficulty, String examScope, int sortOrder) { this.slug=slug; this.title=title; this.description=description; this.difficulty=difficulty; this.examScope=examScope; this.sortOrder=sortOrder; }
|
||||||
|
public void addLevel(NetworkLevel level) { level.setWorld(this); levels.add(level); }
|
||||||
|
public Long getId(){return id;} public void setId(Long value){id=value;} public String getSlug(){return slug;} public String getTitle(){return title;} public String getDescription(){return description;} public String getDifficulty(){return difficulty;} public String getExamScope(){return examScope;} public int getSortOrder(){return sortOrder;} public boolean isActive(){return active;} public void setActive(boolean value){active=value;} public List<NetworkLevel> getLevels(){return levels;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "neuralnet_connections")
|
||||||
|
public class NeuralNetConnection {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
private String requester;
|
||||||
|
private String addressee;
|
||||||
|
private String status;
|
||||||
|
@Column(name = "created_at") private LocalDateTime createdAt;
|
||||||
|
@Column(name = "responded_at") private LocalDateTime respondedAt;
|
||||||
|
public NeuralNetConnection() {}
|
||||||
|
public NeuralNetConnection(String requester, String addressee) { this.requester=requester; this.addressee=addressee; this.status="PENDING"; this.createdAt=LocalDateTime.now(); }
|
||||||
|
public Long getId() { return id; } public String getRequester() { return requester; } public String getAddressee() { return addressee; }
|
||||||
|
public String getStatus() { return status; } public void setStatus(String status) { this.status=status; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getRespondedAt() { return respondedAt; }
|
||||||
|
public void setRespondedAt(LocalDateTime respondedAt) { this.respondedAt=respondedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "neuralnet_messages")
|
||||||
|
public class NeuralNetMessage {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
private String sender;
|
||||||
|
private String recipient;
|
||||||
|
private String body;
|
||||||
|
@Column(name = "sent_at") private LocalDateTime sentAt;
|
||||||
|
@Column(name = "read_at") private LocalDateTime readAt;
|
||||||
|
private boolean reported;
|
||||||
|
public NeuralNetMessage() {}
|
||||||
|
public NeuralNetMessage(String sender, String recipient, String body) { this.sender=sender; this.recipient=recipient; this.body=body; this.sentAt=LocalDateTime.now(); }
|
||||||
|
public Long getId() { return id; } public String getSender() { return sender; } public String getRecipient() { return recipient; }
|
||||||
|
public String getBody() { return body; } public LocalDateTime getSentAt() { return sentAt; } public LocalDateTime getReadAt() { return readAt; }
|
||||||
|
public void setReadAt(LocalDateTime readAt) { this.readAt=readAt; } public boolean isReported() { return reported; } public void setReported(boolean reported) { this.reported=reported; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "neuralnet_privacy")
|
||||||
|
public class NeuralNetPrivacy {
|
||||||
|
@Id private String username;
|
||||||
|
private boolean enabled;
|
||||||
|
public NeuralNetPrivacy() {}
|
||||||
|
public NeuralNetPrivacy(String username, boolean enabled) { this.username = username; this.enabled = enabled; }
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "service_clash_shifts")
|
||||||
|
public class ServiceClashShift {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@Column(nullable = false, unique = true) private String slug;
|
||||||
|
@Column(nullable = false) private String title;
|
||||||
|
private String description;
|
||||||
|
private String difficulty;
|
||||||
|
@Column(name = "exam_scope") private String examScope;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
private boolean active = true;
|
||||||
|
@OneToMany(mappedBy = "shift", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("wave ASC, sortOrder ASC, id ASC") private List<ServiceClashTicket> tickets = new ArrayList<>();
|
||||||
|
public ServiceClashShift() { }
|
||||||
|
public ServiceClashShift(String slug, String title, String description, String difficulty, String examScope, int sortOrder) { this.slug=slug;this.title=title;this.description=description;this.difficulty=difficulty;this.examScope=examScope;this.sortOrder=sortOrder; }
|
||||||
|
public void addTicket(ServiceClashTicket ticket) { ticket.setShift(this); tickets.add(ticket); }
|
||||||
|
public Long getId(){return id;} public void setId(Long value){id=value;} public String getSlug(){return slug;} public String getTitle(){return title;} public String getDescription(){return description;} public String getDifficulty(){return difficulty;} public String getExamScope(){return examScope;} public int getSortOrder(){return sortOrder;} public boolean isActive(){return active;} public List<ServiceClashTicket> getTickets(){return tickets;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity @Table(name = "service_clash_tickets")
|
||||||
|
public class ServiceClashTicket {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "shift_id", nullable = false) private ServiceClashShift shift;
|
||||||
|
@Column(nullable = false) private String title;
|
||||||
|
private int wave;
|
||||||
|
private int points;
|
||||||
|
@Column(name = "sort_order") private int sortOrder;
|
||||||
|
@Column(length = 8000, nullable = false) private String payload;
|
||||||
|
public ServiceClashTicket() { }
|
||||||
|
public ServiceClashTicket(String title,int wave,int points,int sortOrder,String payload){this.title=title;this.wave=wave;this.points=points;this.sortOrder=sortOrder;this.payload=payload;}
|
||||||
|
public Long getId(){return id;} public void setId(Long value){id=value;} public void setShift(ServiceClashShift value){shift=value;} public ServiceClashShift getShift(){return shift;} public String getTitle(){return title;} public int getWave(){return wave;} public int getPoints(){return points;} public String getPayload(){return payload;}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ public class User {
|
|||||||
private String email;
|
private String email;
|
||||||
private String role;
|
private String role;
|
||||||
|
|
||||||
|
@Column(name = "email_verified", nullable = false)
|
||||||
|
private boolean emailVerified = true;
|
||||||
|
|
||||||
@Column(name = "profile_picture")
|
@Column(name = "profile_picture")
|
||||||
private String profilePicture;
|
private String profilePicture;
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ public class User {
|
|||||||
public String getRole() { return role; }
|
public String getRole() { return role; }
|
||||||
public void setRole(String r) { this.role = r; }
|
public void setRole(String r) { this.role = r; }
|
||||||
|
|
||||||
|
public boolean isEmailVerified() { return emailVerified; }
|
||||||
|
public void setEmailVerified(boolean emailVerified) { this.emailVerified = emailVerified; }
|
||||||
|
|
||||||
public String getProfilePicture() { return profilePicture; }
|
public String getProfilePicture() { return profilePicture; }
|
||||||
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
|
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.AccountEmailToken;
|
||||||
|
import com.example.mindflux.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AccountEmailTokenRepository extends JpaRepository<AccountEmailToken, Long> {
|
||||||
|
Optional<AccountEmailToken> findByTokenHashAndPurposeAndConsumedAtIsNull(
|
||||||
|
String tokenHash, AccountEmailToken.Purpose purpose);
|
||||||
|
|
||||||
|
void deleteByUserAndPurpose(User user, AccountEmailToken.Purpose purpose);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.BugHunterBug;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
public interface BugHunterBugRepository extends JpaRepository<BugHunterBug, Long> { long countByHuntId(Long huntId); }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.BugHunterFile;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
public interface BugHunterFileRepository extends JpaRepository<BugHunterFile, Long> { long countByHuntId(Long huntId); }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.BugHunterHuntProgress;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public interface BugHunterHuntProgressRepository extends JpaRepository<BugHunterHuntProgress, Long> {
|
||||||
|
Optional<BugHunterHuntProgress> findByUsernameAndHuntSlug(String username, String huntSlug);
|
||||||
|
List<BugHunterHuntProgress> findAllByUsername(String username);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.BugHunterHunt;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.*;
|
||||||
|
public interface BugHunterHuntRepository extends JpaRepository<BugHunterHunt, Long> {
|
||||||
|
List<BugHunterHunt> findAllByActiveTrueOrderBySortOrderAscIdAsc();
|
||||||
|
List<BugHunterHunt> findAllByOrderBySortOrderAscIdAsc();
|
||||||
|
Optional<BugHunterHunt> findBySlug(String slug);
|
||||||
|
boolean existsBySlug(String slug);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
|
public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
|
||||||
|
|
||||||
@@ -19,9 +20,16 @@ public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
|
|||||||
/** Wie oft ein Spieler ein Spiel abgeschlossen hat. */
|
/** Wie oft ein Spieler ein Spiel abgeschlossen hat. */
|
||||||
long countByUsernameAndGame(String username, String game);
|
long countByUsernameAndGame(String username, String game);
|
||||||
|
|
||||||
|
/** Abgeschlossene Runden eines Zeitraums – Basis für Spielzeit und Wochenverlauf. */
|
||||||
|
List<GameScore> findByUsernameAndPlayedAtGreaterThanEqualAndPlayedAtLessThanOrderByPlayedAtAsc(
|
||||||
|
String username, LocalDateTime from, LocalDateTime until);
|
||||||
|
|
||||||
/** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */
|
/** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */
|
||||||
void deleteByUsername(String username);
|
void deleteByUsername(String username);
|
||||||
|
|
||||||
|
/** Alle Spielstände eines Spielers (für den DSGVO-Datenexport). */
|
||||||
|
List<GameScore> findByUsernameOrderByPlayedAtDesc(String username);
|
||||||
|
|
||||||
/** Gesamtsumme aller Scores eines Spielers (XP-Basis). */
|
/** Gesamtsumme aller Scores eines Spielers (XP-Basis). */
|
||||||
@Query("select coalesce(sum(g.score), 0) from GameScore g where g.username = :username")
|
@Query("select coalesce(sum(g.score), 0) from GameScore g where g.username = :username")
|
||||||
long sumScoreByUsername(@Param("username") String username);
|
long sumScoreByUsername(@Param("username") String username);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.NetworkLevel;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
public interface NetworkLevelRepository extends JpaRepository<NetworkLevel, Long> { }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.NetworkWorld;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.*;
|
||||||
|
public interface NetworkWorldRepository extends JpaRepository<NetworkWorld, Long> { List<NetworkWorld> findAllByActiveTrueOrderBySortOrderAscIdAsc(); Optional<NetworkWorld> findBySlug(String slug); }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.NeuralNetConnection;
|
||||||
|
import org.springframework.data.jpa.repository.*;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import java.util.*;
|
||||||
|
public interface NeuralNetConnectionRepository extends JpaRepository<NeuralNetConnection, Long> {
|
||||||
|
Optional<NeuralNetConnection> findByRequesterAndAddressee(String requester, String addressee);
|
||||||
|
List<NeuralNetConnection> findByAddresseeAndStatusOrderByCreatedAtAsc(String addressee, String status);
|
||||||
|
List<NeuralNetConnection> findByRequesterAndStatusOrderByCreatedAtAsc(String requester, String status);
|
||||||
|
@Query("select c from NeuralNetConnection c where c.status='ACCEPTED' and ((c.requester=:username) or (c.addressee=:username)) order by c.respondedAt desc")
|
||||||
|
List<NeuralNetConnection> acceptedFor(@Param("username") String username);
|
||||||
|
@Query("select c from NeuralNetConnection c where c.status='ACCEPTED' and ((c.requester=:a and c.addressee=:b) or (c.requester=:b and c.addressee=:a))")
|
||||||
|
Optional<NeuralNetConnection> acceptedBetween(@Param("a") String a, @Param("b") String b);
|
||||||
|
@Query("select c from NeuralNetConnection c where c.requester=:u or c.addressee=:u order by c.createdAt asc")
|
||||||
|
List<NeuralNetConnection> findAllInvolving(@Param("u") String u);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.NeuralNetMessage;
|
||||||
|
import org.springframework.data.jpa.repository.*;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import java.util.*;
|
||||||
|
public interface NeuralNetMessageRepository extends JpaRepository<NeuralNetMessage, Long> {
|
||||||
|
@Query("select m from NeuralNetMessage m where (m.sender=:a and m.recipient=:b) or (m.sender=:b and m.recipient=:a) order by m.sentAt asc")
|
||||||
|
List<NeuralNetMessage> conversation(@Param("a") String a, @Param("b") String b);
|
||||||
|
long countByRecipientAndReadAtIsNull(String recipient);
|
||||||
|
@Query("select m from NeuralNetMessage m where m.sender=:u or m.recipient=:u order by m.sentAt asc")
|
||||||
|
List<NeuralNetMessage> findAllInvolving(@Param("u") String u);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.NeuralNetPrivacy;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
public interface NeuralNetPrivacyRepository extends JpaRepository<NeuralNetPrivacy, String> {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
import com.example.mindflux.model.ServiceClashShift;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.*;
|
||||||
|
public interface ServiceClashShiftRepository extends JpaRepository<ServiceClashShift,Long> { Optional<ServiceClashShift> findBySlug(String slug); List<ServiceClashShift> findAllByActiveTrueOrderBySortOrderAscIdAsc(); }
|
||||||
@@ -2,6 +2,7 @@ package com.example.mindflux.repository;
|
|||||||
|
|
||||||
import com.example.mindflux.model.User;
|
import com.example.mindflux.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
@@ -12,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
|
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
|
||||||
Optional<User> findByUsernameIgnoreCase(String username);
|
Optional<User> findByUsernameIgnoreCase(String username);
|
||||||
|
|
||||||
|
List<User> findAllByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
// Benutzer anhand des Benutzernamens löschen
|
// Benutzer anhand des Benutzernamens löschen
|
||||||
void deleteByUsername(String username);
|
void deleteByUsername(String username);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.AccountEmailToken;
|
||||||
|
import com.example.mindflux.model.User;
|
||||||
|
import com.example.mindflux.repository.AccountEmailTokenRepository;
|
||||||
|
import com.example.mindflux.repository.UserRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AccountMailService {
|
||||||
|
|
||||||
|
private static final Duration TOKEN_LIFETIME = Duration.ofHours(24);
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AccountMailService.class);
|
||||||
|
private final AccountEmailTokenRepository tokenRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
private final String publicUrl;
|
||||||
|
private final String fromAddress;
|
||||||
|
|
||||||
|
public AccountMailService(AccountEmailTokenRepository tokenRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
JavaMailSender mailSender,
|
||||||
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
|
@Value("${mindflux.public-url}") String publicUrl,
|
||||||
|
@Value("${mindflux.mail.from}") String fromAddress) {
|
||||||
|
this.tokenRepository = tokenRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.publicUrl = publicUrl.replaceAll("/+$", "");
|
||||||
|
this.fromAddress = fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean sendVerificationFor(User user) {
|
||||||
|
if (user == null) {
|
||||||
|
log.error("mail_event=VERIFICATION_SKIPPED reason=USER_NOT_FOUND_AFTER_REGISTRATION");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.isEmailVerified()) {
|
||||||
|
log.info("mail_event=VERIFICATION_SKIPPED reason=ALREADY_VERIFIED userId={}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IssuedToken issued = issueToken(user, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
|
||||||
|
send(user, issued, "Bestätige deinen Mindflux-Account",
|
||||||
|
"Hallo " + user.getUsername() + ",\n\n"
|
||||||
|
+ "bitte bestätige deine E-Mail-Adresse, um deinen Account freizuschalten:\n"
|
||||||
|
+ publicUrl + "/verify-email?token=" + issued.rawToken() + "\n\n"
|
||||||
|
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
|
||||||
|
+ "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resendVerification(String email) {
|
||||||
|
Optional<User> user = uniqueUserForEmail(email);
|
||||||
|
if (user.isEmpty()) {
|
||||||
|
log.info("mail_event=VERIFICATION_RESEND_IGNORED reason=NO_UNIQUE_USER recipientFingerprint={}", recipientFingerprint(email));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (user.get().isEmailVerified()) {
|
||||||
|
log.info("mail_event=VERIFICATION_RESEND_IGNORED reason=ALREADY_VERIFIED userId={}", user.get().getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("mail_event=VERIFICATION_RESEND_REQUESTED userId={}", user.get().getId());
|
||||||
|
sendVerificationFor(user.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void requestPasswordReset(String email) {
|
||||||
|
uniqueUserForEmail(email).filter(User::isEmailVerified).ifPresent(user -> {
|
||||||
|
IssuedToken issued = issueToken(user, AccountEmailToken.Purpose.PASSWORD_RESET);
|
||||||
|
send(user, issued, "Passwort für deinen Mindflux-Account zurücksetzen",
|
||||||
|
"Hallo " + user.getUsername() + ",\n\n"
|
||||||
|
+ "über diesen Link kannst du ein neues Passwort vergeben:\n"
|
||||||
|
+ publicUrl + "/reset-password?token=" + issued.rawToken() + "\n\n"
|
||||||
|
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
|
||||||
|
+ "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean verifyEmail(String rawToken) {
|
||||||
|
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AccountEmailToken value = token.get();
|
||||||
|
User user = value.getUser();
|
||||||
|
user.setEmailVerified(true);
|
||||||
|
value.setConsumedAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
tokenRepository.save(value);
|
||||||
|
log.info("mail_event=VERIFICATION_CONFIRMED userId={} tokenId={}", user.getId(), value.getId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean resetPassword(String rawToken, String password, String confirmPassword) {
|
||||||
|
if (password == null || password.length() < 8 || !password.equals(confirmPassword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET);
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AccountEmailToken value = token.get();
|
||||||
|
User user = value.getUser();
|
||||||
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
value.setConsumedAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
tokenRepository.save(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isResetTokenValid(String rawToken) {
|
||||||
|
return validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<User> uniqueUserForEmail(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<User> users = userRepository.findAllByEmailIgnoreCase(email.trim());
|
||||||
|
return users.size() == 1 ? Optional.of(users.getFirst()) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IssuedToken issueToken(User user, AccountEmailToken.Purpose purpose) {
|
||||||
|
tokenRepository.deleteByUserAndPurpose(user, purpose);
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
AccountEmailToken token = new AccountEmailToken();
|
||||||
|
token.setUser(user);
|
||||||
|
token.setPurpose(purpose);
|
||||||
|
token.setTokenHash(hash(rawToken));
|
||||||
|
token.setExpiresAt(Instant.now().plus(TOKEN_LIFETIME));
|
||||||
|
tokenRepository.save(token);
|
||||||
|
log.info("mail_event=TOKEN_ISSUED purpose={} userId={} tokenId={} expiresAt={}",
|
||||||
|
purpose, user.getId(), token.getId(), token.getExpiresAt());
|
||||||
|
return new IssuedToken(rawToken, token.getId(), purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccountEmailToken> validToken(String rawToken, AccountEmailToken.Purpose purpose) {
|
||||||
|
if (rawToken == null || rawToken.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return tokenRepository.findByTokenHashAndPurposeAndConsumedAtIsNull(hash(rawToken), purpose)
|
||||||
|
.filter(token -> token.getExpiresAt().isAfter(Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(User user, IssuedToken issued, String subject, String body) {
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(fromAddress);
|
||||||
|
message.setTo(user.getEmail());
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setText(body);
|
||||||
|
log.info("mail_event=SMTP_SUBMISSION_STARTED purpose={} userId={} tokenId={} recipientFingerprint={}",
|
||||||
|
issued.purpose(), user.getId(), issued.tokenId(), recipientFingerprint(user.getEmail()));
|
||||||
|
try {
|
||||||
|
mailSender.send(message);
|
||||||
|
// JavaMail bestätigt hier nur die Annahme durch den konfigurierten SMTP-Server,
|
||||||
|
// nicht die spätere Zustellung im Postfach. Für Bounces sind dessen Logs/DSN nötig.
|
||||||
|
log.info("mail_event=SMTP_SUBMISSION_ACCEPTED purpose={} userId={} tokenId={}",
|
||||||
|
issued.purpose(), user.getId(), issued.tokenId());
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
log.error("mail_event=SMTP_SUBMISSION_FAILED purpose={} userId={} tokenId={} errorType={} detail={}",
|
||||||
|
issued.purpose(), user.getId(), issued.tokenId(), exception.getClass().getSimpleName(),
|
||||||
|
safeFailureDetail(exception.getMessage()));
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String recipientFingerprint(String email) {
|
||||||
|
return hash(email == null ? "" : email.trim().toLowerCase()).substring(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeFailureDetail(String detail) {
|
||||||
|
if (detail == null || detail.isBlank()) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
String sanitized = detail.replaceAll("(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", "[redacted-email]")
|
||||||
|
.replaceAll("[\\r\\n]+", " ");
|
||||||
|
return sanitized.substring(0, Math.min(sanitized.length(), 240));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record IssuedToken(String rawToken, Long tokenId, AccountEmailToken.Purpose purpose) { }
|
||||||
|
|
||||||
|
private String hash(String value) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return java.util.HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 ist nicht verfügbar", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,4 +14,5 @@ public interface AchievementService {
|
|||||||
* Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen.
|
* Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen.
|
||||||
*/
|
*/
|
||||||
void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame);
|
void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame);
|
||||||
|
void awardSpecial(String username, String title, String description);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ public class AchievementServiceImpl implements AchievementService {
|
|||||||
CATALOG.put("Dauerbrenner", "Spiele dasselbe Spiel zehnmal.");
|
CATALOG.put("Dauerbrenner", "Spiele dasselbe Spiel zehnmal.");
|
||||||
CATALOG.put("Punktesammler", "Sammle insgesamt 5000 Punkte.");
|
CATALOG.put("Punktesammler", "Sammle insgesamt 5000 Punkte.");
|
||||||
CATALOG.put("Allrounder", "Spiele vier verschiedene Spiele.");
|
CATALOG.put("Allrounder", "Spiele vier verschiedene Spiele.");
|
||||||
|
CATALOG.put("Gedächtnis-Impuls", "Schließe eine MindMatch-Runde mit Punkten ab.");
|
||||||
|
CATALOG.put("Quiz-Kapitän", "Schließe eine TechTacular-Runde mit Punkten ab.");
|
||||||
|
CATALOG.put("Werkbank aktiv", "Schließe eine DevLab-Runde mit Punkten ab.");
|
||||||
|
CATALOG.put("Syntax-Spur", "Schließe eine SyntaxSaga-Runde mit Punkten ab.");
|
||||||
|
CATALOG.put("Fluchtprotokoll", "Schließe eine SystemEscape-Runde mit Punkten ab.");
|
||||||
|
CATALOG.put("Erste Fallakte", "Schließe eine BugHunter-Akte mit Punkten ab.");
|
||||||
|
CATALOG.put("Leitstand-Einsatz", "Schließe eine ServiceClash-Schicht mit Punkten ab.");
|
||||||
|
CATALOG.put("Incident Commander", "Löse eine ServiceClash-Schicht ohne Fehler oder Aufdecken.");
|
||||||
|
CATALOG.put("Netzwerk-Neustart", "Schließe eine NetWorker-Welt mit Punkten ab.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private final AchievementRepository achievementRepository;
|
private final AchievementRepository achievementRepository;
|
||||||
@@ -70,6 +79,17 @@ public class AchievementServiceImpl implements AchievementService {
|
|||||||
gameScoreRepository.sumScoreByUsername(username) >= 5000);
|
gameScoreRepository.sumScoreByUsername(username) >= 5000);
|
||||||
award(existing, username, "Allrounder",
|
award(existing, username, "Allrounder",
|
||||||
gameScoreRepository.countDistinctGamesByUsername(username) >= 4);
|
gameScoreRepository.countDistinctGamesByUsername(username) >= 4);
|
||||||
|
String gameAchievement = Map.of(
|
||||||
|
"mindmatch", "Gedächtnis-Impuls", "techtacular", "Quiz-Kapitän", "devlab", "Werkbank aktiv",
|
||||||
|
"syntax-saga", "Syntax-Spur", "codeescape", "Fluchtprotokoll", "bughunter", "Erste Fallakte",
|
||||||
|
"serviceclash", "Leitstand-Einsatz", "networker", "Netzwerk-Neustart").get(game);
|
||||||
|
if (gameAchievement != null) award(existing, username, gameAchievement, score > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override @Transactional
|
||||||
|
public void awardSpecial(String username, String title, String description) {
|
||||||
|
Set<String> existing = achievementRepository.findByUsername(username).stream().map(Achievement::getTitle).collect(Collectors.toSet());
|
||||||
|
if (!existing.contains(title)) { Achievement achievement = new Achievement(title, description, null, true); achievement.setUsername(username); achievementRepository.save(achievement); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void award(Set<String> existing, String username, String title, boolean condition) {
|
private void award(Set<String> existing, String username, String title, boolean condition) {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kapselt die gesamte Profilbild-Logik (Dekodierung, Skalierung, Datei-I/O, Pfad-Auflösung),
|
||||||
|
* die zuvor mehrfach im ProfileController dupliziert war. Der Controller bleibt so schlank
|
||||||
|
* und die Bildverarbeitung ist an einer Stelle testbar/änderbar.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AvatarService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AvatarService.class);
|
||||||
|
private static final int MAX_DIM = 400;
|
||||||
|
|
||||||
|
/** Basisverzeichnis der Profilbilder – eine einzige Quelle der Wahrheit für den Pfad. */
|
||||||
|
private Path baseDir() {
|
||||||
|
return Paths.get(System.getProperty("user.dir"), "uploads", "profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Voller Pfad zu einem gespeicherten Profilbild. */
|
||||||
|
public Path resolve(String fileName) {
|
||||||
|
return baseDir().resolve(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitet einen Bild-Upload: liest das Bild, skaliert auf max. Kantenlänge herunter,
|
||||||
|
* löscht ein evtl. vorhandenes altes Bild und speichert normalisiert als JPEG.
|
||||||
|
*
|
||||||
|
* @return der neue Dateiname, oder {@code null}, wenn kein/kein lesbares Bild geliefert wurde.
|
||||||
|
*/
|
||||||
|
public String store(MultipartFile file, String oldFileName) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BufferedImage original = ImageIO.read(file.getInputStream());
|
||||||
|
if (original == null) {
|
||||||
|
log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
|
||||||
|
delete(oldFileName); // altes Bild aufräumen
|
||||||
|
|
||||||
|
String fileName = UUID.randomUUID() + ".jpg";
|
||||||
|
Path dir = baseDir();
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
ImageIO.write(scaled, "jpg", dir.resolve(fileName).toFile());
|
||||||
|
return fileName;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Fehler beim Speichern des Profilbilds", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht ein Profilbild, falls Name gesetzt und Datei vorhanden. Fehler werden nur geloggt. */
|
||||||
|
public void delete(String fileName) {
|
||||||
|
if (fileName == null || fileName.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path path = resolve(fileName);
|
||||||
|
if (Files.deleteIfExists(path)) {
|
||||||
|
log.info("Profilbild gelöscht: {}", path.getFileName());
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Fehler beim Löschen der Profilbild-Datei", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.*;
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.*;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Serverseitige Drei-Phasen-Ermittlung für BugHunter. Lösungsdaten bleiben im Bug-Payload. */
|
||||||
|
@Service
|
||||||
|
public class BugHunterService {
|
||||||
|
private static final String GAME = "bughunter";
|
||||||
|
private static final Set<String> CATEGORIES = Set.of("syntax", "logik", "laufzeit", "sicherheit");
|
||||||
|
private static final Set<String> SCOPES = Set.of("AP1", "AP2_FIAE", "AP2_FISI", "CROSS");
|
||||||
|
private static final Map<String, Integer> DIFFICULTY_ORDER = Map.of("junior", 1, "senior", 2, "principal", 3);
|
||||||
|
private static final int MAX_DURATION_SECONDS = 14_400;
|
||||||
|
private final BugHunterHuntRepository hunts;
|
||||||
|
private final BugHunterFileRepository files;
|
||||||
|
private final BugHunterBugRepository bugs;
|
||||||
|
private final BugHunterHuntProgressRepository progress;
|
||||||
|
private final GameScoreService scores;
|
||||||
|
private final AchievementService achievements;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final Random random = new Random();
|
||||||
|
|
||||||
|
public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs,
|
||||||
|
BugHunterHuntProgressRepository progress, GameScoreService scores, AchievementService achievements, ObjectMapper mapper) {
|
||||||
|
this.hunts = hunts; this.files = files; this.bugs = bugs; this.progress = progress; this.scores = scores; this.achievements = achievements; this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public JsonNode catalog(String username) {
|
||||||
|
ObjectNode root = mapper.createObjectNode(); ArrayNode result = root.putArray("hunts");
|
||||||
|
Map<String, BugHunterHuntProgress> bySlug = new HashMap<>();
|
||||||
|
if (!blank(username)) for (BugHunterHuntProgress item : progress.findAllByUsername(username)) bySlug.put(item.getHuntSlug(), item);
|
||||||
|
List<BugHunterHunt> ordered = new ArrayList<>(hunts.findAllByActiveTrueOrderBySortOrderAscIdAsc());
|
||||||
|
ordered.sort(Comparator.comparingInt((BugHunterHunt hunt) -> DIFFICULTY_ORDER.getOrDefault(hunt.getDifficulty(), Integer.MAX_VALUE)).thenComparingInt(BugHunterHunt::getSortOrder));
|
||||||
|
for (BugHunterHunt hunt : ordered) {
|
||||||
|
if (hunt.getBugs().isEmpty() || hunt.getFiles().isEmpty()) continue;
|
||||||
|
ObjectNode row = result.addObject(); row.put("slug", hunt.getSlug()); row.put("title", hunt.getTitle());
|
||||||
|
row.put("language", hunt.getCodeLanguage()); row.put("difficulty", hunt.getDifficulty());
|
||||||
|
row.put("examScope", hunt.getExamScope()); row.put("description", hunt.getDescription());
|
||||||
|
row.put("bugCount", hunt.getBugs().size()); row.put("fileCount", hunt.getFiles().size());
|
||||||
|
row.put("solved", Optional.ofNullable(bySlug.get(hunt.getSlug())).map(BugHunterHuntProgress::isSolvedClean).orElse(false));
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public JsonNode session(String slug, String username, HttpSession httpSession) {
|
||||||
|
ObjectNode root = mapper.createObjectNode();
|
||||||
|
BugHunterHunt hunt = hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElse(null);
|
||||||
|
if (hunt == null) { root.put("found", false); return root; }
|
||||||
|
root.put("found", true); root.put("slug", hunt.getSlug()); root.put("title", hunt.getTitle());
|
||||||
|
root.put("language", hunt.getCodeLanguage()); root.put("difficulty", hunt.getDifficulty()); root.put("examScope", hunt.getExamScope());
|
||||||
|
root.put("description", nullToEmpty(hunt.getDescription())); root.put("briefing", nullToEmpty(hunt.getBriefing()));
|
||||||
|
root.put("consoleOutput", nullToEmpty(hunt.getConsoleOutput()));
|
||||||
|
int variant = variantFor(username, hunt.getSlug()); root.put("variant", variant);
|
||||||
|
Map<String, Integer> offsets = new HashMap<>(); for (BugHunterFile file : hunt.getFiles()) offsets.put(file.getFilename(), lineOffset(file.getFilename(), variant));
|
||||||
|
List<BugHunterFile> orderedFiles = new ArrayList<>(hunt.getFiles()); if (orderedFiles.size() > 1 && variant > 0) Collections.rotate(orderedFiles, -Math.floorMod(variant, orderedFiles.size()));
|
||||||
|
ArrayNode fs = root.putArray("files");
|
||||||
|
for (BugHunterFile file : orderedFiles) { ObjectNode f = fs.addObject(); f.put("filename", file.getFilename()); ArrayNode lines = f.putArray("lines"); for (String line : variantContent(file.getContent(), variant, offsets.get(file.getFilename())).split("\\R", -1)) lines.add(line); }
|
||||||
|
ArrayNode bs = root.putArray("bugs"); int maxScore = 0;
|
||||||
|
Map<Long, JsonNode> effectivePayloads = new HashMap<>();
|
||||||
|
for (BugHunterBug bug : hunt.getBugs()) { JsonNode payload = parse(bug.getPayload()); if (payload == null) continue; ObjectNode effective = payload.deepCopy(); effective.put("line", payload.path("line").asInt() + offsets.getOrDefault(payload.path("file").asText(), 0)); effectivePayloads.put(bug.getId(), effective); ObjectNode b = bs.addObject(); b.put("bugId", bug.getId()); b.put("points", bug.getPoints()); b.put("evidence", effective.path("evidence").asText("")); maxScore += bug.getPoints(); }
|
||||||
|
root.put("totalBugs", bs.size()); root.put("maxScore", maxScore);
|
||||||
|
Set<Long> bugIds = new HashSet<>(); bs.forEach(b -> bugIds.add(b.path("bugId").asLong()));
|
||||||
|
httpSession.setAttribute("bughunter.run", new RunState(hunt.getSlug(), bugIds, effectivePayloads, System.currentTimeMillis()));
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public JsonNode check(Long bugId, String phase, boolean reveal, JsonNode submission, HttpSession httpSession) {
|
||||||
|
if (!isPhase(phase)) throw new IllegalArgumentException("Unbekannte Ermittlungsphase.");
|
||||||
|
RunState run = run(httpSession);
|
||||||
|
if (bugId == null || !run.bugIds.contains(bugId)) throw new IllegalArgumentException("Bug gehört nicht zu dieser Fallakte.");
|
||||||
|
JsonNode payload = run.payload(bugId);
|
||||||
|
if (payload == null) throw new IllegalArgumentException("Fallakte nicht gefunden.");
|
||||||
|
PhaseState phaseState = run.state(bugId, phase);
|
||||||
|
if ((phase.equals("classify") && !run.state(bugId, "locate").done) || (phase.equals("fix") && !run.state(bugId, "classify").done)) throw new IllegalArgumentException("Schließe zuerst die vorherige Ermittlungsphase ab.");
|
||||||
|
if (phaseState.done) throw new IllegalArgumentException("Diese Ermittlungsphase ist bereits abgeschlossen.");
|
||||||
|
if (!reveal && phaseState.attempts >= 3) throw new IllegalArgumentException("Nach drei Fehlversuchen kannst du die Auflösung anzeigen.");
|
||||||
|
JsonNode sub = submission == null ? mapper.createObjectNode() : submission;
|
||||||
|
boolean correct = switch (phase) { case "locate" -> locationMatches(payload, sub); case "classify" -> categoryMatches(payload, sub); case "fix" -> fixMatches(payload, sub); default -> false; };
|
||||||
|
if (reveal) { phaseState.revealed = true; phaseState.done = true; }
|
||||||
|
else { phaseState.attempts++; phaseState.submission = sub.toString(); if (correct) phaseState.done = true; }
|
||||||
|
ObjectNode result = mapper.createObjectNode(); result.put("correct", correct); result.put("resolved", correct || reveal); result.put("phase", phase);
|
||||||
|
if (correct && payload.hasNonNull("successText")) result.put("successText", payload.get("successText").asText());
|
||||||
|
if (correct || reveal) addPhaseResolution(result, payload, phase);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BugHunterResult finish(String username, BugHunterFinishRequest request, HttpSession httpSession) {
|
||||||
|
if (request == null || request.getSlug() == null || request.getSlug().isBlank()) throw new IllegalArgumentException("Fallakte fehlt.");
|
||||||
|
if (request.getDurationSeconds() < 1 || request.getDurationSeconds() > MAX_DURATION_SECONDS) throw new IllegalArgumentException("Unplausible Spieldauer.");
|
||||||
|
BugHunterHunt hunt = hunts.findBySlug(request.getSlug()).filter(BugHunterHunt::isActive)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Fallakte nicht gefunden."));
|
||||||
|
RunState run = run(httpSession);
|
||||||
|
if (!hunt.getSlug().equals(run.slug)) throw new IllegalArgumentException("Die Fallakte wurde nicht angefordert.");
|
||||||
|
List<BugHunterFinishRequest.Item> submitted = request.getItems() == null ? List.of() : request.getItems();
|
||||||
|
if (submitted.size() > hunt.getBugs().size()) throw new IllegalArgumentException("Zu viele Bugs eingereicht.");
|
||||||
|
Map<Long, BugHunterFinishRequest.Item> byId = new HashMap<>();
|
||||||
|
Set<Long> allowed = new HashSet<>(); for (BugHunterBug bug : hunt.getBugs()) allowed.add(bug.getId());
|
||||||
|
for (BugHunterFinishRequest.Item item : submitted) {
|
||||||
|
if (item == null || item.getBugId() == null || !allowed.contains(item.getBugId()) || byId.putIfAbsent(item.getBugId(), item) != null)
|
||||||
|
throw new IllegalArgumentException("Ungültige oder doppelte Bug-ID.");
|
||||||
|
}
|
||||||
|
int base = 0, max = 0, solved = 0, combo = 0, maxCombo = 0, comboBonus = 0;
|
||||||
|
for (BugHunterBug bug : hunt.getBugs()) {
|
||||||
|
max += bug.getPoints(); BugHunterFinishRequest.Item item = byId.get(bug.getId());
|
||||||
|
if (item == null) { combo = 0; continue; }
|
||||||
|
JsonNode payload = run.payload(bug.getId()); if (payload == null) { combo = 0; continue; }
|
||||||
|
PhaseState located = run.state(bug.getId(), "locate"), classified = run.state(bug.getId(), "classify"), fixed = run.state(bug.getId(), "fix");
|
||||||
|
boolean locate = phaseCorrect(payload, "locate", located);
|
||||||
|
boolean classify = phaseCorrect(payload, "classify", classified);
|
||||||
|
boolean fix = phaseCorrect(payload, "fix", fixed);
|
||||||
|
base += award(bug.getPoints(), .4, located, locate);
|
||||||
|
base += award(bug.getPoints(), .2, classified, classify);
|
||||||
|
base += award(bug.getPoints(), .4, fixed, fix);
|
||||||
|
if (locate && classify && fix) solved++;
|
||||||
|
if (isClean(located, locate) && isClean(classified, classify) && isClean(fixed, fix)) {
|
||||||
|
combo++; maxCombo = Math.max(maxCombo, combo); comboBonus += (int) Math.round(bug.getPoints() * (Math.min(combo, 4) - 1) * .1);
|
||||||
|
} else combo = 0;
|
||||||
|
}
|
||||||
|
boolean cleanSolve = solved == hunt.getBugs().size() && maxCombo == hunt.getBugs().size();
|
||||||
|
if (cleanSolve && !blank(username)) { markCleanSolved(username, hunt.getSlug()); achievements.awardSpecial(username, "Fallakten-Perfektionist", "Schließe eine BugHunter-Akte ohne Fehlversuch und ohne Auflösung ab."); }
|
||||||
|
int stars = stars(base, max); ScoreResult saved = scores.recordScore(username, GAME, hunt.getSlug(), base + comboBonus, solved, request.getDurationSeconds());
|
||||||
|
httpSession.removeAttribute("bughunter.run");
|
||||||
|
return new BugHunterResult(base + comboBonus, base, max, comboBonus, maxCombo, solved, hunt.getBugs().size(), stars, saved.getHighscore(), saved.isNewHighscore(), saved.getPlays(), cleanSolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional public void reset(String username, String slug) {
|
||||||
|
if (blank(username) || blank(slug)) throw new IllegalArgumentException("Fallakte fehlt.");
|
||||||
|
hunts.findBySlug(slug).filter(BugHunterHunt::isActive).orElseThrow(() -> new IllegalArgumentException("Fallakte nicht gefunden."));
|
||||||
|
BugHunterHuntProgress item = progress.findByUsernameAndHuntSlug(username, slug).orElseGet(() -> new BugHunterHuntProgress(username, slug));
|
||||||
|
item.setVariantIndex(item.getVariantIndex() + 1); progress.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true) public List<BugHunterHunt> all() { return hunts.findAllByOrderBySortOrderAscIdAsc(); }
|
||||||
|
@Transactional(readOnly = true) public BugHunterHunt hunt(Long id) { BugHunterHunt hunt = hunts.findById(id).orElse(null); if (hunt != null) { hunt.getFiles().size(); hunt.getBugs().size(); } return hunt; }
|
||||||
|
@Transactional
|
||||||
|
public Long addHunt(String title, String language, String difficulty, String examScope, String description, String briefing, String consoleOutput) {
|
||||||
|
if (blank(title)) return null; String clean = title.trim();
|
||||||
|
BugHunterHunt hunt = new BugHunterHunt(uniqueSlug(clean), clean, cap(language, 50, "Java"), normalizeDifficulty(difficulty), normalizeScope(examScope), cap(description, 1200, ""), cap(briefing, 2000, ""), cap(consoleOutput, 4000, ""), (int) hunts.count() + 1);
|
||||||
|
return hunts.save(hunt).getId();
|
||||||
|
}
|
||||||
|
@Transactional public void addFile(Long huntId, String filename, String content) { hunts.findById(huntId).ifPresent(hunt -> { if (!blank(filename) && content != null && content.length() <= 8000) { hunt.addFile(new BugHunterFile(cap(filename, 255, ""), content, hunt.getFiles().size())); hunts.save(hunt); } }); }
|
||||||
|
@Transactional public void addBug(Long huntId, int points, String payload) { hunts.findById(huntId).ifPresent(hunt -> { if (validPayload(hunt, payload)) { hunt.addBug(new BugHunterBug(normalizePoints(points), hunt.getBugs().size(), payload.trim())); hunts.save(hunt); } }); }
|
||||||
|
@Transactional public void deleteHunt(Long id) { hunts.deleteById(id); }
|
||||||
|
@Transactional public void deleteFile(Long id) { files.deleteById(id); }
|
||||||
|
@Transactional public void deleteBug(Long id) { bugs.deleteById(id); }
|
||||||
|
@Transactional public void toggle(Long id) { hunts.findById(id).ifPresent(h -> h.setActive(!h.isActive())); }
|
||||||
|
|
||||||
|
private boolean validPayload(BugHunterHunt hunt, String raw) {
|
||||||
|
JsonNode p = parse(raw); if (p == null || raw.length() > 8000 || blank(p.path("file").asText()) || p.path("line").asInt(0) < 1 || !CATEGORIES.contains(p.path("category").asText()) || !p.path("fixOptions").isArray()) return false;
|
||||||
|
BugHunterFile file = hunt.getFiles().stream().filter(f -> f.getFilename().equals(p.path("file").asText())).findFirst().orElse(null);
|
||||||
|
int index = p.path("fixIndex").asInt(-1); return file != null && p.path("line").asInt() <= file.getContent().split("\\R", -1).length && index >= 0 && index < p.path("fixOptions").size();
|
||||||
|
}
|
||||||
|
private boolean phaseCorrect(JsonNode p, String phase, PhaseState answer) { return answer != null && !answer.revealed && answer.submission != null && matches(p, phase, parse(answer.submission)); }
|
||||||
|
private boolean isClean(PhaseState answer, boolean correct) { return correct && answer != null && answer.attempts == 1 && !answer.revealed; }
|
||||||
|
private int award(int points, double weight, PhaseState answer, boolean correct) { if (!correct || answer == null) return 0; int attempts = Math.max(1, Math.min(answer.attempts, 3)); double factor = attempts == 1 ? 1 : attempts == 2 ? .6 : .3; return (int) Math.round(points * weight * factor); }
|
||||||
|
private boolean matches(JsonNode p, String phase, JsonNode sub) { return switch (phase) { case "locate" -> locationMatches(p, sub); case "classify" -> categoryMatches(p, sub); case "fix" -> fixMatches(p, sub); default -> false; }; }
|
||||||
|
private boolean locationMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("file").asText(), sub.path("file").asText()) && p.path("line").asInt(-1) == sub.path("line").asInt(-2); }
|
||||||
|
private boolean categoryMatches(JsonNode p, JsonNode sub) { return textEquals(p.path("category").asText(), sub.path("category").asText()); }
|
||||||
|
private boolean fixMatches(JsonNode p, JsonNode sub) { int index = p.path("fixIndex").asInt(-1); JsonNode options = p.path("fixOptions"); return index >= 0 && index < options.size() && textEquals(options.get(index).asText(), sub.path("choice").asText()); }
|
||||||
|
private void addPhaseResolution(ObjectNode target, JsonNode p, String phase) {
|
||||||
|
if (phase.equals("locate")) { ObjectNode location = target.putObject("location"); location.put("file", p.path("file").asText()); location.put("line", p.path("line").asInt()); }
|
||||||
|
else if (phase.equals("classify")) { target.put("category", p.path("category").asText()); ArrayNode options = target.putArray("fixOptions"); List<String> shuffled = new ArrayList<>(); p.path("fixOptions").forEach(o -> shuffled.add(o.asText())); Collections.shuffle(shuffled, random); shuffled.forEach(options::add); }
|
||||||
|
else { target.put("fixedLine", p.path("fixedLine").asText()); target.put("explanation", p.path("explanation").asText()); }
|
||||||
|
}
|
||||||
|
private JsonNode parse(String raw) { try { return raw == null || raw.isBlank() ? null : mapper.readTree(raw); } catch (Exception e) { return null; } }
|
||||||
|
private int variantFor(String username, String slug) { return blank(username) ? 0 : progress.findByUsernameAndHuntSlug(username, slug).map(BugHunterHuntProgress::getVariantIndex).orElse(0); }
|
||||||
|
private int lineOffset(String filename, int variant) { return variant <= 0 ? 0 : 1 + Math.floorMod(filename.hashCode() + variant, 3); }
|
||||||
|
private String variantContent(String content, int variant, int offset) { return offset <= 0 ? content : "// Aktenvermerk · Variante " + (variant + 1) + "\n".repeat(offset) + content; }
|
||||||
|
private void markCleanSolved(String username, String slug) { BugHunterHuntProgress item = progress.findByUsernameAndHuntSlug(username, slug).orElseGet(() -> new BugHunterHuntProgress(username, slug)); item.setSolvedClean(true); item.setSolvedAt(LocalDateTime.now()); progress.save(item); }
|
||||||
|
private String uniqueSlug(String title) { String base = title.toLowerCase().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss").replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); if (base.isBlank()) base = "hunt"; String result = base; int n = 2; while (hunts.existsBySlug(result)) result = base + "-" + n++; return result; }
|
||||||
|
private int stars(int score, int max) { if (max <= 0 || score <= 0) return 0; double ratio = (double) score / max; return ratio >= .8 ? 3 : ratio >= .5 ? 2 : 1; }
|
||||||
|
private int normalizePoints(int value) { return value <= 0 ? 100 : Math.min(value, 1000); }
|
||||||
|
private String normalizeDifficulty(String value) { return Set.of("junior", "senior", "principal").contains(value) ? value : "junior"; }
|
||||||
|
private String normalizeScope(String value) { return SCOPES.contains(value) ? value : "AP1"; }
|
||||||
|
private boolean isPhase(String value) { return Set.of("locate", "classify", "fix").contains(value); }
|
||||||
|
private boolean blank(String value) { return value == null || value.isBlank(); }
|
||||||
|
private String cap(String value, int max, String fallback) { if (value == null || value.isBlank()) return fallback; String clean = value.trim(); return clean.length() <= max ? clean : clean.substring(0, max); }
|
||||||
|
private String nullToEmpty(String value) { return value == null ? "" : value; }
|
||||||
|
private boolean textEquals(String first, String second) { return normalize(first).equalsIgnoreCase(normalize(second)); }
|
||||||
|
private String normalize(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " "); }
|
||||||
|
private RunState run(HttpSession session) { Object value = session.getAttribute("bughunter.run"); if (!(value instanceof RunState run)) throw new IllegalArgumentException("Fordere zuerst eine Fallakte an."); return run; }
|
||||||
|
public static class RunState {
|
||||||
|
final String slug; final Set<Long> bugIds; final Map<Long, JsonNode> payloads; final long startedAt; final Map<Long, Map<String, PhaseState>> phases = new HashMap<>();
|
||||||
|
RunState(String slug, Set<Long> bugIds, Map<Long, JsonNode> payloads, long startedAt) { this.slug = slug; this.bugIds = Set.copyOf(bugIds); this.payloads = Map.copyOf(payloads); this.startedAt = startedAt; }
|
||||||
|
JsonNode payload(Long bugId) { return payloads.get(bugId); }
|
||||||
|
PhaseState state(Long bugId, String phase) { return phases.computeIfAbsent(bugId, ignored -> new HashMap<>()).computeIfAbsent(phase, ignored -> new PhaseState()); }
|
||||||
|
}
|
||||||
|
static class PhaseState { int attempts; boolean revealed; boolean done; String submission; }
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package com.example.mindflux.service;
|
|||||||
import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import com.example.mindflux.repository.*; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.node.*; import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*;
|
import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import com.example.mindflux.repository.*; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.node.*; import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*;
|
||||||
@Service public class CodeEscapeService {
|
@Service public class CodeEscapeService {
|
||||||
public static final String GAME="codeescape"; private static final Set<String>TYPES=Set.of("code","choice","order","match"),SCOPES=Set.of("AP1","AP2_FIAE","AP2_FISI","CROSS"); private static final int MAX_ITEMS=40;
|
public static final String GAME="codeescape"; private static final Set<String>TYPES=Set.of("code","choice","order","match"),SCOPES=Set.of("AP1","AP2_FIAE","AP2_FISI","CROSS"); private static final int MAX_ITEMS=40;
|
||||||
private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;private final ObjectMapper mapper;
|
private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;private final AchievementService achievements;private final ObjectMapper mapper;
|
||||||
public CodeEscapeService(EscapeScenarioRepository s,EscapeRoomRepository r,EscapePuzzleRepository p,GameScoreService g,ObjectMapper m){scenarios=s;rooms=r;puzzles=p;scores=g;mapper=m;}
|
public CodeEscapeService(EscapeScenarioRepository s,EscapeRoomRepository r,EscapePuzzleRepository p,GameScoreService g,AchievementService a,ObjectMapper m){scenarios=s;rooms=r;puzzles=p;scores=g;achievements=a;mapper=m;}
|
||||||
public static class RunState implements java.io.Serializable {public Long scenarioId;public String slug;public final List<Long>roomIds=new ArrayList<>();public final Set<Long>puzzleIds=new HashSet<>();public final Map<Long,Integer> attempts=new HashMap<>(),hints=new HashMap<>();public final Set<Long> revealed=new HashSet<>(),resolved=new HashSet<>(),opened=new LinkedHashSet<>();}
|
public static class RunState implements java.io.Serializable {public Long scenarioId;public String slug;public final List<Long>roomIds=new ArrayList<>();public final Set<Long>puzzleIds=new HashSet<>();public final Map<Long,Integer> attempts=new HashMap<>(),hints=new HashMap<>();public final Set<Long> revealed=new HashSet<>(),resolved=new HashSet<>(),opened=new LinkedHashSet<>();}
|
||||||
private RunState state(HttpSession session){Object o=session.getAttribute("codeescape.run");if(o instanceof RunState s&&s.scenarioId!=null)return s;throw new IllegalArgumentException("Kein aktiver SystemEscape-Run.");}
|
private RunState state(HttpSession session){Object o=session.getAttribute("codeescape.run");if(o instanceof RunState s&&s.scenarioId!=null)return s;throw new IllegalArgumentException("Kein aktiver SystemEscape-Run.");}
|
||||||
@Transactional(readOnly=true) public JsonNode catalog(){ArrayNode a=mapper.createArrayNode();for(EscapeScenario s:scenarios.findAllByActiveTrueOrderBySortOrderAscIdAsc()){if(s.getRooms().isEmpty())continue;ObjectNode n=a.addObject();n.put("slug",s.getSlug());n.put("title",s.getTitle());n.put("difficulty",s.getDifficulty());n.put("examScope",s.getExamScope());n.put("description",s.getDescription());n.put("timeLimitSeconds",s.getTimeLimitSeconds());n.put("roomCount",Math.min(4,s.getRooms().size()));n.put("poolSize",s.getRooms().size());}return mapper.createObjectNode().set("scenarios",a);}
|
@Transactional(readOnly=true) public JsonNode catalog(){ArrayNode a=mapper.createArrayNode();for(EscapeScenario s:scenarios.findAllByActiveTrueOrderBySortOrderAscIdAsc()){if(s.getRooms().isEmpty())continue;ObjectNode n=a.addObject();n.put("slug",s.getSlug());n.put("title",s.getTitle());n.put("difficulty",s.getDifficulty());n.put("examScope",s.getExamScope());n.put("description",s.getDescription());n.put("timeLimitSeconds",s.getTimeLimitSeconds());n.put("roomCount",Math.min(4,s.getRooms().size()));n.put("poolSize",s.getRooms().size());}return mapper.createObjectNode().set("scenarios",a);}
|
||||||
@@ -13,7 +13,7 @@ import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import c
|
|||||||
@Transactional(readOnly=true) public JsonNode hint(Long id,int level,HttpSession session){RunState st=state(session);if(!st.puzzleIds.contains(id))throw new IllegalArgumentException("Rätsel gehört nicht zu diesem Run.");EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));int lv=Math.max(1,Math.min(2,level));st.hints.merge(id,lv,Math::max);List<String>h=strings(parse(p.getPayload()),"hints");ObjectNode o=mapper.createObjectNode();o.put("level",lv);o.put("text",h.isEmpty()?"Prüfe die Aufgabenstellung noch einmal.":h.get(Math.min(lv-1,h.size()-1)));return o;}
|
@Transactional(readOnly=true) public JsonNode hint(Long id,int level,HttpSession session){RunState st=state(session);if(!st.puzzleIds.contains(id))throw new IllegalArgumentException("Rätsel gehört nicht zu diesem Run.");EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));int lv=Math.max(1,Math.min(2,level));st.hints.merge(id,lv,Math::max);List<String>h=strings(parse(p.getPayload()),"hints");ObjectNode o=mapper.createObjectNode();o.put("level",lv);o.put("text",h.isEmpty()?"Prüfe die Aufgabenstellung noch einmal.":h.get(Math.min(lv-1,h.size()-1)));return o;}
|
||||||
@Transactional(readOnly=true) public JsonNode door(Long id,String code,HttpSession session){RunState st=state(session);int next=st.opened.size();if(next>=st.roomIds.size()||!Objects.equals(st.roomIds.get(next),id))throw new IllegalArgumentException("Raum gehört nicht zum aktuellen Abschnitt dieses Runs.");EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));boolean complete=r.getPuzzles().stream().allMatch(p->st.resolved.contains(p.getId()));boolean open=complete&&eq(r.getDoorCode(),code,false);if(open)st.opened.add(id);ObjectNode o=mapper.createObjectNode();o.put("open",open);o.put("complete",complete);if(open)o.put("successText",r.getSuccessText());else o.put("message",complete?"Code abgelehnt. Setze die Fragmente von links nach rechts ohne Leerzeichen zusammen.":"Löse zuerst alle Rätsel dieses Raums.");return o;}
|
@Transactional(readOnly=true) public JsonNode door(Long id,String code,HttpSession session){RunState st=state(session);int next=st.opened.size();if(next>=st.roomIds.size()||!Objects.equals(st.roomIds.get(next),id))throw new IllegalArgumentException("Raum gehört nicht zum aktuellen Abschnitt dieses Runs.");EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));boolean complete=r.getPuzzles().stream().allMatch(p->st.resolved.contains(p.getId()));boolean open=complete&&eq(r.getDoorCode(),code,false);if(open)st.opened.add(id);ObjectNode o=mapper.createObjectNode();o.put("open",open);o.put("complete",complete);if(open)o.put("successText",r.getSuccessText());else o.put("message",complete?"Code abgelehnt. Setze die Fragmente von links nach rechts ohne Leerzeichen zusammen.":"Löse zuerst alle Rätsel dieses Raums.");return o;}
|
||||||
@Transactional public EscapeResult finish(String user,EscapeFinishRequest req,HttpSession session){RunState st=state(session);EscapeScenario s=scenarios.findBySlug(req.getSlug()).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));if(!Objects.equals(st.scenarioId,s.getId())||!Objects.equals(st.slug,req.getSlug()))throw new IllegalArgumentException("Szenario gehört nicht zu diesem Run.");if(req.getDurationSeconds()<1||req.getDurationSeconds()>s.getTimeLimitSeconds()*4)throw new IllegalArgumentException("Unplausible Spieldauer.");if(req.getItems()==null||req.getItems().size()>MAX_ITEMS)throw new IllegalArgumentException("Ungültige Rätselliste.");Map<Long,EscapePuzzle>byId=new HashMap<>();Map<Long,EscapeRoom>selectedRooms=new HashMap<>();for(EscapeRoom r:s.getRooms())if(st.roomIds.contains(r.getId())){selectedRooms.put(r.getId(),r);for(EscapePuzzle p:r.getPuzzles())byId.put(p.getId(),p);}Set<Long>seen=new HashSet<>();int base=0,solved=0;for(EscapeFinishRequest.Item i:req.getItems()){if(i.getPuzzleId()==null||!st.puzzleIds.contains(i.getPuzzleId())||!seen.add(i.getPuzzleId()))throw new IllegalArgumentException("Fremde oder doppelte Rätsel-ID.");EscapePuzzle p=byId.get(i.getPuzzleId());if(p!=null&&matches(p,i.getSubmission())&&!st.revealed.contains(p.getId())){solved++;int a=Math.max(1,st.attempts.getOrDefault(p.getId(),1));double af=a==1?1:a==2?.6:.3;int hl=st.hints.getOrDefault(p.getId(),0);double hf=hl==0?1:hl==1?.8:.6;base+=(int)Math.round(p.getPoints()*af*hf);}}
|
@Transactional public EscapeResult finish(String user,EscapeFinishRequest req,HttpSession session){RunState st=state(session);EscapeScenario s=scenarios.findBySlug(req.getSlug()).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));if(!Objects.equals(st.scenarioId,s.getId())||!Objects.equals(st.slug,req.getSlug()))throw new IllegalArgumentException("Szenario gehört nicht zu diesem Run.");if(req.getDurationSeconds()<1||req.getDurationSeconds()>s.getTimeLimitSeconds()*4)throw new IllegalArgumentException("Unplausible Spieldauer.");if(req.getItems()==null||req.getItems().size()>MAX_ITEMS)throw new IllegalArgumentException("Ungültige Rätselliste.");Map<Long,EscapePuzzle>byId=new HashMap<>();Map<Long,EscapeRoom>selectedRooms=new HashMap<>();for(EscapeRoom r:s.getRooms())if(st.roomIds.contains(r.getId())){selectedRooms.put(r.getId(),r);for(EscapePuzzle p:r.getPuzzles())byId.put(p.getId(),p);}Set<Long>seen=new HashSet<>();int base=0,solved=0;for(EscapeFinishRequest.Item i:req.getItems()){if(i.getPuzzleId()==null||!st.puzzleIds.contains(i.getPuzzleId())||!seen.add(i.getPuzzleId()))throw new IllegalArgumentException("Fremde oder doppelte Rätsel-ID.");EscapePuzzle p=byId.get(i.getPuzzleId());if(p!=null&&matches(p,i.getSubmission())&&!st.revealed.contains(p.getId())){solved++;int a=Math.max(1,st.attempts.getOrDefault(p.getId(),1));double af=a==1?1:a==2?.6:.3;int hl=st.hints.getOrDefault(p.getId(),0);double hf=hl==0?1:hl==1?.8:.6;base+=(int)Math.round(p.getPoints()*af*hf);}}
|
||||||
int opened=st.opened.size();base+=opened*50;int max=selectedRooms.values().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==st.roomIds.size();int bonus=escaped?Math.max(0,(int)Math.round(500.0*(s.getTimeLimitSeconds()-req.getDurationSeconds())/s.getTimeLimitSeconds())):0;int total=base+bonus;int stars=stars(base,max);ScoreResult saved=scores.recordScore(user,GAME,s.getSlug(),total,solved,req.getDurationSeconds());httpClear(session);return new EscapeResult(total,base,max,bonus,solved,opened,escaped,stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays());}
|
int opened=st.opened.size();base+=opened*50;int max=selectedRooms.values().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==st.roomIds.size();int bonus=escaped?Math.max(0,(int)Math.round(500.0*(s.getTimeLimitSeconds()-req.getDurationSeconds())/s.getTimeLimitSeconds())):0;int total=base+bonus;int stars=stars(base,max);ScoreResult saved=scores.recordScore(user,GAME,s.getSlug(),total,solved,req.getDurationSeconds());if(escaped&&stars==3)achievements.awardSpecial(user,"Schleusenmeister","Entkomme aus SystemEscape mit drei Sternen.");httpClear(session);return new EscapeResult(total,base,max,bonus,solved,opened,escaped,stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays());}
|
||||||
private void httpClear(HttpSession s){s.removeAttribute("codeescape.run");}
|
private void httpClear(HttpSession s){s.removeAttribute("codeescape.run");}
|
||||||
private boolean matches(EscapePuzzle p,JsonNode sub){JsonNode j=parse(p.getPayload());if(j==null||sub==null)return false;return switch(p.getType()){case"code"->strings(j,"solutions").stream().anyMatch(x->eq(x,sub.path("answer").asText(""),j.path("caseSensitive").asBoolean(false)));case"choice"->eq(j.path("correct").asText(),sub.path("answer").asText(),false);case"order"->listEq(strings(j,"solution"),strings(sub,"items"));case"match"->pairEq(j,sub);default->false;};}
|
private boolean matches(EscapePuzzle p,JsonNode sub){JsonNode j=parse(p.getPayload());if(j==null||sub==null)return false;return switch(p.getType()){case"code"->strings(j,"solutions").stream().anyMatch(x->eq(x,sub.path("answer").asText(""),j.path("caseSensitive").asBoolean(false)));case"choice"->eq(j.path("correct").asText(),sub.path("answer").asText(),false);case"order"->listEq(strings(j,"solution"),strings(sub,"items"));case"match"->pairEq(j,sub);default->false;};}
|
||||||
private boolean pairEq(JsonNode j,JsonNode s){Map<String,String>w=new HashMap<>();for(JsonNode p:array(j,"pairs"))if(p.size()>1)w.put(norm(p.get(0).asText()),norm(p.get(1).asText()));for(JsonNode p:array(s,"pairs"))if(p.size()>1&&!Objects.equals(w.get(norm(p.get(0).asText())),norm(p.get(1).asText())))return false;return arraySize(s,"pairs")==w.size();}private int arraySize(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a.size():0;}private boolean listEq(List<String>a,List<String>b){if(a.size()!=b.size())return false;for(int i=0;i<a.size();i++)if(!eq(a.get(i),b.get(i),false))return false;return true;}private static boolean eq(String a,String b,boolean cs){return cs?norm(a).equals(norm(b)):norm(a).equalsIgnoreCase(norm(b));}private static String norm(String s){return s==null?"":s.trim().replaceAll("\\s+"," ");}private int stars(int s,int m){if(s<=0||m<=0)return 0;double r=(double)s/m;return r>=.8?3:r>=.5?2:1;}private JsonNode parse(String j){try{return mapper.readTree(j);}catch(Exception e){return null;}}private Iterable<JsonNode> array(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a:mapper.createArrayNode();}private List<String> strings(JsonNode n,String f){List<String>o=new ArrayList<>();for(JsonNode x:array(n,f))o.add(x.asText());return o;}private ArrayNode shuffle(List<String>x){List<String>c=new ArrayList<>(x);Collections.shuffle(c);ArrayNode a=mapper.createArrayNode();c.forEach(a::add);return a;}private void copy(JsonNode s,ObjectNode t,String f){if(s.hasNonNull(f))t.put(f,s.get(f).asText());}
|
private boolean pairEq(JsonNode j,JsonNode s){Map<String,String>w=new HashMap<>();for(JsonNode p:array(j,"pairs"))if(p.size()>1)w.put(norm(p.get(0).asText()),norm(p.get(1).asText()));for(JsonNode p:array(s,"pairs"))if(p.size()>1&&!Objects.equals(w.get(norm(p.get(0).asText())),norm(p.get(1).asText())))return false;return arraySize(s,"pairs")==w.size();}private int arraySize(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a.size():0;}private boolean listEq(List<String>a,List<String>b){if(a.size()!=b.size())return false;for(int i=0;i<a.size();i++)if(!eq(a.get(i),b.get(i),false))return false;return true;}private static boolean eq(String a,String b,boolean cs){return cs?norm(a).equals(norm(b)):norm(a).equalsIgnoreCase(norm(b));}private static String norm(String s){return s==null?"":s.trim().replaceAll("\\s+"," ");}private int stars(int s,int m){if(s<=0||m<=0)return 0;double r=(double)s/m;return r>=.8?3:r>=.5?2:1;}private JsonNode parse(String j){try{return mapper.readTree(j);}catch(Exception e){return null;}}private Iterable<JsonNode> array(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a:mapper.createArrayNode();}private List<String> strings(JsonNode n,String f){List<String>o=new ArrayList<>();for(JsonNode x:array(n,f))o.add(x.asText());return o;}private ArrayNode shuffle(List<String>x){List<String>c=new ArrayList<>(x);Collections.shuffle(c);ArrayNode a=mapper.createArrayNode();c.forEach(a::add);return a;}private void copy(JsonNode s,ObjectNode t,String f){if(s.hasNonNull(f))t.put(f,s.get(f).asText());}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import com.example.mindflux.dto.WeeklyActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speichert Spielergebnisse und liefert Statistiken. Der Score wird hier
|
* Speichert Spielergebnisse und liefert Statistiken. Der Score wird hier
|
||||||
@@ -110,4 +114,39 @@ public class GameScoreService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
return new GameStats(highscore, plays, lastPlayed);
|
return new GameStats(highscore, plays, lastPlayed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregiert ausschließlich gespeicherte Abschlusszeiten. Runden ohne gemessene
|
||||||
|
* Dauer zählen für XP, aber bewusst nicht als Spielzeit.
|
||||||
|
*/
|
||||||
|
public WeeklyActivity weeklyActivity(String username, LocalDate monday) {
|
||||||
|
LocalDateTime from = monday.atStartOfDay();
|
||||||
|
LocalDateTime until = monday.plusDays(7).atStartOfDay();
|
||||||
|
int[] seconds = new int[7];
|
||||||
|
int[] xp = new int[7];
|
||||||
|
|
||||||
|
for (GameScore score : repository
|
||||||
|
.findByUsernameAndPlayedAtGreaterThanEqualAndPlayedAtLessThanOrderByPlayedAtAsc(username, from, until)) {
|
||||||
|
int day = (int) (score.getPlayedAt().toLocalDate().toEpochDay() - monday.toEpochDay());
|
||||||
|
if (day < 0 || day >= 7) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
xp[day] += Math.max(score.getScore(), 0);
|
||||||
|
if (score.getDurationSeconds() != null) {
|
||||||
|
seconds[day] += Math.max(score.getDurationSeconds(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Integer> minutes = new ArrayList<>(7);
|
||||||
|
List<Integer> weeklyXp = new ArrayList<>(7);
|
||||||
|
int totalSeconds = 0;
|
||||||
|
int totalXp = 0;
|
||||||
|
for (int day = 0; day < 7; day++) {
|
||||||
|
minutes.add((int) Math.ceil(seconds[day] / 60.0));
|
||||||
|
weeklyXp.add(xp[day]);
|
||||||
|
totalSeconds += seconds[day];
|
||||||
|
totalXp += xp[day];
|
||||||
|
}
|
||||||
|
return new WeeklyActivity(minutes, weeklyXp, (int) Math.ceil(totalSeconds / 60.0), totalXp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Kleine, deterministische IPv4-Simulation für NetWorker. Sie bewertet Erreichbarkeit statt Mustertexte. */
|
||||||
|
public final class NetSimulator {
|
||||||
|
private NetSimulator() { }
|
||||||
|
public record Goal(String id, boolean passed, boolean reachable, List<String> trace, String reason, String message) { }
|
||||||
|
public record Simulation(List<Goal> goals) { public boolean allPassed() { return goals.stream().allMatch(Goal::passed); } }
|
||||||
|
|
||||||
|
public static Simulation simulate(JsonNode net) {
|
||||||
|
Map<String, Node> nodes = nodes(net); List<Goal> result = new ArrayList<>();
|
||||||
|
String validation = validation(nodes);
|
||||||
|
for (JsonNode goal : net.path("goals")) {
|
||||||
|
String id = goal.path("id").asText("ziel"), from = goal.path("from").asText(), to = goal.path("to").asText(); boolean expected = goal.path("shouldReach").asBoolean(true);
|
||||||
|
if (validation != null) { result.add(new Goal(id, false, false, List.of(from), "invalid_config", validation)); continue; }
|
||||||
|
Node source = nodes.get(from), target = nodes.get(to);
|
||||||
|
if (source == null || target == null) { result.add(new Goal(id, false, false, List.of(from), "missing_node", "Ein Zielgerät fehlt in der Topologie.")); continue; }
|
||||||
|
Route route = route(nodes, source, target, new LinkedHashSet<>(), 8);
|
||||||
|
boolean passed = route.reachable == expected;
|
||||||
|
String message = passed ? (expected ? "Paket zugestellt." : "Der Sperrpfad bleibt korrekt geschlossen.") : route.message;
|
||||||
|
result.add(new Goal(id, passed, route.reachable, route.trace, route.reason, message));
|
||||||
|
}
|
||||||
|
return new Simulation(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Route route(Map<String, Node> nodes, Node from, Node target, LinkedHashSet<String> seen, int ttl) {
|
||||||
|
if (ttl <= 0) return Route.fail(List.copyOf(seen), "routing_loop", "Die Route endet in einer Schleife.");
|
||||||
|
if (sameSubnet(from.ip, from.prefix, target.ip)) return Route.ok(List.of(from.id, target.id));
|
||||||
|
if (from.gateway == null) return Route.fail(List.of(from.id), "missing_gateway", "Das Paket verlässt das lokale Netz nicht: Es fehlt ein Gateway.");
|
||||||
|
Node router = routerForGateway(nodes, from.gateway);
|
||||||
|
if (router == null || !sameSubnet(from.ip, from.prefix, from.gateway)) return Route.fail(List.of(from.id), "gateway_unreachable", "Das eingetragene Gateway liegt nicht im lokalen Subnetz.");
|
||||||
|
List<String> trace = new ArrayList<>(); trace.add(from.id); trace.add(router.id);
|
||||||
|
Route next = routerRoute(nodes, router, target.ip, seen, ttl - 1);
|
||||||
|
trace.addAll(next.trace);
|
||||||
|
return next.reachable ? Route.ok(trace) : Route.fail(trace, next.reason, next.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Route routerRoute(Map<String, Node> nodes, Node router, long destination, LinkedHashSet<String> seen, int ttl) {
|
||||||
|
if (!seen.add(router.id)) return Route.fail(List.of(router.id), "routing_loop", "Die Router verweisen im Kreis aufeinander.");
|
||||||
|
for (long gateway : router.gateways) if (sameSubnet(gateway, router.gatewayPrefix, destination)) return Route.ok(List.of());
|
||||||
|
JsonNode best = null; int bestPrefix = -1;
|
||||||
|
for (JsonNode route : router.routes) { int prefix = route.path("prefix").asInt(-1); Long network = ip(route.path("network").asText()); if (network != null && prefix >= 0 && sameSubnet(network, prefix, destination) && prefix > bestPrefix) { best = route; bestPrefix = prefix; } }
|
||||||
|
if (best == null) return Route.fail(List.of(), "no_route", "Der Router kennt keine Route zum Zielnetz.");
|
||||||
|
Node next = nodes.get(best.path("nextRouter").asText());
|
||||||
|
if (next == null || !"router".equals(next.type)) return Route.fail(List.of(), "invalid_route", "Die statische Route zeigt auf keinen erreichbaren Router.");
|
||||||
|
Route onward = routerRoute(nodes, next, destination, seen, ttl - 1);
|
||||||
|
List<String> trace = new ArrayList<>(); trace.add(next.id); trace.addAll(onward.trace); return onward.reachable ? Route.ok(trace) : Route.fail(trace, onward.reason, onward.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String validation(Map<String, Node> nodes) {
|
||||||
|
Map<String, String> ips = new HashMap<>();
|
||||||
|
for (Node node : nodes.values()) {
|
||||||
|
if (!"router".equals(node.type) && node.ip == null) return node.id + " enthält keine gültige IPv4-Adresse.";
|
||||||
|
if (node.ip == null || node.prefix < 0 || node.prefix > 30) continue;
|
||||||
|
if (hostPart(node.ip, node.prefix) == 0) return node.id + " nutzt eine Netzadresse statt einer Hostadresse.";
|
||||||
|
if (hostPart(node.ip, node.prefix) == hostMask(node.prefix)) return node.id + " nutzt eine Broadcast-Adresse statt einer Hostadresse.";
|
||||||
|
String previous = ips.putIfAbsent(node.ip + "/" + node.prefix, node.id); if (previous != null) return previous + " und " + node.id + " besitzen dieselbe IP-Adresse.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static Map<String, Node> nodes(JsonNode net) { Map<String, Node> result = new LinkedHashMap<>(); for (JsonNode raw : net.path("nodes")) { String id=raw.path("id").asText(); if(id.isBlank()) continue; List<Long> gateways=new ArrayList<>(); for(JsonNode value:raw.path("gateways")){Long parsed=ip(value.asText());if(parsed!=null)gateways.add(parsed);} List<JsonNode> routes=new ArrayList<>(); raw.path("routes").forEach(routes::add); Long address=ip(raw.path("ip").asText()); Long gateway=ip(raw.path("gateway").asText()); result.put(id,new Node(id,raw.path("type").asText("host"),address,raw.path("prefix").asInt(-1),gateway,gateways,raw.path("gatewayPrefix").asInt(24),routes)); } return result; }
|
||||||
|
private static Node routerForGateway(Map<String, Node> nodes, long gateway) { return nodes.values().stream().filter(n -> n.gateways.contains(gateway)).findFirst().orElse(null); }
|
||||||
|
private static Long ip(String raw) { try { String[] p=raw.trim().split("\\."); if(p.length!=4)return null; long n=0; for(String part:p){int v=Integer.parseInt(part);if(v<0||v>255)return null;n=(n<<8)|v;} return n; } catch(Exception ignored){return null;} }
|
||||||
|
private static boolean sameSubnet(long first,int prefix,long second){ if(prefix<0||prefix>32)return false; long mask=prefix==0?0:(0xffffffffL<<(32-prefix))&0xffffffffL; return (first&mask)==(second&mask); }
|
||||||
|
private static long hostPart(long address,int prefix){long mask=prefix==0?0:(0xffffffffL<<(32-prefix))&0xffffffffL;return address&~mask&0xffffffffL;}
|
||||||
|
private static long hostMask(int prefix){return prefix==32?0:(1L<<(32-prefix))-1;}
|
||||||
|
private record Node(String id,String type,Long ip,int prefix,Long gateway,List<Long> gateways,int gatewayPrefix,List<JsonNode> routes) { }
|
||||||
|
private record Route(boolean reachable,List<String> trace,String reason,String message) { static Route ok(List<String> trace){return new Route(true,trace,"","Paket zugestellt.");} static Route fail(List<String> trace,String reason,String message){return new Route(false,trace,reason,message);} }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.*;
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.*;
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
|
import com.fasterxml.jackson.databind.node.*;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NetworkerService {
|
||||||
|
private static final String GAME="networker"; private static final int MAX_DURATION=14_400;
|
||||||
|
private final NetworkWorldRepository worlds; private final NetworkLevelRepository levels; private final GameScoreService scores; private final AchievementService achievements; private final ObjectMapper mapper;
|
||||||
|
public NetworkerService(NetworkWorldRepository worlds, NetworkLevelRepository levels, GameScoreService scores, AchievementService achievements, ObjectMapper mapper){this.worlds=worlds;this.levels=levels;this.scores=scores;this.achievements=achievements;this.mapper=mapper;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode catalog(){ObjectNode root=mapper.createObjectNode();ArrayNode rows=root.putArray("worlds");for(NetworkWorld w:worlds.findAllByActiveTrueOrderBySortOrderAscIdAsc()){ObjectNode row=rows.addObject();row.put("slug",w.getSlug());row.put("title",w.getTitle());row.put("description",w.getDescription());row.put("difficulty",w.getDifficulty());row.put("examScope",w.getExamScope());row.put("levelCount",w.getLevels().size());}return root;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){NetworkWorld world=worlds.findBySlug(slug).filter(NetworkWorld::isActive).orElseThrow(()->new IllegalArgumentException("Netzwelt nicht gefunden."));ObjectNode root=mapper.createObjectNode();root.put("slug",world.getSlug());root.put("title",world.getTitle());root.put("description",world.getDescription());root.put("difficulty",world.getDifficulty());root.put("examScope",world.getExamScope());ArrayNode rows=root.putArray("levels");Map<Long,JsonNode> payloads=new HashMap<>();int max=0;for(NetworkLevel level:world.getLevels()){JsonNode raw=parse(level.getPayload());if(!(raw instanceof ObjectNode node))continue;payloads.put(level.getId(),node);ObjectNode row=rows.addObject();row.put("levelId",level.getId());row.put("title",level.getTitle());row.put("type",level.getLevelType());row.put("points",level.getPoints());row.set("network",sanitize(node));max+=level.getPoints();}root.put("maxScore",max);http.setAttribute("networker.run",new Run(world.getSlug(),payloads));return root;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode check(NetworkCheckRequest request,HttpSession http){if(request==null||request.getLevelId()==null)throw new IllegalArgumentException("Level fehlt.");Run run=run(http);JsonNode raw=run.payloads.get(request.getLevelId());if(raw==null)throw new IllegalArgumentException("Level gehört nicht zu dieser Welt.");State state=run.states.computeIfAbsent(request.getLevelId(),ignored->new State());if(state.done)throw new IllegalArgumentException("Dieses Level ist bereits abgeschlossen.");if(!request.isReveal()&&state.attempts>=3)throw new IllegalArgumentException("Nach drei Versuchen kannst du die Lösung anzeigen.");ObjectNode merged=request.isReveal()?solution(raw):merge(raw,request.getConfig());NetSimulator.Simulation simulation=NetSimulator.simulate(merged);boolean passed=simulation.allPassed();if(request.isReveal()){state.revealed=true;state.done=true;}else{state.attempts++;if(passed)state.done=true;}state.passed=passed;ObjectNode result=mapper.createObjectNode();result.put("allPassed",passed);result.put("resolved",state.done);result.put("attempts",state.attempts);result.put("revealed",state.revealed);ArrayNode goals=result.putArray("goals");for(NetSimulator.Goal goal:simulation.goals()){ObjectNode row=goals.addObject();row.put("id",goal.id());row.put("passed",goal.passed());row.put("reachable",goal.reachable());row.put("reason",goal.reason());row.put("message",goal.message());ArrayNode trace=row.putArray("trace");goal.trace().forEach(trace::add);}if(request.isReveal())result.set("solution",sanitize(solution(raw)));return result;}
|
||||||
|
@Transactional public NetworkResult finish(String username,NetworkFinishRequest request,HttpSession http){if(request==null||blank(request.getSlug())||request.getDurationSeconds()<1||request.getDurationSeconds()>MAX_DURATION)throw new IllegalArgumentException("Unplausibler Abschluss.");Run run=run(http);if(!run.slug.equals(request.getSlug()))throw new IllegalArgumentException("Diese Welt wurde nicht angefordert.");NetworkWorld world=worlds.findBySlug(request.getSlug()).filter(NetworkWorld::isActive).orElseThrow(()->new IllegalArgumentException("Netzwelt nicht gefunden."));int score=0,max=0,solved=0;boolean perfect=true;for(NetworkLevel level:world.getLevels()){max+=level.getPoints();State state=run.states.get(level.getId());if(state!=null&&state.done&&state.passed&&!state.revealed){solved++;score+=(int)Math.round(level.getPoints()*(state.attempts==1?1:state.attempts==2?.6:.3));if(state.attempts!=1)perfect=false;}else perfect=false;}int stars=stars(score,max);ScoreResult saved=scores.recordScore(username,GAME,world.getSlug(),score,solved,request.getDurationSeconds());if(solved>0)achievements.awardSpecial(username,"Paketbote","Schließe deine erste NetWorker-Welt ab.");if(perfect&&solved==world.getLevels().size())achievements.awardSpecial(username,"Blueprint-Profi","Löse eine NetWorker-Welt fehlerfrei.");http.removeAttribute("networker.run");return new NetworkResult(score,max,solved,world.getLevels().size(),stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays(),perfect);}
|
||||||
|
private ObjectNode sanitize(ObjectNode raw){ObjectNode safe=raw.deepCopy();safe.remove("solution");return safe;}
|
||||||
|
private ObjectNode solution(JsonNode raw){ObjectNode result=((ObjectNode)raw).deepCopy();JsonNode solution=result.path("solution");if(solution.isObject())apply(result,(ObjectNode)solution);result.remove("solution");return result;}
|
||||||
|
private ObjectNode merge(JsonNode raw,JsonNode submitted){ObjectNode result=((ObjectNode)raw).deepCopy();if(submitted instanceof ObjectNode node)apply(result,node);result.remove("solution");return result;}
|
||||||
|
private void apply(ObjectNode target,ObjectNode submitted){Map<String,ObjectNode> incoming=new HashMap<>();submitted.path("nodes").forEach(node->{if(node instanceof ObjectNode value&&!value.path("id").asText().isBlank())incoming.put(value.path("id").asText(),value);});for(JsonNode node:target.path("nodes")){if(!(node instanceof ObjectNode editable))continue;ObjectNode value=incoming.get(editable.path("id").asText());if(value==null)continue;Set<String> fields=new HashSet<>();editable.path("editable").forEach(field->fields.add(field.asText()));for(String field:fields){if(!value.has(field))continue;if(field.equals("routes")&&value.path(field).isArray())editable.set(field,value.get(field));else if(value.get(field).isValueNode())editable.set(field,value.get(field));}}}
|
||||||
|
private JsonNode parse(String raw){try{return mapper.readTree(raw);}catch(Exception e){return null;}} private int stars(int score,int max){if(max<=0||score<=0)return 0;double ratio=(double)score/max;return ratio>=.8?3:ratio>=.5?2:1;}private boolean blank(String value){return value==null||value.isBlank();}private Run run(HttpSession session){Object value=session.getAttribute("networker.run");if(value instanceof Run run)return run;throw new IllegalArgumentException("Fordere zuerst eine Netzwelt an.");}
|
||||||
|
static class Run {final String slug;final Map<Long,JsonNode> payloads;final Map<Long,State> states=new HashMap<>();Run(String slug,Map<Long,JsonNode> payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State{int attempts;boolean done,revealed,passed;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.*;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NeuralNetService {
|
||||||
|
private final NeuralNetPrivacyRepository privacy;
|
||||||
|
private final NeuralNetConnectionRepository connections;
|
||||||
|
private final NeuralNetMessageRepository messages;
|
||||||
|
private final UserRepository users;
|
||||||
|
public NeuralNetService(NeuralNetPrivacyRepository privacy, NeuralNetConnectionRepository connections, NeuralNetMessageRepository messages, UserRepository users) {
|
||||||
|
this.privacy=privacy; this.connections=connections; this.messages=messages; this.users=users;
|
||||||
|
}
|
||||||
|
public record Overview(String username, boolean enabled, List<String> connections, List<String> incoming, List<String> outgoing, long unread) {}
|
||||||
|
public record ChatMessage(long id, String sender, String body, LocalDateTime sentAt) {}
|
||||||
|
private boolean enabled(String username) { return privacy.findById(username).map(NeuralNetPrivacy::isEnabled).orElse(false); }
|
||||||
|
private void requireEnabled(String username) { if (!enabled(username)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "NeuralNet ist für dieses Konto nicht aktiviert."); }
|
||||||
|
private void requireConnection(String a, String b) { if (connections.acceptedBetween(a,b).isEmpty()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Private Nachrichten sind erst nach bestätigter Verbindung möglich."); }
|
||||||
|
public void enable(String username) { privacy.save(new NeuralNetPrivacy(username, true)); }
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Overview overview(String username) {
|
||||||
|
if (!enabled(username)) return new Overview(username, false, List.of(), List.of(), List.of(), 0);
|
||||||
|
List<String> accepted=connections.acceptedFor(username).stream().map(c -> c.getRequester().equals(username) ? c.getAddressee() : c.getRequester()).toList();
|
||||||
|
List<String> incoming=connections.findByAddresseeAndStatusOrderByCreatedAtAsc(username,"PENDING").stream().map(NeuralNetConnection::getRequester).toList();
|
||||||
|
List<String> outgoing=connections.findByRequesterAndStatusOrderByCreatedAtAsc(username,"PENDING").stream().map(NeuralNetConnection::getAddressee).toList();
|
||||||
|
return new Overview(username, true, accepted, incoming, outgoing, messages.countByRecipientAndReadAtIsNull(username));
|
||||||
|
}
|
||||||
|
public void request(String username, String target) {
|
||||||
|
requireEnabled(username); target=cleanUser(target);
|
||||||
|
var targetUser=users.findByUsernameIgnoreCase(target).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Dieses NeuralNet-Mitglied ist nicht verfügbar."));
|
||||||
|
target=targetUser.getUsername();
|
||||||
|
if (username.equalsIgnoreCase(target) || !enabled(target)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dieses NeuralNet-Mitglied ist nicht verfügbar.");
|
||||||
|
if (connections.findByRequesterAndAddressee(username,target).isPresent() || connections.findByRequesterAndAddressee(target,username).isPresent()) throw new ResponseStatusException(HttpStatus.CONFLICT, "Zwischen euch besteht bereits eine Anfrage oder Verbindung.");
|
||||||
|
connections.save(new NeuralNetConnection(username,target));
|
||||||
|
}
|
||||||
|
public void accept(String username, String requester) { var c=pendingFor(username,requester); c.setStatus("ACCEPTED"); c.setRespondedAt(LocalDateTime.now()); connections.save(c); }
|
||||||
|
public void decline(String username, String requester) { connections.delete(pendingFor(username,requester)); }
|
||||||
|
public void cancel(String username, String target) { connections.delete(connections.findByRequesterAndAddressee(username,cleanUser(target)).filter(c -> c.getStatus().equals("PENDING")).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Anfrage nicht gefunden."))); }
|
||||||
|
@Transactional
|
||||||
|
public List<ChatMessage> conversation(String username, String other) {
|
||||||
|
requireEnabled(username); other=cleanUser(other); requireConnection(username,other);
|
||||||
|
var result=messages.conversation(username,other);
|
||||||
|
result.stream().filter(m -> m.getRecipient().equals(username) && m.getReadAt()==null).forEach(m -> m.setReadAt(LocalDateTime.now()));
|
||||||
|
return result.stream().map(m -> new ChatMessage(m.getId(),m.getSender(),m.getBody(),m.getSentAt())).toList();
|
||||||
|
}
|
||||||
|
public ChatMessage send(String username, String recipient, String body) {
|
||||||
|
requireEnabled(username); recipient=cleanUser(recipient); requireConnection(username,recipient);
|
||||||
|
String text=body==null ? "" : body.trim();
|
||||||
|
if (text.isBlank() || text.length()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Eine Nachricht benötigt 1 bis 2000 Zeichen.");
|
||||||
|
var m=messages.save(new NeuralNetMessage(username,recipient,text));
|
||||||
|
return new ChatMessage(m.getId(),m.getSender(),m.getBody(),m.getSentAt());
|
||||||
|
}
|
||||||
|
private NeuralNetConnection pendingFor(String addressee,String requester) { return connections.findByRequesterAndAddressee(cleanUser(requester),addressee).filter(c -> c.getStatus().equals("PENDING")).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"Anfrage nicht gefunden.")); }
|
||||||
|
private String cleanUser(String username) { if (username==null || username.trim().isBlank() || username.trim().length()>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Ungültiger Benutzername."); return username.trim(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.PlatformStatus;
|
||||||
|
import com.example.mindflux.repository.GameScoreRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PlatformStatusService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlatformStatusService.class);
|
||||||
|
private static final int REGISTERED_MODULES = 9;
|
||||||
|
|
||||||
|
private final GameScoreRepository gameScoreRepository;
|
||||||
|
|
||||||
|
public PlatformStatusService(GameScoreRepository gameScoreRepository) {
|
||||||
|
this.gameScoreRepository = gameScoreRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Abfrage ist absichtlich echt: Nur wenn der Fortschrittsspeicher lesbar
|
||||||
|
* ist, meldet das Dashboard das Lernnetz als bereit.
|
||||||
|
*/
|
||||||
|
public PlatformStatus currentStatus() {
|
||||||
|
try {
|
||||||
|
gameScoreRepository.count();
|
||||||
|
return new PlatformStatus(true, REGISTERED_MODULES, Instant.now());
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
log.warn("Fortschrittsspeicher ist für den Lernnetz-Status nicht erreichbar: {}", exception.getMessage());
|
||||||
|
return new PlatformStatus(false, REGISTERED_MODULES, Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.Achievement;
|
||||||
|
import com.example.mindflux.model.GameScore;
|
||||||
|
import com.example.mindflux.model.NeuralNetConnection;
|
||||||
|
import com.example.mindflux.model.NeuralNetMessage;
|
||||||
|
import com.example.mindflux.model.User;
|
||||||
|
import com.example.mindflux.repository.AchievementRepository;
|
||||||
|
import com.example.mindflux.repository.GameScoreRepository;
|
||||||
|
import com.example.mindflux.repository.NeuralNetConnectionRepository;
|
||||||
|
import com.example.mindflux.repository.NeuralNetMessageRepository;
|
||||||
|
import com.example.mindflux.repository.NeuralNetPrivacyRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt alle personenbezogenen Daten eines Kontos für den DSGVO-Datenexport
|
||||||
|
* (Art. 20 – Datenübertragbarkeit). Bewusst OHNE das Passwort-Hash; nur Daten,
|
||||||
|
* die die betroffene Person selbst erzeugt hat.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ProfileExportService {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
private final GameScoreRepository scores;
|
||||||
|
private final AchievementRepository achievements;
|
||||||
|
private final NeuralNetPrivacyRepository privacy;
|
||||||
|
private final NeuralNetConnectionRepository connections;
|
||||||
|
private final NeuralNetMessageRepository messages;
|
||||||
|
|
||||||
|
public ProfileExportService(UserService userService, GameScoreRepository scores,
|
||||||
|
AchievementRepository achievements, NeuralNetPrivacyRepository privacy,
|
||||||
|
NeuralNetConnectionRepository connections, NeuralNetMessageRepository messages) {
|
||||||
|
this.userService = userService;
|
||||||
|
this.scores = scores;
|
||||||
|
this.achievements = achievements;
|
||||||
|
this.privacy = privacy;
|
||||||
|
this.connections = connections;
|
||||||
|
this.messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> export(String username) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("exportiertAm", LocalDateTime.now().toString());
|
||||||
|
out.put("hinweis", "DSGVO-Datenexport (Art. 20). Enthält alle zu diesem Konto gespeicherten "
|
||||||
|
+ "personenbezogenen Daten außer dem Passwort-Hash.");
|
||||||
|
|
||||||
|
User user = userService.findByUsername(username);
|
||||||
|
Map<String, Object> account = new LinkedHashMap<>();
|
||||||
|
if (user != null) {
|
||||||
|
account.put("benutzername", user.getUsername());
|
||||||
|
account.put("email", user.getEmail());
|
||||||
|
account.put("rolle", user.getRole());
|
||||||
|
account.put("emailBestaetigt", user.isEmailVerified());
|
||||||
|
account.put("hatProfilbild", user.getProfilePicture() != null && !user.getProfilePicture().isBlank());
|
||||||
|
}
|
||||||
|
out.put("konto", account);
|
||||||
|
|
||||||
|
List<Map<String, Object>> scoreList = new ArrayList<>();
|
||||||
|
for (GameScore s : scores.findByUsernameOrderByPlayedAtDesc(username)) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("spiel", s.getGame());
|
||||||
|
m.put("deck", s.getDeck());
|
||||||
|
m.put("punkte", s.getScore());
|
||||||
|
m.put("zuege", s.getMoves());
|
||||||
|
m.put("dauerSekunden", s.getDurationSeconds());
|
||||||
|
m.put("gespieltAm", s.getPlayedAt() != null ? s.getPlayedAt().toString() : null);
|
||||||
|
scoreList.add(m);
|
||||||
|
}
|
||||||
|
out.put("spielstaende", scoreList);
|
||||||
|
|
||||||
|
List<Map<String, Object>> achievementList = new ArrayList<>();
|
||||||
|
for (Achievement a : achievements.findByUsername(username)) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("titel", a.getTitle());
|
||||||
|
m.put("beschreibung", a.getDescription());
|
||||||
|
m.put("erreichtAm", a.getDateAchieved() != null ? a.getDateAchieved().toString() : null);
|
||||||
|
achievementList.add(m);
|
||||||
|
}
|
||||||
|
out.put("erfolge", achievementList);
|
||||||
|
|
||||||
|
Map<String, Object> neural = new LinkedHashMap<>();
|
||||||
|
neural.put("nachrichtenAktiviert", privacy.findById(username).map(p -> p.isEnabled()).orElse(false));
|
||||||
|
List<Map<String, Object>> connList = new ArrayList<>();
|
||||||
|
for (NeuralNetConnection c : connections.findAllInvolving(username)) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("anfragender", c.getRequester());
|
||||||
|
m.put("empfaenger", c.getAddressee());
|
||||||
|
m.put("status", c.getStatus());
|
||||||
|
m.put("erstelltAm", c.getCreatedAt() != null ? c.getCreatedAt().toString() : null);
|
||||||
|
connList.add(m);
|
||||||
|
}
|
||||||
|
neural.put("verbindungen", connList);
|
||||||
|
List<Map<String, Object>> msgList = new ArrayList<>();
|
||||||
|
for (NeuralNetMessage msg : messages.findAllInvolving(username)) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("von", msg.getSender());
|
||||||
|
m.put("an", msg.getRecipient());
|
||||||
|
m.put("text", msg.getBody());
|
||||||
|
m.put("gesendetAm", msg.getSentAt() != null ? msg.getSentAt().toString() : null);
|
||||||
|
m.put("gelesen", msg.getReadAt() != null);
|
||||||
|
msgList.add(m);
|
||||||
|
}
|
||||||
|
neural.put("nachrichten", msgList);
|
||||||
|
out.put("neuralNet", neural);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.example.mindflux.service;
|
|||||||
import com.example.mindflux.dto.GameProgress;
|
import com.example.mindflux.dto.GameProgress;
|
||||||
import com.example.mindflux.dto.GameStats;
|
import com.example.mindflux.dto.GameStats;
|
||||||
import com.example.mindflux.dto.ProgressData;
|
import com.example.mindflux.dto.ProgressData;
|
||||||
|
import com.example.mindflux.dto.WeeklyActivity;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProgressService {
|
public class ProgressService {
|
||||||
@@ -91,6 +95,25 @@ public class ProgressService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aktuelle Wochenwerte, jeweils Montag bis Sonntag. */
|
||||||
|
public WeeklyActivity getCurrentWeekActivity(String username) {
|
||||||
|
LocalDate monday = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||||
|
return gameScoreService.weeklyActivity(username, monday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die tatsächlich gespielten Spiele, nach ihrem Highscore sortiert. */
|
||||||
|
public List<GameProgress> getTopGames(String username, int limit) {
|
||||||
|
List<GameProgress> games = getGameProgress(username).stream()
|
||||||
|
.filter(game -> game.getPlays() > 0)
|
||||||
|
.sorted(Comparator.comparingInt(GameProgress::getHighscore).reversed()
|
||||||
|
.thenComparing(Comparator.comparingInt(GameProgress::getPlays).reversed()))
|
||||||
|
.limit(limit)
|
||||||
|
.toList();
|
||||||
|
int topScore = games.stream().mapToInt(GameProgress::getHighscore).max().orElse(1);
|
||||||
|
games.forEach(game -> game.setProgressPercent(Math.round(game.getHighscore() * 100f / topScore)));
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
private GameProgress toGameProgress(String displayName, GameStats stats) {
|
private GameProgress toGameProgress(String displayName, GameStats stats) {
|
||||||
GameProgress game = new GameProgress();
|
GameProgress game = new GameProgress();
|
||||||
game.setName(displayName);
|
game.setName(displayName);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public enum RegistrationResult {
|
|||||||
USERNAME_INVALID,
|
USERNAME_INVALID,
|
||||||
USERNAME_TAKEN,
|
USERNAME_TAKEN,
|
||||||
EMAIL_INVALID,
|
EMAIL_INVALID,
|
||||||
|
EMAIL_TAKEN,
|
||||||
PASSWORD_MISMATCH,
|
PASSWORD_MISMATCH,
|
||||||
PASSWORD_TOO_SHORT
|
PASSWORD_TOO_SHORT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.dto.*;
|
||||||
|
import com.example.mindflux.model.*;
|
||||||
|
import com.example.mindflux.repository.ServiceClashShiftRepository;
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
|
import com.fasterxml.jackson.databind.node.*;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ServiceClashService {
|
||||||
|
private static final String GAME="serviceclash", SESSION="serviceclash.run"; private static final int MAX_DURATION=14_400;
|
||||||
|
private final ServiceClashShiftRepository shifts; private final GameScoreService scores; private final AchievementService achievements; private final ObjectMapper mapper;
|
||||||
|
public ServiceClashService(ServiceClashShiftRepository shifts,GameScoreService scores,AchievementService achievements,ObjectMapper mapper){this.shifts=shifts;this.scores=scores;this.achievements=achievements;this.mapper=mapper;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode catalog(){ObjectNode root=mapper.createObjectNode();ArrayNode rows=root.putArray("shifts");for(ServiceClashShift shift:shifts.findAllByActiveTrueOrderBySortOrderAscIdAsc()){ObjectNode row=rows.addObject();row.put("slug",shift.getSlug());row.put("title",shift.getTitle());row.put("description",shift.getDescription());row.put("difficulty",shift.getDifficulty());row.put("examScope",shift.getExamScope());row.put("ticketCount",shift.getTickets().size());row.put("waves",shift.getTickets().stream().mapToInt(ServiceClashTicket::getWave).max().orElse(1));}return root;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){ServiceClashShift shift=shift(slug);ObjectNode root=mapper.createObjectNode();root.put("slug",shift.getSlug());root.put("title",shift.getTitle());root.put("description",shift.getDescription());root.put("difficulty",shift.getDifficulty());root.put("examScope",shift.getExamScope());Map<Long,ObjectNode> raw=new LinkedHashMap<>();ArrayNode tickets=root.putArray("tickets");int max=0;for(ServiceClashTicket ticket:shift.getTickets()){ObjectNode payload=parse(ticket.getPayload());if(payload==null)continue;raw.put(ticket.getId(),payload);ObjectNode safe=tickets.addObject();safe.put("ticketId",ticket.getId());safe.put("title",ticket.getTitle());safe.put("wave",ticket.getWave());safe.put("points",ticket.getPoints());safe.put("requester",payload.path("requester").asText());safe.put("text",payload.path("text").asText());ArrayNode options=safe.putArray("options");List<JsonNode> shuffled=new ArrayList<>();payload.path("options").forEach(shuffled::add);Collections.shuffle(shuffled);for(JsonNode option:shuffled){ObjectNode row=options.addObject();row.put("id",option.path("id").asText());row.put("text",option.path("text").asText());}max+=ticket.getPoints();}root.put("maxScore",max);http.setAttribute(SESSION,new Run(shift.getSlug(),raw));return root;}
|
||||||
|
public JsonNode check(ServiceClashCheckRequest request,HttpSession http){if(request==null||request.getTicketId()==null||!Set.of("classify","priority","solve").contains(request.getStep()))throw new IllegalArgumentException("Die Prüfdaten sind unvollständig.");Run run=run(http);ObjectNode payload=run.payloads.get(request.getTicketId());if(payload==null)throw new IllegalArgumentException("Dieses Ticket gehört nicht zu deiner Schicht.");State state=run.states.computeIfAbsent(request.getTicketId(),id->new State());String step=request.getStep();if(state.completed(step))throw new IllegalArgumentException("Dieser Schritt ist bereits abgeschlossen.");if(!request.isReveal()&&(request.getValue()==null||request.getValue().isBlank()))throw new IllegalArgumentException("Wähle zuerst eine Antwort.");boolean correct=request.isReveal()?false:expected(payload,step).equals(request.getValue());state.attempts.merge(step,1,Integer::sum);if(request.isReveal()){state.revealed.add(step);state.setCompleted(step,true);} else if(correct){state.setCompleted(step,true);} else if(step.equals("solve")&&state.attempts.get(step)>=3){state.setCompleted(step,true);}ObjectNode result=mapper.createObjectNode();result.put("step",step);result.put("correct",correct);result.put("resolved",state.completed(step));result.put("attempts",state.attempts.get(step));result.put("canReveal",!state.completed(step));if(state.completed(step)&&(!correct||request.isReveal()))result.put("answer",expected(payload,step));if(state.ticketDone()){result.put("ticketDone",true);result.put("explanation",payload.path("explanation").asText());result.put("solution",payload.path("solution").asText());}return result;}
|
||||||
|
@Transactional public ServiceClashResult finish(String username,ServiceClashFinishRequest request,HttpSession http){if(request==null||request.getSlug()==null||request.getSlug().isBlank()||request.getDurationSeconds()<1||request.getDurationSeconds()>MAX_DURATION)throw new IllegalArgumentException("Unplausibler Abschluss.");Run run=run(http);if(!run.slug.equals(request.getSlug()))throw new IllegalArgumentException("Diese Schicht wurde nicht angefordert.");ServiceClashShift shift=shift(request.getSlug());int score=0,max=0,resolved=0,health=100;boolean flawless=true;for(ServiceClashTicket ticket:shift.getTickets()){max+=ticket.getPoints();State state=run.states.get(ticket.getId());if(state!=null&&state.ticketDone()){resolved++;double ratio=stepRatio(state,"classify",.20)+stepRatio(state,"priority",.30)+stepRatio(state,"solve",.50);score+=(int)Math.round(ticket.getPoints()*ratio);if(ratio<1)flawless=false;}else{health-=15;flawless=false;}}health=Math.max(0,health);int stars=stars(score,max);ScoreResult saved=scores.recordScore(username,GAME,shift.getSlug(),score,resolved,request.getDurationSeconds());if(resolved>0)achievements.awardSpecial(username,"Leitstand-Einsatz","Schließe eine ServiceClash-Schicht mit Punkten ab.");if(flawless&&resolved==shift.getTickets().size())achievements.awardSpecial(username,"Incident Commander","Löse eine komplette ServiceClash-Schicht ohne Fehler oder Aufdecken.");http.removeAttribute(SESSION);return new ServiceClashResult(score,max,stars,resolved,shift.getTickets().size(),health,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays(),flawless);}
|
||||||
|
private double stepRatio(State state,String step,double weight){if(!state.completed(step)||state.revealed.contains(step))return 0;int attempts=state.attempts.getOrDefault(step,1);return weight*(attempts==1?1:attempts==2?.6:.3);} private String expected(ObjectNode payload,String step){return switch(step){case "classify"->payload.path("category").asText();case "priority"->payload.path("priority").asText();default->payload.path("solution").asText();};}private ServiceClashShift shift(String slug){return shifts.findBySlug(slug).filter(ServiceClashShift::isActive).orElseThrow(()->new IllegalArgumentException("Schicht nicht gefunden."));}private ObjectNode parse(String raw){try{JsonNode node=mapper.readTree(raw);return node instanceof ObjectNode value?value:null;}catch(Exception e){return null;}}private int stars(int score,int max){if(score<=0||max<=0)return 0;double ratio=(double)score/max;return ratio>=.8?3:ratio>=.5?2:1;}private Run run(HttpSession http){Object value=http.getAttribute(SESSION);if(value instanceof Run run)return run;throw new IllegalArgumentException("Wähle zuerst eine Schicht aus.");}
|
||||||
|
static class Run {final String slug;final Map<Long,ObjectNode> payloads;final Map<Long,State> states=new HashMap<>();Run(String slug,Map<Long,ObjectNode> payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State {final Map<String,Integer> attempts=new HashMap<>();final Set<String> revealed=new HashSet<>(),done=new HashSet<>();boolean completed(String step){return done.contains(step);}void setCompleted(String step,boolean value){if(value)done.add(step);}boolean ticketDone(){return done.containsAll(Set.of("classify","priority","solve"));}}
|
||||||
|
}
|
||||||
@@ -85,6 +85,12 @@ public class TechtacularService {
|
|||||||
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
|
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public AnswerResult checkAnswer(Long questionId, Long answerId) {
|
public AnswerResult checkAnswer(Long questionId, Long answerId) {
|
||||||
|
return checkAnswer(questionId, answerId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bewertet eine Antwort und reduziert nur bei einem gueltig eingesetzten Joker die Punkte. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public AnswerResult checkAnswer(Long questionId, Long answerId, boolean hintUsed) {
|
||||||
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
|
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
|
||||||
if (question == null) {
|
if (question == null) {
|
||||||
return new AnswerResult(false, null, "", 0);
|
return new AnswerResult(false, null, "", 0);
|
||||||
@@ -92,12 +98,36 @@ public class TechtacularService {
|
|||||||
TechtacularAnswer correct = question.correctAnswer();
|
TechtacularAnswer correct = question.correctAnswer();
|
||||||
Long correctId = correct != null ? correct.getId() : null;
|
Long correctId = correct != null ? correct.getId() : null;
|
||||||
boolean isCorrect = correctId != null && correctId.equals(answerId);
|
boolean isCorrect = correctId != null && correctId.equals(answerId);
|
||||||
return new AnswerResult(isCorrect, correctId, question.getExplanation(), isCorrect ? question.getPoints() : 0);
|
int points = isCorrect ? question.getPoints() : 0;
|
||||||
|
if (hintUsed) {
|
||||||
|
points /= 2;
|
||||||
|
}
|
||||||
|
return new AnswerResult(isCorrect, correctId, question.getExplanation(), points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waehlt bis zu zwei falsche Antworten aus. Die korrekte Antwort wird dabei weder
|
||||||
|
* entfernt noch als separate Information an das Frontend uebermittelt.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FiftyFiftyHint fiftyFifty(Long questionId) {
|
||||||
|
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
|
||||||
|
if (question == null) {
|
||||||
|
return new FiftyFiftyHint(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> removableIds = question.getAnswers().stream()
|
||||||
|
.filter(answer -> !answer.isCorrect())
|
||||||
|
.map(TechtacularAnswer::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toCollection(ArrayList::new));
|
||||||
|
Collections.shuffle(removableIds, random);
|
||||||
|
return new FiftyFiftyHint(removableIds.stream().limit(2).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
|
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
|
||||||
@Transactional
|
@Transactional
|
||||||
public TechtacularResult finish(String username, FinishRequest request) {
|
public TechtacularResult finish(String username, FinishRequest request, Set<Long> hintedQuestionIds) {
|
||||||
int score = 0;
|
int score = 0;
|
||||||
int maxScore = 0;
|
int maxScore = 0;
|
||||||
int correctCount = 0;
|
int correctCount = 0;
|
||||||
@@ -116,7 +146,11 @@ public class TechtacularService {
|
|||||||
maxScore += question.getPoints();
|
maxScore += question.getPoints();
|
||||||
TechtacularAnswer correct = question.correctAnswer();
|
TechtacularAnswer correct = question.correctAnswer();
|
||||||
if (correct != null && correct.getId().equals(answer.getAnswerId())) {
|
if (correct != null && correct.getId().equals(answer.getAnswerId())) {
|
||||||
score += question.getPoints();
|
// hintUsed reist mit dem DTO fuer eine nachvollziehbare Client-UI;
|
||||||
|
// fuer die Wertung ist ausschliesslich die Session-Information massgeblich.
|
||||||
|
boolean jokerUsedForQuestion = hintedQuestionIds != null
|
||||||
|
&& hintedQuestionIds.contains(question.getId());
|
||||||
|
score += jokerUsedForQuestion ? question.getPoints() / 2 : question.getPoints();
|
||||||
correctCount++;
|
correctCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +162,12 @@ public class TechtacularService {
|
|||||||
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
|
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kompatibilitaetsvariante fuer Aufrufer ohne rundenbezogenen Joker-Zustand. */
|
||||||
|
@Transactional
|
||||||
|
public TechtacularResult finish(String username, FinishRequest request) {
|
||||||
|
return finish(username, request, Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Admin ----------
|
// ---------- Admin ----------
|
||||||
|
|
||||||
/** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
|
/** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ public class UserServiceImpl implements UserService {
|
|||||||
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
|
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
|
||||||
return RegistrationResult.USERNAME_TAKEN;
|
return RegistrationResult.USERNAME_TAKEN;
|
||||||
}
|
}
|
||||||
|
if (!userRepository.findAllByEmailIgnoreCase(email.trim()).isEmpty()) {
|
||||||
|
return RegistrationResult.EMAIL_TAKEN;
|
||||||
|
}
|
||||||
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
|
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
|
||||||
|
user.setEmailVerified(false);
|
||||||
try {
|
try {
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ spring.output.ansi.enabled=ALWAYS
|
|||||||
|
|
||||||
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
|
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
|
||||||
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
|
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
|
||||||
|
mindflux.mail.from=${MAIL_USERNAME:noreply@sysnaptik.de}
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# E-Mail-Versand
|
||||||
|
# =======================
|
||||||
|
spring.mail.host=${MAIL_HOST:localhost}
|
||||||
|
spring.mail.port=${MAIL_PORT:587}
|
||||||
|
spring.mail.username=${MAIL_USERNAME:}
|
||||||
|
spring.mail.password=${MAIL_PASSWORD:}
|
||||||
|
spring.mail.properties.mail.smtp.auth=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.required=true
|
||||||
|
spring.mail.properties.mail.smtp.connectiontimeout=5000
|
||||||
|
spring.mail.properties.mail.smtp.timeout=5000
|
||||||
|
spring.mail.properties.mail.smtp.writetimeout=5000
|
||||||
|
|
||||||
# =======================
|
# =======================
|
||||||
# Datei-Uploads (Profilbilder)
|
# Datei-Uploads (Profilbilder)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"hunts": [
|
||||||
|
{
|
||||||
|
"slug": "falsche-rechnung", "title": "Fall #1: Die falsche Rechnung", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||||
|
"description": "Eine Rechnung liefert im letzten Monat einen falschen Wert.", "briefing": "Die Monatsauswertung soll alle zwölf Monate addieren. Das Ticket nennt nur einen fehlenden Dezemberwert.", "consoleOutput": "[report] Summe 2025: 11 Monate verarbeitet\n[warn] Erwartet: 12 Monatswerte",
|
||||||
|
"files": [{"filename":"Jahresreport.java","content":"public class Jahresreport {\n static int summe(int[] monate) {\n int total = 0;\n for (int i = 0; i <= monate.length; i++) {\n total += monate[i];\n }\n return total;\n }\n}"}],
|
||||||
|
"bugs": [{"points":100,"file":"Jahresreport.java","line":4,"category":"logik","evidence":"Die Schleife greift nach dem letzten Array-Element zu.","fixOptions":["for (int i = 0; i < monate.length; i++) {","for (int i = 1; i <= monate.length; i++) {","for (int i = 0; i <= monate.length - 1; i += 2) {","for (int i = monate.length; i >= 0; i--) {"],"fixIndex":0,"fixedLine":" for (int i = 0; i < monate.length; i++) {","explanation":"Bei Arrays ist der letzte gültige Index length - 1. Mit < length werden alle Elemente genau einmal verarbeitet."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "ampel-bleibt-rot", "title": "Fall #2: Die sture Ampel", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||||
|
"description": "Die Ampel zeigt immer Rot – selbst bei einer grünen Konfiguration.", "briefing": "Im Schulungsprojekt wird der Status aus einer Konfigurationsdatei gelesen.", "consoleOutput": "Status aus config: green\nUI-Ausgabe: ROT",
|
||||||
|
"files": [{"filename":"Ampel.java","content":"public class Ampel {\n static String anzeigen(String status) {\n if (status = \"green\") {\n return \"GRÜN\";\n }\n return \"ROT\";\n }\n}"}],
|
||||||
|
"bugs": [{"points":100,"file":"Ampel.java","line":3,"category":"syntax","evidence":"Der Vergleich verändert den Wert statt zwei Werte zu vergleichen.","fixOptions":["if (\"green\".equals(status)) {","if (status := \"green\") {","if (status.equals = \"green\") {","if (status =! \"green\") {"],"fixIndex":0,"fixedLine":" if (\"green\".equals(status)) {","explanation":"equals vergleicht Zeichenketten. Die Literalkonstante links verhindert zusätzlich eine NullPointerException."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "menue-fallt-durch", "title": "Fall #3: Das fallende Menü", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||||
|
"description": "Der Punkt ‚Status‘ öffnet unerwartet den Abmelde-Dialog.", "briefing": "Ein Azubi meldet einen Fehler im Konsolenmenü. Prüfe die Verzweigungen.", "consoleOutput": "Eingabe: 2\nStatus: OK\nAbmeldung gestartet",
|
||||||
|
"files": [{"filename":"Menü.java","content":"switch (auswahl) {\n case 1:\n System.out.println(\"Start\");\n break;\n case 2:\n System.out.println(\"Status: OK\");\n case 3:\n System.out.println(\"Abmeldung gestartet\");\n break;\n default:\n System.out.println(\"Ungültig\");\n}"}],
|
||||||
|
"bugs": [{"points":100,"file":"Menü.java","line":6,"category":"logik","evidence":"Nach dem Status-Fall wird der nächste case ebenfalls ausgeführt.","fixOptions":[" break;"," continue;"," return false;"," case 2: break;"],"fixIndex":0,"fixedLine":" break;","explanation":"Ohne break fällt ein switch in den nächsten Fall durch (fall-through)."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "absturz-um-mitternacht", "title": "Fall #4: Der Absturz um Mitternacht", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Ein nächtlicher Job stürzt sporadisch ab.", "briefing": "Der Job läuft um 00:00 Uhr. Der Stacktrace zeigt auf die Formatierung des letzten Ereignisses.", "consoleOutput": "java.lang.NullPointerException: Cannot invoke \"String.toUpperCase()\"\n at Nachtjob.letztesEreignis(Nachtjob.java:5)\n at Nachtjob.main(Nachtjob.java:10)",
|
||||||
|
"files": [{"filename":"Nachtjob.java","content":"public class Nachtjob {\n static String letztesEreignis(String event) {\n if (event == null) {\n System.out.println(\"Kein Ereignis vorhanden\");\n }\n return event.toUpperCase();\n }\n\n public static void main(String[] args) {\n System.out.println(letztesEreignis(null));\n }\n}"}],
|
||||||
|
"bugs": [{"points":150,"file":"Nachtjob.java","line":5,"category":"laufzeit","evidence":"Der Stacktrace verweist direkt auf toUpperCase in Zeile 5.","fixOptions":[" return \"KEIN EREIGNIS\";"," event = event.toUpperCase();"," throw new NullPointerException();"," System.out.println(event.toUpperCase());"],"fixIndex":0,"fixedLine":" return \"KEIN EREIGNIS\";","explanation":"Nach der Null-Prüfung muss die Methode einen sicheren Ersatzwert zurückgeben, bevor event verwendet wird."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "offenes-login", "title": "Fall #5: Das offene Login", "language": "PHP", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Ein Sicherheitscheck meldet eine manipulierbare Login-Abfrage.", "briefing": "Die Eingabe eines Benutzernamens landet direkt in der SQL-Abfrage. Finde die Schwachstelle und wähle den sicheren Fix.", "consoleOutput": "Scanner: CWE-89 SQL Injection\nTest-Eingabe: ' OR '1'='1",
|
||||||
|
"files": [{"filename":"login.php","content":"<?php\nfunction login($pdo, $name) {\n $sql = \"SELECT id FROM users WHERE name = '$name'\";\n return $pdo->query($sql)->fetch();\n}\n?>"}],
|
||||||
|
"bugs": [{"points":150,"file":"login.php","line":3,"category":"sicherheit","evidence":"Der externe Wert $name wird in einen SQL-String interpoliert.","fixOptions":[" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);"," $name = addslashes($name); $sql = \"SELECT id FROM users WHERE name = '$name'\";"," $sql = \"SELECT id FROM users WHERE name LIKE '%$name%'\";"," $sql = \"SELECT id FROM users WHERE name = \" . $name;"],"fixIndex":0,"fixedLine":" $stmt = $pdo->prepare('SELECT id FROM users WHERE name = ?'); $stmt->execute([$name]);","explanation":"Prepared Statements trennen SQL-Code und Nutzdaten. Escaping allein ist kein zuverlässiger Schutz."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "geteilte-liste", "title": "Fall #6: Die geteilte Liste", "language": "Java", "difficulty": "principal", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Parallele Worker verlieren gelegentlich Aufträge aus ihrer Queue.", "briefing": "Zwei Worker greifen gleichzeitig auf die gemeinsame Auftragsliste zu. Die Ursache liegt nicht dort, wo der Fehler sichtbar wird.", "consoleOutput": "Exception in thread worker-2 java.util.ConcurrentModificationException\n at java.base/java.util.ArrayList$Itr.next\n at Worker.run(Worker.java:6)",
|
||||||
|
"files": [
|
||||||
|
{"filename":"Worker.java","content":"public class Worker implements Runnable {\n private final Auftragsliste liste;\n public Worker(Auftragsliste liste) { this.liste = liste; }\n public void run() {\n for (String job : liste.jobs()) {\n System.out.println(job);\n }\n }\n}"},
|
||||||
|
{"filename":"Auftragsliste.java","content":"import java.util.*;\npublic class Auftragsliste {\n private final List<String> jobs = new ArrayList<>();\n public void add(String job) { jobs.add(job); }\n public List<String> jobs() { return jobs; }\n}"}
|
||||||
|
],
|
||||||
|
"bugs": [{"points":200,"file":"Auftragsliste.java","line":5,"category":"sicherheit","evidence":"Die interne, veränderbare Liste wird direkt an parallele Aufrufer zurückgegeben.","fixOptions":[" public List<String> jobs() { return List.copyOf(jobs); }"," public List<String> jobs() { return new ArrayList<>(); }"," public List<String> jobs() { return null; }"," public List<String> jobs() { jobs.clear(); return jobs; }"],"fixIndex":0,"fixedLine":" public List<String> jobs() { return List.copyOf(jobs); }","explanation":"Eine unveränderliche Momentaufnahme verhindert, dass externe Aufrufer die interne Liste gleichzeitig verändern. Für echte gemeinsame Mutation wäre zusätzlich eine threadsichere Datenstruktur nötig."}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "archiv-unter-druck", "title": "Fall #7: Archiv unter Druck", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Der tägliche Audit-Export bricht bei einem leeren Archiv ab und die CSV-Route stürzt nur in der Nachtverarbeitung ab.", "briefing": "Das Compliance-Team benötigt den Export vor 06:00 Uhr. Zwei Fehler liegen in getrennten Dateien; der Stacktrace nennt nur den Controller.", "consoleOutput": "java.lang.NullPointerException: Cannot invoke \"String.equals(Object)\"\n at ExportController.ausliefern(ExportController.java:4)\n[warn] Audit-Archiv enthält heute keine Einträge",
|
||||||
|
"files": [
|
||||||
|
{"filename":"AuditExport.java","content":"import java.util.List;\n\npublic class AuditExport {\n public String export(List<String> einträge) {\n if (einträge == null && einträge.isEmpty()) {\n return \"Keine Einträge\";\n }\n return String.join(\"\\n\", einträge);\n }\n}"},
|
||||||
|
{"filename":"ExportController.java","content":"public class ExportController {\n public String ausliefern(String format, AuditExport export, java.util.List<String> einträge) {\n if (format.equals(\"csv\")) {\n return export.export(einträge);\n }\n return \"Format nicht unterstützt\";\n }\n}"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":150,"file":"AuditExport.java","line":5,"category":"laufzeit","evidence":"Bei null wird rechts von && trotzdem ein Methodenaufruf ausgeführt.","fixOptions":[" if (einträge == null || einträge.isEmpty()) {"," if (einträge != null && einträge.isEmpty()) {"," if (einträge.isEmpty() || einträge == null) {"," if (einträge == null & einträge.isEmpty()) {"],"fixIndex":0,"fixedLine":" if (einträge == null || einträge.isEmpty()) {","explanation":"Die ODER-Verknüpfung beendet die Auswertung sofort, wenn die Liste null ist. Danach kann isEmpty sicher geprüft werden."},
|
||||||
|
{"points":150,"file":"ExportController.java","line":3,"category":"laufzeit","evidence":"Der Stacktrace zeigt auf den Methodenaufruf equals; format kann aus einem optionalen Jobparameter stammen.","fixOptions":[" if (\"csv\".equals(format)) {"," if (format == \"csv\") {"," if (format.equalsIgnoreCase == \"csv\") {"," if (format != \"csv\") {"],"fixIndex":0,"fixedLine":" if (\"csv\".equals(format)) {","explanation":"Wird die feste Zeichenkette links verwendet, liefert equals bei einem null-Wert einfach false statt eine NullPointerException auszulösen."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "buchung-im-doppelpack", "title": "Fall #8: Buchung im Doppelpack", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Nach einem Retry erscheinen Bestellungen doppelt in der Buchhaltung; zusätzlich wird eine Nullzahlung nicht erkannt.", "briefing": "Ein Zahlungsanbieter sendet einen Retry, wenn die Antwort verzögert ist. Prüfe die Idempotenz und die Geldwert-Prüfung, bevor der Monatsabschluss startet.", "consoleOutput": "[payment] retry received for order A-1042\n[ledger] duplicate reference A-1042\n[warn] zero amount accepted",
|
||||||
|
"files": [
|
||||||
|
{"filename":"BestellungService.java","content":"public class BestellungService {\n private final BestellungRepository repository;\n public BestellungService(BestellungRepository repository) { this.repository = repository; }\n\n public void speichern(Bestellung bestellung) {\n if (repository.existsByReferenz(bestellung.referenz())) {\n repository.save(bestellung);\n }\n }\n}"},
|
||||||
|
{"filename":"ZahlungPruefung.java","content":"import java.math.BigDecimal;\n\npublic class ZahlungPruefung {\n static boolean istNullzahlung(BigDecimal betrag) {\n return betrag == BigDecimal.ZERO;\n }\n}"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":150,"file":"BestellungService.java","line":6,"category":"logik","evidence":"Bei einer bereits bekannten Referenz wird die Bestellung erneut gespeichert.","fixOptions":[" if (!repository.existsByReferenz(bestellung.referenz())) {"," if (repository.existsByReferenz(bestellung.referenz()) == null) {"," if (repository.existsByReferenz(bestellung.referenz()) = false) {"," if (repository.existsByReferenz(bestellung.referenz()) || true) {"],"fixIndex":0,"fixedLine":" if (!repository.existsByReferenz(bestellung.referenz())) {","explanation":"Eine Referenz dient hier als Idempotenzschlüssel. Nur unbekannte Bestellungen dürfen den Speichervorgang auslösen."},
|
||||||
|
{"points":150,"file":"ZahlungPruefung.java","line":4,"category":"logik","evidence":"BigDecimal-Werte können denselben Betrag darstellen, ohne dass sie dasselbe Objekt sind.","fixOptions":[" return betrag != null && betrag.compareTo(BigDecimal.ZERO) == 0;"," return betrag.equals(BigDecimal.ZERO) || betrag == null;"," return betrag == new BigDecimal(\"0\");"," return betrag.toString() == \"0\";"],"fixIndex":0,"fixedLine":" return betrag != null && betrag.compareTo(BigDecimal.ZERO) == 0;","explanation":"compareTo vergleicht den numerischen Wert unabhängig von der Skala. Zusätzlich verhindert die Null-Prüfung einen Absturz."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "protokoll-ohne-schutz", "title": "Fall #9: Protokoll ohne Schutz", "language": "PHP", "difficulty": "principal", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Ein Security-Audit findet Zugangsdaten im Log und eine unsichere Passwortprüfung im internen API-Gateway.", "briefing": "Das Incident-Team hat einen Logauszug gesichert. Finde beide Schwachstellen, ohne die Fehlersuche selbst unbrauchbar zu machen.", "consoleOutput": "[api] Token: eyJhbGciOiJIUzI1NiJ9...\nSecurity finding: CWE-532 Sensitive Information in Log Files\nSecurity finding: CWE-256 Plaintext Storage of a Password",
|
||||||
|
"files": [
|
||||||
|
{"filename":"api.php","content":"<?php\nfunction requestApi(string $token, string $path): void {\n error_log('API request ' . $path . ' with token ' . $token);\n // Anfrage senden\n}\n?>"},
|
||||||
|
{"filename":"auth.php","content":"<?php\nfunction anmelden(array $user, string $passwort): bool {\n return $user['password'] == $passwort;\n}\n?>"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":200,"file":"api.php","line":3,"category":"sicherheit","evidence":"Der vollständige Bearer-Token wird in eine dauerhaft gespeicherte Logzeile übernommen.","fixOptions":[" error_log('API request ' . $path . ' with token [redigiert]');"," error_log('API request ' . $path . ' with token ' . substr($token, 0));"," error_log($token);"," error_log(base64_encode($token));"],"fixIndex":0,"fixedLine":" error_log('API request ' . $path . ' with token [redigiert]');","explanation":"Authentifizierungsdaten gehören nie in Logs. Für die Diagnose reichen Pfad, Request-ID und ein klarer Hinweis auf einen vorhandenen Token."},
|
||||||
|
{"points":200,"file":"auth.php","line":3,"category":"sicherheit","evidence":"Das Passwort wird direkt mit einem gespeicherten Wert verglichen; sichere Passwort-Hashes benötigen eine spezialisierte Prüfung.","fixOptions":[" return password_verify($passwort, $user['password_hash']);"," return md5($passwort) == $user['password'];"," return $user['password'] === $passwort;"," return strlen($passwort) > 0;"],"fixIndex":0,"fixedLine":" return password_verify($passwort, $user['password_hash']);","explanation":"password_verify prüft einen Passwort-Hash inklusive Salt und Algorithmus sicher. Klartextvergleiche und MD5 sind dafür ungeeignet."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "rabatt-mit-nebenwirkung", "title": "Fall #10: Rabatt mit Nebenwirkung", "language": "Java", "difficulty": "junior", "examScope": "AP1",
|
||||||
|
"description": "Im Campus-Shop erhalten Studierende keinen Rabatt, während die Kasse bei manchen Warenkörben einen Artikel zu viel zählt.", "briefing": "Vor dem Start der Prüfungswoche wurde ein neues Kassenterminal ausgerollt. Der Support meldet zwei widersprüchliche Beobachtungen: Der Rabatt erscheint bei einigen Testkonten nicht, und die Tagesstatistik weicht bei einem Warenkorb mit drei Artikeln um genau eins ab. Die Fachabteilung möchte eine kleine, nachvollziehbare Korrektur statt eines Umbaus der Kasse.", "consoleOutput": "[checkout] Kundengruppe: STUDENT\n[checkout] Rabatt angewendet: false\n[stats] Warenkorb W-17: erwartet 3 Artikel, gezählt 4",
|
||||||
|
"files": [
|
||||||
|
{"filename":"Kundenprofil.java","content":"public class Kundenprofil {\n static boolean hatStudentenrabatt(String gruppe) {\n return gruppe == \"STUDENT\";\n }\n\n static int rabattProzent(String gruppe) {\n return hatStudentenrabatt(gruppe) ? 10 : 0;\n }\n}"},
|
||||||
|
{"filename":"Warenkorb.java","content":"import java.util.List;\n\npublic class Warenkorb {\n static int artikelAnzahl(List<String> artikel) {\n int anzahl = 0;\n for (int i = 0; i <= artikel.size(); i++) {\n anzahl++;\n }\n return anzahl;\n }\n}"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":100,"file":"Kundenprofil.java","line":3,"category":"logik","evidence":"Die Gruppe kommt aus dem Profilservice und kann denselben Text in einem anderen String-Objekt enthalten.","fixOptions":[" return \"STUDENT\".equals(gruppe);"," return gruppe = \"STUDENT\";"," return gruppe.equalsIgnoreCase == \"STUDENT\";"," return gruppe != \"STUDENT\";"],"fixIndex":0,"fixedLine":" return \"STUDENT\".equals(gruppe);","explanation":"Bei Zeichenketten vergleicht == die Objektidentität, nicht den Inhalt. equals prüft den Text; die Konstante links bleibt auch bei einer fehlenden Gruppe sicher."},
|
||||||
|
{"points":100,"file":"Warenkorb.java","line":6,"category":"logik","evidence":"Die Statistik zählt pro Schleifendurchlauf. Bei drei Artikeln wird die Schleife viermal durchlaufen.","fixOptions":[" for (int i = 0; i < artikel.size(); i++) {"," for (int i = 1; i <= artikel.size(); i++) {"," for (int i = 0; i <= artikel.size() - 1; i += 2) {"," for (int i = artikel.size(); i >= 0; i--) {"],"fixIndex":0,"fixedLine":" for (int i = 0; i < artikel.size(); i++) {","explanation":"Die Anzahl der Durchläufe reicht von Index 0 bis vor die Größe der Liste. Mit < artikel.size() wird jeder Artikel genau einmal gezählt."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "freigabe-vor-pruefung", "title": "Fall #11: Freigabe vor Prüfung", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Ein bereits abgelaufener Release wird im Deployment-Portal als freigegeben angezeigt, und ein API-Schlüssel taucht im Diagnoseprotokoll auf.", "briefing": "Das Release-Team stoppte ein Hotfix-Deployment wenige Minuten vor dem Rollout. Die Oberfläche zeigte trotz abgelaufener Freigabe ein grünes Licht. Parallel hat das Security-Team in einem zentralen Log einen produktiven API-Schlüssel entdeckt. Untersuche die Entscheidungslogik und die Diagnoseausgabe; beide Fehler liegen in verschiedenen Komponenten und dürfen nicht mit einem bloßen Workaround kaschiert werden.", "consoleOutput": "[release] Status: GEPRUEFT\n[release] Freigabe gültig bis: 2026-07-12T18:00:00Z\n[release] Bewertung um 2026-07-13T08:14:00Z: freigegeben\n[audit] deployment payments-api mit key sk_live_...",
|
||||||
|
"files": [
|
||||||
|
{"filename":"DeploymentFreigabe.java","content":"import java.time.Instant;\n\npublic class DeploymentFreigabe {\n static boolean darfAusrollen(Release release, Instant jetzt) {\n return release.freigabeBis().isAfter(jetzt)\n && release.status() == \"GEPRUEFT\";\n }\n}\n"},
|
||||||
|
{"filename":"Release.java","content":"import java.time.Instant;\n\npublic record Release(String name, String status, Instant freigabeBis) {\n}\n"},
|
||||||
|
{"filename":"AuditLog.java","content":"public class AuditLog {\n static void deploymentStart(String releaseName, String apiKey) {\n System.out.println(\"deployment \" + releaseName + \" mit key \" + apiKey);\n }\n}"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":150,"file":"DeploymentFreigabe.java","line":6,"category":"logik","evidence":"Der Zeitvergleich ist korrekt; der Status stammt jedoch aus einer Datenbankabfrage und wird als String geliefert.","fixOptions":[" && \"GEPRUEFT\".equals(release.status());"," && release.status() = \"GEPRUEFT\";"," && release.status().equals = \"GEPRUEFT\";"," && release.status() != \"GEPRUEFT\";"],"fixIndex":0,"fixedLine":" && \"GEPRUEFT\".equals(release.status());","explanation":"Der Freigabestatus muss inhaltlich verglichen werden. Mit == kann derselbe Text je nach Herkunft fälschlich als verschieden behandelt werden."},
|
||||||
|
{"points":150,"file":"AuditLog.java","line":3,"category":"sicherheit","evidence":"Der Schlüssel ist ein Credential. Zentrale Logs sind für Betriebsteams einsehbar und oft länger gespeichert als die eigentliche Sitzung.","fixOptions":[" System.out.println(\"deployment \" + releaseName + \" mit key [redigiert]\");"," System.out.println(\"deployment \" + releaseName + \" mit key \" + apiKey.substring(0));"," System.out.println(apiKey);"," System.out.println(\"deployment \" + releaseName + \" mit key \" + java.util.Base64.getEncoder().encodeToString(apiKey.getBytes()));"],"fixIndex":0,"fixedLine":" System.out.println(\"deployment \" + releaseName + \" mit key [redigiert]\");","explanation":"API-Schlüssel dürfen nicht protokolliert werden – auch nicht kodiert oder vollständig abgeschnitten. Für die Diagnose reichen Release-Name, Zeit und eine Request-ID."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "bericht-mit-fremdzugriff", "title": "Fall #12: Bericht mit Fremdzugriff", "language": "PHP", "difficulty": "principal", "examScope": "AP2_FIAE",
|
||||||
|
"description": "Ein Mandantenbericht lässt sich über einen manipulierten Dateinamen außerhalb des Archivbereichs aufrufen; außerdem prüft die Berechtigung den Mandanten nicht.", "briefing": "Ein externer Penetrationstest hat zwei Befunde kombiniert: Eine angemeldete Person kann einen ungewöhnlichen Berichtsnamen an den Download-Endpunkt senden und die Zugriffsprüfung bestätigt nur eine allgemeine Rolle. Im Incident-Log steht eine Anfrage, die auf einen fremden Mandanten zeigt. Die Lösung muss Eingaben begrenzen und den Zugriff an den tatsächlich angeforderten Kunden binden, ohne den normalen Monatsreport zu beschädigen.", "consoleOutput": "GET /reports/download?bericht=../../kunden/K-204/umsatz\n[report] Datei angefordert: ../../kunden/K-204/umsatz\n[auth] user U-88, role REPORT_VIEWER, angeforderter Kunde K-204\nSecurity finding: CWE-22 Path Traversal\nSecurity finding: CWE-639 Authorization Bypass Through User-Controlled Key",
|
||||||
|
"files": [
|
||||||
|
{"filename":"ReportController.php","content":"<?php\nfunction downloadReport(): string {\n $name = $_GET['bericht'] ?? '';\n $file = REPORT_DIR . '/' . $name . '.pdf';\n if (!is_file($file)) {\n throw new RuntimeException('Bericht nicht gefunden');\n }\n return file_get_contents($file);\n}\n?>"},
|
||||||
|
{"filename":"ReportAccess.php","content":"<?php\nfunction darfLesen(array $user, string $kundeId): bool {\n return $user['role'] === 'REPORT_VIEWER';\n}\n?>"},
|
||||||
|
{"filename":"ReportAudit.php","content":"<?php\nfunction protokolliereDownload(string $userId, string $kundeId, string $bericht): void {\n error_log(\"report-download user=$userId kunde=$kundeId bericht=$bericht\");\n}\n?>"}
|
||||||
|
],
|
||||||
|
"bugs": [
|
||||||
|
{"points":200,"file":"ReportController.php","line":3,"category":"sicherheit","evidence":"Der Dateiname kommt direkt aus dem Query-Parameter. Die anschließende Dateiprüfung verhindert keinen Pfadwechsel aus REPORT_DIR heraus.","fixOptions":[" $name = $_GET['bericht'] ?? ''; if (!preg_match('/^[A-Z0-9_-]+$/', $name)) { throw new InvalidArgumentException('Ungültiger Bericht'); }"," $name = str_replace('../', '', $_GET['bericht'] ?? '');"," $name = base64_encode($_GET['bericht'] ?? '');"," $name = $_GET['bericht'] ?? ''; chmod(REPORT_DIR, 0777);"],"fixIndex":0,"fixedLine":" $name = $_GET['bericht'] ?? ''; if (!preg_match('/^[A-Z0-9_-]+$/', $name)) { throw new InvalidArgumentException('Ungültiger Bericht'); }","explanation":"Eine Positivliste erlaubt nur die erwarteten Berichtsschlüssel. Das bloße Entfernen einzelner Zeichenfolgen lässt Varianten von Pfadmanipulationen offen."},
|
||||||
|
{"points":200,"file":"ReportAccess.php","line":3,"category":"sicherheit","evidence":"Die Rolle erlaubt das Lesen von Berichten, aber sie beweist nicht, dass der angeforderte Kunde zum Benutzer gehört.","fixOptions":[" return $user['role'] === 'REPORT_VIEWER' && in_array($kundeId, $user['kundenIds'], true);"," return $user['role'] === 'REPORT_VIEWER' || $kundeId !== '';"," return $user['role'] == 'REPORT_VIEWER' && $kundeId == $kundeId;"," return strlen($kundeId) > 0;"],"fixIndex":0,"fixedLine":" return $user['role'] === 'REPORT_VIEWER' && in_array($kundeId, $user['kundenIds'], true);","explanation":"Autorisierung braucht sowohl die passende Rolle als auch eine Mandantenbindung. Der strikte Vergleich in in_array verhindert dabei ungewollte Typumwandlungen."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE bughunter_hunts (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
code_language VARCHAR(50) NOT NULL DEFAULT 'Java',
|
||||||
|
difficulty VARCHAR(30) NOT NULL DEFAULT 'junior',
|
||||||
|
exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1',
|
||||||
|
description VARCHAR(1200),
|
||||||
|
briefing VARCHAR(2000),
|
||||||
|
console_output VARCHAR(4000),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
CONSTRAINT chk_bughunter_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bughunter_files (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content VARCHAR(8000) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bughunter_bugs (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
hunt_id BIGINT NOT NULL REFERENCES bughunter_hunts(id) ON DELETE CASCADE,
|
||||||
|
points INT NOT NULL DEFAULT 100,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
payload VARCHAR(8000) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_bughunter_files_hunt ON bughunter_files(hunt_id);
|
||||||
|
CREATE INDEX idx_bughunter_bugs_hunt ON bughunter_bugs(hunt_id);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE bughunter_hunt_progress (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
hunt_slug VARCHAR(150) NOT NULL,
|
||||||
|
solved_clean BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
variant_index INT NOT NULL DEFAULT 0,
|
||||||
|
solved_at TIMESTAMP,
|
||||||
|
CONSTRAINT uq_bughunter_progress_user_hunt UNIQUE (username, hunt_slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_bughunter_progress_user ON bughunter_hunt_progress(username);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE networker_worlds (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description VARCHAR(1200),
|
||||||
|
difficulty VARCHAR(30) NOT NULL DEFAULT 'junior',
|
||||||
|
exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
CONSTRAINT chk_networker_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE networker_levels (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
world_id BIGINT NOT NULL REFERENCES networker_worlds(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
level_type VARCHAR(30) NOT NULL,
|
||||||
|
points INT NOT NULL DEFAULT 100,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
payload VARCHAR(8000) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_networker_levels_world ON networker_levels(world_id);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE service_clash_shifts (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description VARCHAR(1200),
|
||||||
|
difficulty VARCHAR(30) NOT NULL DEFAULT 'junior',
|
||||||
|
exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP2_FISI',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE service_clash_tickets (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
shift_id BIGINT NOT NULL REFERENCES service_clash_shifts(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(220) NOT NULL,
|
||||||
|
wave INT NOT NULL DEFAULT 1,
|
||||||
|
points INT NOT NULL DEFAULT 100,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
payload VARCHAR(8000) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_service_clash_tickets_shift ON service_clash_tickets(shift_id);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE neuralnet_privacy (
|
||||||
|
username VARCHAR(100) PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE neuralnet_connections (
|
||||||
|
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',
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
responded_at TIMESTAMP,
|
||||||
|
CONSTRAINT uq_neuralnet_connection UNIQUE (requester, addressee),
|
||||||
|
CONSTRAINT chk_neuralnet_no_self CHECK (requester <> addressee)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_neuralnet_connection_addressee ON neuralnet_connections(addressee, status);
|
||||||
|
|
||||||
|
CREATE TABLE neuralnet_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_neuralnet_message_pair ON neuralnet_messages(sender, recipient, sent_at);
|
||||||
|
CREATE INDEX idx_neuralnet_message_unread ON neuralnet_messages(recipient, read_at);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Klar gekennzeichnetes Testkonto für den NeuralNet-Anfrage-/Chat-Flow.
|
||||||
|
-- Passwort wird nicht im Changelog dokumentiert, sondern nur beim Anlegen genannt.
|
||||||
|
INSERT INTO users (username, password, email, role)
|
||||||
|
VALUES ('NeuralTest', '$2a$10$mnDopAl0hEIUkiCTvy2WKuJ5qSX3to.3wyOvO9/pdRRrpVpCfHjlu', 'neuraltest@example.invalid', 'USER')
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO neuralnet_privacy (username, enabled)
|
||||||
|
VALUES ('NeuralTest', TRUE)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET enabled = TRUE;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE account_email_tokens (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
|
purpose VARCHAR(32) NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
consumed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT account_email_tokens_purpose_check
|
||||||
|
CHECK (purpose IN ('EMAIL_VERIFICATION', 'PASSWORD_RESET'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_account_email_tokens_lookup
|
||||||
|
ON account_email_tokens (token_hash, purpose);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AccountEmailToken nutzt java.time.Instant. Hibernate erwartet dafür PostgreSQL-
|
||||||
|
-- Zeitstempel mit Zeitzone; V18 wurde auf bereits laufenden Systemen zuerst angewendet.
|
||||||
|
ALTER TABLE account_email_tokens
|
||||||
|
ALTER COLUMN expires_at TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING expires_at AT TIME ZONE 'UTC',
|
||||||
|
ALTER COLUMN consumed_at TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING consumed_at AT TIME ZONE 'UTC';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- JPA mappt String-Felder standardmäßig auf VARCHAR, nicht auf CHAR (bpchar).
|
||||||
|
ALTER TABLE account_email_tokens
|
||||||
|
ALTER COLUMN token_hash TYPE VARCHAR(64);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- V21: Umlaut-Korrektur fuer bereits geseedete Spiel-Inhalte (bestehende Datenbanken).
|
||||||
|
--
|
||||||
|
-- Die Inhalte von DevLab, TechTacular und BugHunter wurden einmalig aus den
|
||||||
|
-- content.json-Dateien geseedet (Seeder laufen nur bei leerer Tabelle). Die JSON-
|
||||||
|
-- Quellen sind jetzt umlautkorrekt; frische Installationen sind dadurch bereits
|
||||||
|
-- korrekt. Fuer schon befuellte DBs leeren wir die Content-Tabellen, woraufhin die
|
||||||
|
-- CommandLineRunner-Seeder sie beim naechsten Start aus dem korrigierten JSON neu
|
||||||
|
-- aufbauen (gleiche Vorgehensweise wie V6 fuer TechTacular).
|
||||||
|
--
|
||||||
|
-- CASCADE raeumt nur die abhaengigen Content-Tabellen (Tasks/Questions/Answers/
|
||||||
|
-- Files/Bugs). Spielstaende (game_scores speichert das Spiel als String, kein FK)
|
||||||
|
-- und der Fortschritt (bughunter_hunt_progress hat keinen FK auf die Hunts) bleiben
|
||||||
|
-- erhalten; durch RESTART IDENTITY und die deterministische Seed-Reihenfolge sind
|
||||||
|
-- die neuen IDs identisch zu vorher.
|
||||||
|
--
|
||||||
|
-- Networker ist NICHT betroffen: dort war ausschliesslich der (bewusst stabile)
|
||||||
|
-- Slug transliteriert, die sichtbaren Welt-Titel/-Beschreibungen sind bereits korrekt.
|
||||||
|
|
||||||
|
TRUNCATE TABLE devlab_games RESTART IDENTITY CASCADE;
|
||||||
|
TRUNCATE TABLE techtacular_categories RESTART IDENTITY CASCADE;
|
||||||
|
TRUNCATE TABLE bughunter_hunts RESTART IDENTITY CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- V22: Entfernt das Demo-/Testkonto "NeuralTest" (per V17 fuer den NeuralNet-Flow
|
||||||
|
-- geseedet) aus produktiven Datenbanken. Ein klar erkennbares Testkonto gehoert
|
||||||
|
-- nicht in Produktion.
|
||||||
|
--
|
||||||
|
-- Die NeuralNet-Tabellen (neuralnet_privacy, neuralnet_connections, neuralnet_messages)
|
||||||
|
-- referenzieren users(username) mit ON DELETE CASCADE und werden beim Loeschen des
|
||||||
|
-- Kontos automatisch mitgeraeumt. game_scores hat keinen Fremdschluessel, daher wird
|
||||||
|
-- ein evtl. vorhandener Spielstand des Demo-Kontos defensiv vorab entfernt.
|
||||||
|
--
|
||||||
|
-- Wird das Konto fuer die lokale Entwicklung gebraucht, sollte es ueber einen
|
||||||
|
-- @Profile("dev")-gebundenen Runner neu angelegt werden, nicht per Migration.
|
||||||
|
|
||||||
|
DELETE FROM game_scores WHERE username = 'NeuralTest';
|
||||||
|
DELETE FROM users WHERE username = 'NeuralTest';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user