Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1ba38cf0 | |||
| 548a90f15e | |||
| a23a1b4e7e | |||
| 87cd63e373 | |||
| 4bf019747a | |||
| 333af0253e | |||
| 018efdee0f | |||
| 0f2c9eae86 | |||
| 17702554f8 | |||
| 4935d00b4e | |||
| 0b0360be9d | |||
| edb5b36408 | |||
| 986f6226ef | |||
| 4f99593d34 | |||
| 6195a11501 | |||
| 02975ebb25 |
+74
-1
@@ -11,6 +11,32 @@ bewusst zurückgestellte Restpunkte sind dort weiterhin als offen markiert):
|
|||||||
Sicherheit, Datenintegrität, echtes XP/Achievement-System, Navigations-Umbau,
|
Sicherheit, Datenintegrität, echtes XP/Achievement-System, Navigations-Umbau,
|
||||||
Docker/CI-Härtung und erste Tests.
|
Docker/CI-Härtung und erste Tests.
|
||||||
|
|
||||||
|
### Responsive Darstellung & Barrierefreiheit
|
||||||
|
- Logout-Navigation in Dashboard, DevLab, MindMatch, SyntaxSaga und TechTacular auf ein
|
||||||
|
einheitliches Icon mit zugänglichem Namen und Tooltip „Abmelden“ vereinheitlicht.
|
||||||
|
- **Globales Accessibility-Widget:** Ein schwebender Rollstuhl-Button öffnet auf jeder Seite
|
||||||
|
ein tastaturbedienbares Panel. Einstellungen werden ausschließlich lokal im Browser gespeichert.
|
||||||
|
- Enthalten sind vier Textgrößen, Hochkontrast, farbenblindfreundliche Statusfarben, Monochrom,
|
||||||
|
einen Lesemodus für Dyslexie mit Verdana und angepassten Abständen, unterstrichene Links, reduzierte Animationen,
|
||||||
|
großer Cursor und ein zentraler Reset.
|
||||||
|
- Floating-Button an die Mindflux-Palette angepasst; der Schrift-Schalter heißt nun transparent
|
||||||
|
„Lesemodus (Dyslexie)“ und weist auf Verdana plus vergrößerte Abstände hin.
|
||||||
|
- Der dauerhaft erreichbare Auslöser besitzt nun reservierten Layoutplatz statt Spielkarten oder
|
||||||
|
Formulare zu überdecken: seitlich auf Desktop und in einer unteren Schutzzone auf kleinen Displays.
|
||||||
|
- Accessibility-Zugriff in die jeweilige sticky Kopfzeile neben Theme/Logout verschoben; Seiten
|
||||||
|
ohne Werkzeugleiste erhalten eine eigene reservierte Utility-Zeile statt eines Inhalts-Overlays.
|
||||||
|
- Accessibility-Schaltfläche auf dieselben 38 px Außen- und 18 px Iconmaße wie die übrigen
|
||||||
|
Kopfzeilen-Werkzeuge vereinheitlicht.
|
||||||
|
- SystemEscape behält seine Terminal-Typografie im Spiel, verwendet für globale Navigation und
|
||||||
|
Werkzeuge aber die einheitliche Mindflux-Sans-Serif-Schrift.
|
||||||
|
- TechTacular-Kategoriekarten auf identische Höhe gesetzt und zweizeilige Titel mittig ausgerichtet.
|
||||||
|
- Das Panel besitzt Dialogsemantik, sichtbaren Tastaturfokus, Escape-Schließen und verständliche
|
||||||
|
Aktivzustände; Spielzustände oder Benutzerkonten werden dabei nicht verändert.
|
||||||
|
- **DevLab-Spielansicht nutzt große Bildschirme aus:** Die bisherige starre Begrenzung auf
|
||||||
|
1100 px wurde durch ein fluides, bis 2200 px breites Viewport-Layout ersetzt. Hauptbereich,
|
||||||
|
Seitenpanel, Abstände und Grundschrift skalieren mit der verfügbaren Bildschirmgröße.
|
||||||
|
- Auf Mobilgeräten bleibt das Layout einspaltig und setzt die Grundschrift auf mindestens 16 px.
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
- **DB-Passwort aus dem Repo entfernt:** `docker-compose.yml` liest jetzt `${MINDFLUX_DB_PASSWORD}`
|
- **DB-Passwort aus dem Repo entfernt:** `docker-compose.yml` liest jetzt `${MINDFLUX_DB_PASSWORD}`
|
||||||
aus einem Gitea-Secret (wie die Admin-Passwörter).
|
aus einem Gitea-Secret (wie die Admin-Passwörter).
|
||||||
@@ -64,7 +90,7 @@ Docker/CI-Härtung und erste Tests.
|
|||||||
`theme.js`/localStorage); funktionslose Sprach-/Benachrichtigungs-Attrappen entfernt;
|
`theme.js`/localStorage); funktionslose Sprach-/Benachrichtigungs-Attrappen entfernt;
|
||||||
`SettingsController` in den `ProfileController` gemergt; Token-Farbe statt Hardcode.
|
`SettingsController` in den `ProfileController` gemergt; Token-Farbe statt Hardcode.
|
||||||
- **Spielnamen vereinheitlicht** (englisch, zusammengeschrieben): DevLab, SyntaxSaga sowie
|
- **Spielnamen vereinheitlicht** (englisch, zusammengeschrieben): DevLab, SyntaxSaga sowie
|
||||||
CodeEscape, ServiceClash, BugHunter, NetWorker für die geplanten Spiele (Keys in
|
SystemEscape, ServiceClash, BugHunter, NetWorker für die geplanten Spiele (Keys in
|
||||||
`KNOWN_GAMES`/`ProgressService` mit umbenannt – noch keine Scores vorhanden).
|
`KNOWN_GAMES`/`ProgressService` mit umbenannt – noch keine Scores vorhanden).
|
||||||
- **DevLab erscheint jetzt auf der Statistik-Seite** (fehlte in `ProgressService.GAMES`).
|
- **DevLab erscheint jetzt auf der Statistik-Seite** (fehlte in `ProgressService.GAMES`).
|
||||||
- Tote Templates entfernt: `login.html`, `register.html` (Formular lebt auf der Startseite),
|
- Tote Templates entfernt: `login.html`, `register.html` (Formular lebt auf der Startseite),
|
||||||
@@ -86,6 +112,53 @@ Docker/CI-Härtung und erste Tests.
|
|||||||
- **`pw_anleitung.md`:** Betriebsanleitung für DB-Secret, automatische Runner-Rotation,
|
- **`pw_anleitung.md`:** Betriebsanleitung für DB-Secret, automatische Runner-Rotation,
|
||||||
manuellen Notfallweg und Verifikation nach dem Deployment.
|
manuellen Notfallweg und Verifikation nach dem Deployment.
|
||||||
|
|
||||||
|
### Zukunftsfundament & FluxTimer
|
||||||
|
- **Plan technisch aktualisiert:** freie Flyway-Reihenfolge V11–V14, persistiertes
|
||||||
|
`exam_scope`-Tagging und verbindlicher S3-Anti-Cheat-Vertrag für alle neuen Spiele.
|
||||||
|
- **Spiel-Akzentbasis:** Dark-/Light-Tokens für SystemEscape, BugHunter, ServiceClash und NetWorker.
|
||||||
|
- **FluxTimer-MVP:** lokaler Pausentimer mit 5–240 Minuten Intervall, rotierenden Erinnerungen,
|
||||||
|
eigenem Text, Snooze, Probelauf, optionalem Sound und lokaler Browser-Benachrichtigung.
|
||||||
|
Countdown und Overlay laufen über Navigationen weiter; ein eigener Topbar-Button öffnet den
|
||||||
|
Schnellzugriff und die Einstellungen enthalten die vollständige Konfiguration.
|
||||||
|
- **Dashboard-Topbar aufgeräumt:** generische Winkehand und dekorative Pulsline entfernt.
|
||||||
|
FluxTimer besitzt statt der Glocke jetzt ein eigenes Uhr-mit-Blitz-Icon im Zeitreise-Look;
|
||||||
|
die Glocke bleibt frei für spätere echte Benachrichtigungen.
|
||||||
|
- **Login entschlackt:** das generische Schloss-Emoji aus dem Login-Button entfernt; die klare
|
||||||
|
Beschriftung reicht als Handlungsaufforderung und passt besser zur eigenständigen Designsprache.
|
||||||
|
- Datenschutzerklärung um lokale Speicherung und Browser-Benachrichtigung ergänzt.
|
||||||
|
|
||||||
|
### SystemEscape (interner Key `codeescape`)
|
||||||
|
- **Variable SystemEscape-Runs:** Jedes Szenario besitzt nun acht Räume; ein Durchlauf wählt
|
||||||
|
serverseitig vier davon in zufälliger Reihenfolge. Der Pool wächst damit auf 24 Räume und
|
||||||
|
69 Rätsel, mit 1.680 möglichen geordneten Raumkombinationen je Szenario.
|
||||||
|
- Die konkrete Raum- und Rätselauswahl wird im Server-Run festgeschrieben. Checks, Hinweise,
|
||||||
|
Türen, Finish, Maximalpunktzahl und Sterne akzeptieren ausschließlich IDs dieses Runs.
|
||||||
|
- Lobby erklärt Poolgröße und Run-Länge; ein neuer Start erzeugt ausdrücklich eine neue Route.
|
||||||
|
- **Sichtbarer Name zu SystemEscape geändert:** Der neue Name bildet Netzwerk-, Security-,
|
||||||
|
Hardware-, Backup- und Entwicklungsthemen besser ab. Interner Key, Route und Datenmodell
|
||||||
|
bleiben für bestehende Scores und Migrationen kompatibel bei `codeescape`.
|
||||||
|
- **Spielablauf verdeutlicht und Schleusenfehler behoben:** dauerhafte Drei-Schritt-Führung
|
||||||
|
„Rätsel lösen → Fragmente sammeln → Türcode bilden“, nummerierte Fragmentreihenfolge,
|
||||||
|
optionales automatisches Einsetzen und klare Rückmeldungen bei Prüfung, Erfolg und Fehler.
|
||||||
|
- **Produktive Türcodes repariert:** ergänzende V11.1-Datenmigration gleicht alle neun Türcodes
|
||||||
|
mit ihren sichtbaren Fragmenten ab. Die Lösereihenfolge der Rätsel bleibt bewusst frei.
|
||||||
|
- Schleusen lassen sich serverseitig erst nach Abschluss aller Rätsel öffnen; ein zusätzlicher
|
||||||
|
Test sichert diesen Ablauf und die hilfreiche Fehlermeldung ab.
|
||||||
|
- **Neues vollständiges Spiel:** drei Facility-Szenarien mit neun Räumen und 24 IT-Rätseln
|
||||||
|
von AP1 bis AP2 (FIAE/FISI/Cross), Fragment-Inventar, Türcodes, Countdown, Alarmzustand,
|
||||||
|
Hinweise, Reveal, Zeitbonus, Sterne, Highscore und Ergebnis-Debriefing.
|
||||||
|
- **Serverautoritativ:** V11-Schema mit `exam_scope`, lösungsfreie Session-DTOs, serverseitig
|
||||||
|
protokollierte Versuche/Hinweise/Reveals/Türen und vollständige Score-Neuberechnung beim Finish.
|
||||||
|
Fremde oder doppelte IDs, überlange Listen und unplausible Zeiten werden abgewiesen.
|
||||||
|
- **Vier Mechaniken:** freie Code-Eingabe, Choice, Reihenfolge und Matching; alle Lösungen bleiben
|
||||||
|
bis zur expliziten Auflösung auf dem Server.
|
||||||
|
- **Eigene visuelle Welt:** dunkle Facility-/CRT-Optik, Scanlines, Terminal-Dialoge,
|
||||||
|
Fragment-Chips und SystemEscape-Akzent im Dashboard; responsive für mobile Ansichten.
|
||||||
|
- **Admin-CRUD:** Szenarien, Räume und Rätsel inklusive JSON-Payload und Prüfungsbereich pflegbar.
|
||||||
|
- Dashboard, Security-CSRF-Ausnahme, Score-/XP-/Achievement-System und Admin-Dashboard integriert.
|
||||||
|
- Vier neue Service-Tests prüfen Lösungsfreiheit, serverseitige Hint-/Versuchsabrechnung sowie
|
||||||
|
Schutz vor fremden IDs und unplausiblen Laufzeiten.
|
||||||
|
|
||||||
## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten
|
## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten
|
||||||
|
|
||||||
Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und
|
Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und
|
||||||
|
|||||||
+74
-37
@@ -4,18 +4,18 @@
|
|||||||
|
|
||||||
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 17 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier davon sind nur Platzhalter-Karten, die auf `/comingsoon` (`dummy.html`) zeigen und **keinerlei Mechanik, Controller, Tabellen oder Inhalte** besitzen:
|
||||||
|
|
||||||
1. **Code Escape Room** – „Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever."
|
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 `escape-room`, `service-clash`, `bug-hunter`, `netzwerker` stehen schon in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`, Zeile 23–25) und in `ProgressService.GAMES` (Zeile 23–26); die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Ziel dieses Plans: detaillierte, umsetzungsreife Designs für alle vier Spiele nach dem etablierten Architektur-Rezept der vorhandenen Spiele (MindMatch, TechTacular, Syntax Saga, Dev Lab) sowie ein priorisierter Verbesserungs-/Optimierungskatalog für den Rest der Plattform.
|
Die Infrastruktur wartet bereits auf sie: Die Spiel-Keys `codeescape`, `serviceclash`, `bughunter`, `networker` stehen in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`) und in `ProgressService.GAMES`; die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Ziel dieses Plans: detaillierte, umsetzungsreife Designs für alle vier Spiele nach dem etablierten Architektur-Rezept der vorhandenen Spiele (MindMatch, TechTacular, SyntaxSaga, DevLab) sowie ein priorisierter Verbesserungs-/Optimierungskatalog für den Rest der Plattform.
|
||||||
|
|
||||||
**Zielgruppe & inhaltlicher Anspruch: die GESAMTE Fachinformatiker-Ausbildung.** Nicht nur AP1 – abgedeckt werden muss die komplette Ausbildung bis einschließlich **AP2** (beide Fachrichtungen: Anwendungsentwicklung/FIAE und Systemintegration/FISI). Konsequenzen für alle Spiele und Inhalte:
|
**Zielgruppe & inhaltlicher Anspruch: die GESAMTE Fachinformatiker-Ausbildung.** Nicht nur AP1 – abgedeckt werden muss die komplette Ausbildung bis einschließlich **AP2** (beide Fachrichtungen: Anwendungsentwicklung/FIAE und Systemintegration/FISI). Konsequenzen für alle Spiele und Inhalte:
|
||||||
|
|
||||||
- **Schwierigkeitsstufen = Ausbildungsprogression.** Die in allen vier Designs vorhandenen Stufen (Junior/Senior/Principal, leicht/mittel/schwer, Welten 1–3, Schicht 1–3) werden inhaltlich gemappt: untere Stufen ≈ 1. Lehrjahr/AP1-Stoff, obere Stufen ≈ AP2-Niveau. Kein Schema-Umbau nötig – die `difficulty`-Felder existieren in allen Datenmodellen bereits; es ist eine **Content-Kuratierungs-Regel**.
|
- **Schwierigkeitsstufen = Ausbildungsprogression.** Die in allen vier Designs vorhandenen Stufen (Junior/Senior/Principal, leicht/mittel/schwer, Welten 1–3, Schicht 1–3) werden inhaltlich gemappt: untere Stufen ≈ 1. Lehrjahr/AP1-Stoff, obere Stufen ≈ AP2-Niveau. Kein Schema-Umbau nötig – die `difficulty`-Felder existieren in allen Datenmodellen bereits; es ist eine **Content-Kuratierungs-Regel**.
|
||||||
- **AP2-Content pro Spiel (Startrichtung):** CodeEscape → Szenarien zu Verschlüsselung/Zertifikaten, Virtualisierung/Container, Backup-Strategien; BugHunter → Principal-Fälle auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Lücken); ServiceClash → Schichten mit Change-Management, Monitoring/Eskalationsketten, Security-Incidents (FISI-AP2); NetWorker → Welten zu VLAN, statischem Routing über mehrere Hops, Firewall-Regeln und (Ausbau) IPv6/Subnetting-Transfer.
|
- **AP2-Content pro Spiel (Startrichtung):** SystemEscape → Szenarien zu Verschlüsselung/Zertifikaten, Virtualisierung/Container, Backup-Strategien; BugHunter → Principal-Fälle auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Lücken); ServiceClash → Schichten mit Change-Management, Monitoring/Eskalationsketten, Security-Incidents (FISI-AP2); NetWorker → Welten zu VLAN, statischem Routing über mehrere Hops, Firewall-Regeln und (Ausbau) IPv6/Subnetting-Transfer.
|
||||||
- **Content-Tagging:** Beim Kuratieren jedes Inhalts (Seeder-JSONs, Admin-Eingaben) wird vermerkt, ob er AP1-, AP2/FIAE- oder AP2/FISI-relevant ist – zunächst als Konvention im Titel/`description` (z. B. Präfix „[AP2]"), später optional als eigenes Feld + Filter in den Lobbys („Zeige nur AP2-Stoff").
|
- **Content-Tagging:** Neue Parent-Inhalte erhalten von Anfang an ein persistiertes Feld `exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1'` mit der Allowlist `AP1 | AP2_FIAE | AP2_FISI | CROSS`. Seeder und Admin validieren dieses Feld; Katalog-DTOs geben es aus. Titelpräfixe sind nur Anzeige, nie Datenquelle. Lobby-Filter können dadurch später ohne Migration ergänzt werden.
|
||||||
- **FinalFlux erweitern:** Das bestehende Lernmodul ist rein AP1-fokussiert (28 Seiten + FluxChecks). Die Struktur (statische Themenseiten + Frage-JSONs) trägt problemlos einen **AP2-Ast** – als eigener Sidebar-Block „AP2 Anwendungsentwicklung" / „AP2 Systemintegration" (in Teil 2 als Punkt S10 aufgenommen).
|
- **FinalFlux erweitern:** Das bestehende Lernmodul ist rein AP1-fokussiert (28 Seiten + FluxChecks). Die Struktur (statische Themenseiten + Frage-JSONs) trägt problemlos einen **AP2-Ast** – als eigener Sidebar-Block „AP2 Anwendungsentwicklung" / „AP2 Systemintegration" (in Teil 2 als Punkt S10 aufgenommen).
|
||||||
- Die vorhandenen FinalFlux-Inhalte (IP-Berechnung, VLAN, OSI, Wirtschaft) bleiben die AP1-Basis, auf der die Spiele aufsetzen.
|
- Die vorhandenen FinalFlux-Inhalte (IP-Berechnung, VLAN, OSI, Wirtschaft) bleiben die AP1-Basis, auf der die Spiele aufsetzen.
|
||||||
|
|
||||||
@@ -36,18 +36,20 @@ Vorgabe: Alle Spielnamen folgen dem MindMatch-Muster – **ein Wort, englisch, C
|
|||||||
| Dev Lab | **DevLab** | `/devlab` | `devlab` |
|
| Dev Lab | **DevLab** | `/devlab` | `devlab` |
|
||||||
| Syntax Saga | **SyntaxSaga** | `/syntaxsaga` | `syntax-saga` (Key behalten – es existieren bereits Scores) |
|
| Syntax Saga | **SyntaxSaga** | `/syntaxsaga` | `syntax-saga` (Key behalten – es existieren bereits Scores) |
|
||||||
| FinalFlux | **FinalFlux** ✓ | `/finalflux` | – |
|
| FinalFlux | **FinalFlux** ✓ | `/finalflux` | – |
|
||||||
| Code Escape Room | **CodeEscape** | `/codeescape` | `codeescape` |
|
| SystemEscape | **SystemEscape** | `/codeescape` | `codeescape` |
|
||||||
| Service Clash | **ServiceClash** | `/serviceclash` | `serviceclash` |
|
| Service Clash | **ServiceClash** | `/serviceclash` | `serviceclash` |
|
||||||
| Bug Hunter | **BugHunter** | `/bughunter` | `bughunter` |
|
| Bug Hunter | **BugHunter** | `/bughunter` | `bughunter` |
|
||||||
| Netzwerker – Das Router-Rätselspiel | **NetWorker** | `/networker` | `networker` |
|
| Netzwerker – Das Router-Rätselspiel | **NetWorker** | `/networker` | `networker` |
|
||||||
|
|
||||||
Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben (`/mindmatch`, `/syntaxsaga` …) – die neuen Routen folgen dem. Die vier reservierten Keys (`escape-room`, `service-clash`, `bug-hunter`, `netzwerker`) haben **noch keine Scores in der DB** und werden daher jetzt kostenlos auf das einheitliche Schema umbenannt (`codeescape`, `serviceclash`, `bughunter`, `networker`) – zu ändern in `GameScoreController.KNOWN_GAMES`, `ProgressService.GAMES` und den jeweiligen Service-`GAME`-Konstanten. Nur `syntax-saga` behält seinen Key (Bestandsdaten), Anzeigename wird trotzdem `SyntaxSaga`. Anzeigenamen anpassen in: `dashboard.html`-`GAMES`-Array, `ProgressService.GAMES`, Seitentiteln/Templates der neuen Spiele. _Alle nachfolgenden Abschnitte verwenden bereits die neuen Namen; wo im Text noch V9–V12-Design-Details Alt-Keys nennen, gilt die Tabelle hier._
|
Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben (`/mindmatch`, `/syntaxsaga` …) – die neuen Routen folgen dem. Die historischen Plan-Aliasse `escape-room`, `service-clash`, `bug-hunter` und `netzwerker` hatten noch keine Scores und wurden deshalb vor der Implementierung auf `codeescape`, `serviceclash`, `bughunter` und `networker` vereinheitlicht. Nur `syntax-saga` behält seinen Key wegen möglicher Bestandsdaten. Die Tabelle und der Abschnitt „Gemeinsames Architektur-Rezept" sind die verbindliche Quelle; verbliebene Altbegriffe in erklärenden Designpassagen sind keine Routen- oder Schema-Vorgaben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Visuelle Identität: vier Spiele, vier Welten
|
## Visuelle Identität: vier Spiele, vier Welten
|
||||||
|
|
||||||
> ☐ **Noch offen** – wird mit dem Bau der Spiele umgesetzt; auch die Spiel-Akzent-Tokens in `theme.css` sind noch nicht angelegt.
|
> ◐ **Fundament umgesetzt:** Die Dark-/Light-Akzent-Tokens für alle vier Spiele sind in
|
||||||
|
> `theme.css` angelegt. Offen bleiben die konkreten Karten-Designs, Spiel-Layouts, Illustrationen
|
||||||
|
> und Animationen; diese entstehen jeweils zusammen mit dem betreffenden Spiel.
|
||||||
|
|
||||||
Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr deutlich** voneinander unterscheiden – nicht nur mechanisch, sondern auf den ersten Blick. Dazu zwei Maßnahmen:
|
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:
|
||||||
|
|
||||||
@@ -57,12 +59,12 @@ Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr d
|
|||||||
|
|
||||||
| Spiel | Welt-Metapher | Farbstimmung | Prägende visuelle Elemente |
|
| Spiel | Welt-Metapher | Farbstimmung | Prägende visuelle Elemente |
|
||||||
|-------|---------------|--------------|---------------------------|
|
|-------|---------------|--------------|---------------------------|
|
||||||
| **CodeEscape** | Dunkle Facility bei Nacht | Bernstein/Orange auf Tiefschwarz | CRT-Terminals mit Scanline-Overlay, physisches Keypad mit Tasten-Feedback, Raum-„Bühnenbild" als SVG-Illustration (Racks, Türen, flackernde Neonröhre), Fragment-Chips wie Hardware-Token, Alarm-Puls bei Zeitablauf, Tür-Öffnungs-Animation zwischen Räumen |
|
| **SystemEscape** | Dunkle Facility bei Nacht | Bernstein/Orange auf Tiefschwarz | CRT-Terminals mit Scanline-Overlay, physisches Keypad mit Tasten-Feedback, Raum-„Bühnenbild" als SVG-Illustration (Racks, Türen, flackernde Neonröhre), Fragment-Chips wie Hardware-Token, Alarm-Puls bei Zeitablauf, Tür-Öffnungs-Animation zwischen Räumen |
|
||||||
| **BugHunter** | Noir-Detektivbüro | Entsättigt + Crimson-Akzente | Fallakten-Optik (Aktendeckel, Büroklammer, „VERTRAULICH"-Stempel), IDE-Look mit Zeilennummern-Gutter und Tab-Reitern, roter Ermittlungsfaden zum markierten Bug, „GELÖST"-Stempel-Animation im Debriefing, Schreibmaschinen-Typo für Briefings |
|
| **BugHunter** | Noir-Detektivbüro | Entsättigt + Crimson-Akzente | Fallakten-Optik (Aktendeckel, Büroklammer, „VERTRAULICH"-Stempel), IDE-Look mit Zeilennummern-Gutter und Tab-Reitern, roter Ermittlungsfaden zum markierten Bug, „GELÖST"-Stempel-Animation im Debriefing, Schreibmaschinen-Typo für Briefings |
|
||||||
| **ServiceClash** | Hektischer Leitstand/Cockpit | Kühles Cyan + Warnrot | Kanban-artige Ticket-Queue mit einfliegenden Karten, große SLA-Ring-Gauges und Gesundheits-Meter, laufender Event-Ticker unten, Wellen-Countdown-Banner, komisch überzeichnete Ticket-Icons (brennender Drucker 🔥), Screen-Shake bei Major Incidents |
|
| **ServiceClash** | Hektischer Leitstand/Cockpit | Kühles Cyan + Warnrot | Kanban-artige Ticket-Queue mit einfliegenden Karten, große SLA-Ring-Gauges und Gesundheits-Meter, laufender Event-Ticker unten, Wellen-Countdown-Banner, komisch überzeichnete Ticket-Icons (brennender Drucker 🔥), Screen-Shake bei Major Incidents |
|
||||||
| **NetWorker** | Technische Blaupause | Blueprint-Blau auf Millimeterpapier | Raster-Hintergrund wie Konstruktionszeichnung, Geräte als Schematic-Glyphen, Gummiband-Kabelzug, leuchtende Paket-Punkte, die den echten Trace entlangfliegen und am Fehler-Hop rot zerplatzen, Diagnose-Callouts wie Plan-Anmerkungen |
|
| **NetWorker** | Technische Blaupause | Blueprint-Blau auf Millimeterpapier | Raster-Hintergrund wie Konstruktionszeichnung, Geräte als Schematic-Glyphen, Gummiband-Kabelzug, leuchtende Paket-Punkte, die den echten Trace entlangfliegen und am Fehler-Hop rot zerplatzen, Diagnose-Callouts wie Plan-Anmerkungen |
|
||||||
|
|
||||||
Dazu je Spiel ein eigenes Karten-Design auf dem Dashboard (Hover-Animation im jeweiligen Spiel-Akzent), damit die Unterscheidbarkeit schon in der Übersicht beginnt. Die Interaktionsformen sind ohnehin grundverschieden: Modal-Rätsel + Keypad (CodeEscape), klickbarer Code-Viewer (BugHunter), Echtzeit-Queue (ServiceClash), frei bespielbare SVG-Canvas (NetWorker).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ Dazu je Spiel ein eigenes Karten-Design auf dem Dashboard (Hover-Animation im je
|
|||||||
|
|
||||||
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** `db/migration/V9__…` ff. (V8 ist die letzte; `ddl-auto=validate` ⇒ Migration ist Pflicht). SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes.
|
1. **Flyway-Migration** ab `db/migration/V11__…` (V9/V10 sind durch User-/Datenintegrität belegt; `ddl-auto=validate` ⇒ Migration ist Pflicht). Reserviert: V11 SystemEscape (interner Key `codeescape`), V12 BugHunter, V13 NetWorker, V14 ServiceClash; danach FluxNet. SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `exam_scope VARCHAR(20)`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes.
|
||||||
2. **Entities** in `model/` (Parent mit `@OneToMany(cascade=ALL, orphanRemoval=true)`, Lösungs-Logik nur serverseitig auf der Entity).
|
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.
|
||||||
@@ -82,13 +84,37 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
10. **Frontend:** Template in `templates/`, eigenes CSS mit eindeutigem Präfix (nur `theme.css`-Tokens, Erfolg = blaues ✓, Fehler = rotes ✗, **nie grün**), JS als IIFE in `static/js/`, Highscore via `GET /api/games/<key>/stats`, Cache-Busting `?v=N`.
|
10. **Frontend:** Template in `templates/`, eigenes CSS mit eindeutigem Präfix (nur `theme.css`-Tokens, Erfolg = blaues ✓, Fehler = rotes ✗, **nie grün**), JS als IIFE in `static/js/`, Highscore via `GET /api/games/<key>/stats`, Cache-Busting `?v=N`.
|
||||||
11. **Integration:** Route in `SecurityConfig`-CSRF-Ignore-Liste aufnehmen, Dashboard-Karte in `dashboard.html` von `/comingsoon` auf die echte Route umstellen. (`KNOWN_GAMES` + `ProgressService` enthalten die Keys bereits.)
|
11. **Integration:** Route in `SecurityConfig`-CSRF-Ignore-Liste aufnehmen, Dashboard-Karte in `dashboard.html` von `/comingsoon` auf die echte Route umstellen. (`KNOWN_GAMES` + `ProgressService` enthalten die Keys bereits.)
|
||||||
|
|
||||||
|
### Verbindlicher Sicherheitsvertrag für neue Spiele (S3)
|
||||||
|
|
||||||
|
1. Session-/Katalogantworten enthalten nur opaque IDs und lösungsfreie DTOs; Lösung, Reveal-Status und Scoring-Metadaten bleiben serverseitig.
|
||||||
|
2. `finish` akzeptiert nur IDs aus der angeforderten Runde und verwirft fremde, doppelte oder nicht zum Parent gehörende IDs.
|
||||||
|
3. Punkte, Sterne, Highscore, gelöste Anzahl und Boni werden ausschließlich serverseitig aus kanonischen Daten neu berechnet.
|
||||||
|
4. `durationSeconds` muss plausibel sein (`>= 1` und höchstens das konfigurierte Limit plus dokumentierte Toleranz); Listen und Zähler erhalten fachliche Obergrenzen.
|
||||||
|
5. Reveal-/Hint-Nutzung wird serverseitig protokolliert oder beim Finish erneut validiert und kann nicht durch Weglassen im Request verborgen werden.
|
||||||
|
6. Deterministische Seeds werden spätestens bei ServiceClash mit einem serverseitigen HMAC signiert; der Client darf keinen frei wählbaren Seed zur Wertung einreichen.
|
||||||
|
7. Für jedes Spiel entstehen Negativtests: fremde IDs, Duplikate, überlange Listen, unmögliche Zeiten, manipulierte gesperrte Felder und Reveal-vor-Finish.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Teil 1: Spieldesigns
|
## Teil 1: Spieldesigns
|
||||||
|
|
||||||
### Spiel 1: Code Escape Room (Key: `escape-room`, Migration V9)
|
### Plattformweite Barrierefreiheit ✅ Grundwidget umgesetzt
|
||||||
|
|
||||||
**Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** = ein Durchlauf mit 3 Räumen in fester Reihenfolge; jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment** (Ziffern/Hex-Chunk in einer Inventar-Leiste); die Fragmente ergeben zusammen den Türcode zum nächsten Raum. Countdown erzeugt Zeitdruck; ein 2-stufiges Hinweis-System tauscht Punkte gegen Hilfe.
|
- Globales, per Tastatur erreichbares Accessibility-Panel über einen schwebenden Rollstuhl-Button.
|
||||||
|
- Schriftgröße in vier nachvollziehbaren Stufen (100 %, 115 %, 130 %, 150 %), lokal gespeichert
|
||||||
|
und auf Dashboard, Lernseiten sowie allen Spielen identisch angewendet.
|
||||||
|
- Enthalten: erhöhter Kontrast, farbenblindfreundliches Statusfarbprofil, Monochrommodus,
|
||||||
|
Lesemodus für Dyslexie mit Verdana und angepassten Abständen, unterstrichene Links, reduzierte Bewegungen, großer
|
||||||
|
Cursor, gut sichtbare Fokusmarkierungen und zentraler Reset.
|
||||||
|
- Noch offen bleibt die systematische Prüfung aller Spielmechaniken auf vollständige
|
||||||
|
Tastaturbedienbarkeit sowie ein WCAG-Audit mit Kontrastmessung und Screenreader-Tests.
|
||||||
|
- Layouts dürfen bei Textvergrößerung weder Inhalte abschneiden noch horizontales Scrollen der
|
||||||
|
gesamten Seite erzwingen; Codebereiche dürfen weiterhin gezielt horizontal scrollen.
|
||||||
|
- Das Panel ergänzt semantisches HTML und WCAG-Prüfungen, ersetzt diese aber nicht.
|
||||||
|
|
||||||
|
### Spiel 1: SystemEscape (interner Key: `codeescape`, Migration V11 + Datenfix V11.1) ✅ MVP inklusive Spielführung umgesetzt
|
||||||
|
|
||||||
|
**Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** besitzt einen Pool aus 8 Räumen; jeder Run zieht daraus 4 Räume in zufälliger Reihenfolge. Jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment**; die Fragmente ergeben zusammen den Türcode zum nächsten Raum. Countdown erzeugt Zeitdruck; ein 2-stufiges Hinweis-System tauscht Punkte gegen Hilfe.
|
||||||
|
|
||||||
**Lernziele (AP1 → AP2):** Zahlensysteme (Bin/Hex/Dez), Ports & Protokolle, Subnetting, TCP-Handshake/Boot-Reihenfolgen, RAID, Hardware, SQL/Shell-Grundlagen – als Rätsel verpackt statt als Quiz. Schwere Szenarien tragen AP2-Stoff (Verschlüsselung/Zertifikate, Virtualisierung/Container, Backup-Strategien).
|
**Lernziele (AP1 → AP2):** Zahlensysteme (Bin/Hex/Dez), Ports & Protokolle, Subnetting, TCP-Handshake/Boot-Reihenfolgen, RAID, Hardware, SQL/Shell-Grundlagen – als Rätsel verpackt statt als Quiz. Schwere Szenarien tragen AP2-Stoff (Verschlüsselung/Zertifikate, Virtualisierung/Container, Backup-Strategien).
|
||||||
|
|
||||||
@@ -103,9 +129,9 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
- Pro Rätsel: `punkte × versuchsfaktor × hinweisfaktor` (Versuch: 1.0/0.6/0.3, aufgedeckt 0; Hinweis: keiner ×1.0, Stufe 1 ×0.8, Stufe 2 ×0.6). Hinweis-Nutzung wird **serverseitig in der HttpSession** protokolliert (der `/hint`-Endpoint merkt sich die höchste gelieferte Stufe pro Rätsel) – der Client kann sie nicht unterschlagen. _(Einzige bewusste Abweichung vom sonst zustandslosen Muster.)_
|
- Pro Rätsel: `punkte × versuchsfaktor × hinweisfaktor` (Versuch: 1.0/0.6/0.3, aufgedeckt 0; Hinweis: keiner ×1.0, Stufe 1 ×0.8, Stufe 2 ×0.6). Hinweis-Nutzung wird **serverseitig in der HttpSession** protokolliert (der `/hint`-Endpoint merkt sich die höchste gelieferte Stufe pro Rätsel) – der Client kann sie nicht unterschlagen. _(Einzige bewusste Abweichung vom sonst zustandslosen Muster.)_
|
||||||
- Pro geöffneter Tür: pauschal **+50**.
|
- Pro geöffneter Tür: pauschal **+50**.
|
||||||
- Sterne aus `baseScore/maxScore` (≥0.8→3, ≥0.5→2). **Zeitbonus danach**, nur bei Flucht: `max(0, round(500 × (zeitlimit − sekunden) / zeitlimit))` – die MindMatch-Idee, auf 15-Minuten-Läufe skaliert. Timer auf 0 bricht nicht ab: HUD wechselt in „ALARM"-Zustand (pulsierendes `var(--danger)`), der Bonus ist nur weg.
|
- Sterne aus `baseScore/maxScore` (≥0.8→3, ≥0.5→2). **Zeitbonus danach**, nur bei Flucht: `max(0, round(500 × (zeitlimit − sekunden) / zeitlimit))` – die MindMatch-Idee, auf 15-Minuten-Läufe skaliert. Timer auf 0 bricht nicht ab: HUD wechselt in „ALARM"-Zustand (pulsierendes `var(--danger)`), der Bonus ist nur weg.
|
||||||
- `recordScore(user, "escape-room", szenarioSlug, baseScore + zeitbonus, gelösteRätsel, sekunden)`.
|
- `recordScore(user, "codeescape", szenarioSlug, baseScore + zeitbonus, gelösteRätsel, sekunden)`.
|
||||||
|
|
||||||
**Datenmodell (`V9__escape_room.sql`):** 3 Tabellen im V8-Stil:
|
**Datenmodell (`V11__codeescape.sql`):** 3 Tabellen im V8-Stil:
|
||||||
- `escape_scenarios` (id, slug UNIQUE, title, difficulty, description, time_limit_seconds INT DEFAULT 900, sort_order, active)
|
- `escape_scenarios` (id, slug UNIQUE, title, difficulty, description, time_limit_seconds INT DEFAULT 900, sort_order, active)
|
||||||
- `escape_rooms` (id, scenario_id FK CASCADE, title, intro, door_prompt, **door_code VARCHAR(50)**, success_text, sort_order)
|
- `escape_rooms` (id, scenario_id FK CASCADE, title, intro, door_prompt, **door_code VARCHAR(50)**, success_text, sort_order)
|
||||||
- `escape_puzzles` (id, room_id FK CASCADE, puzzle_type VARCHAR(30), prompt, points INT DEFAULT 100, **code_fragment VARCHAR(20)**, sort_order, payload VARCHAR(8000))
|
- `escape_puzzles` (id, room_id FK CASCADE, puzzle_type VARCHAR(30), prompt, points INT DEFAULT 100, **code_fragment VARCHAR(20)**, sort_order, payload VARCHAR(8000))
|
||||||
@@ -116,7 +142,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
- `check(puzzleId, reveal, submission)` – Typ `code`: neuer trivialer Checker via `anyEquals(payload.solutions, answer, caseSensitive)`; **`choice`/`order`/`match`: Checker 1:1 aus `DevLabService` kopieren.** Bei `correct || reveal` kommt `"fragment"` in die Antwort.
|
- `check(puzzleId, reveal, submission)` – Typ `code`: neuer trivialer Checker via `anyEquals(payload.solutions, answer, caseSensitive)`; **`choice`/`order`/`match`: Checker 1:1 aus `DevLabService` kopieren.** Bei `correct || reveal` kommt `"fragment"` in die Antwort.
|
||||||
- `openDoor(roomId, code)`, `hint(session, puzzleId, level)`, `finish(...)` wie oben; plus Admin-Methoden nach Dev-Lab-Vorbild.
|
- `openDoor(roomId, code)`, `hint(session, puzzleId, level)`, `finish(...)` wie oben; plus Admin-Methoden nach Dev-Lab-Vorbild.
|
||||||
|
|
||||||
**API (`/api/escape-room`):** `GET /scenarios`, `GET /session/{slug}`, `POST /check` (`{puzzleId, reveal, submission:{answer}}` → `{correct, fragment, successText}`), `POST /door` (`{roomId, code}` → `{open, successText}`), `POST /hint` (`{puzzleId, level}` → `{text, level}`), `POST /finish` (`{slug, durationSeconds, escaped, items:[{puzzleId, submission, attempts, revealed}], doors:[{roomId, code}]}` → `{score, baseScore, maxScore, timeBonus, solvedPuzzles, roomsOpened, escaped, stars, highscore, newHighscore, plays}`).
|
**API (`/api/codeescape`):** `GET /scenarios`, `GET /session/{slug}`, `POST /check` (`{puzzleId, reveal, submission:{answer}}` → `{correct, fragment, successText}`), `POST /door` (`{roomId, code}` → `{open, successText}`), `POST /hint` (`{puzzleId, level}` → `{text, level}`), `POST /finish` (`{slug, durationSeconds, escaped, items:[{puzzleId, submission, attempts, revealed}], doors:[{roomId, code}]}` → `{score, baseScore, maxScore, timeBonus, solvedPuzzles, roomsOpened, escaped, stars, highscore, newHighscore, plays}`).
|
||||||
|
|
||||||
**Frontend:** `templates/escape-room.html`, `static/js/escaperoom.js` (IIFE-State-Machine `LOBBY → INTRO → ROOM(i) → PUZZLE_MODAL → DOOR_TRY → TRANSITION → … → RESULT`), `static/css/escaperoom.css` mit Präfix `esc-`. **Die `choice`/`order`/`match`-Renderer aus `devlabgame.js` (Vertrag `{render, collect, lock, mark}`) portieren**, nur ein `code`-Renderer (Terminal-Artefakt + Textfeld) kommt neu dazu. Layout: HUD oben (Raumtitel, Raum-Punkte ● ● ○, Timer-Chip), Mitte Rätselkarten, unten Fragment-Chips + Keypad-Button. Nur Theme-Tokens, Erfolg blau ✓, Fehler rot ✗.
|
**Frontend:** `templates/escape-room.html`, `static/js/escaperoom.js` (IIFE-State-Machine `LOBBY → INTRO → ROOM(i) → PUZZLE_MODAL → DOOR_TRY → TRANSITION → … → RESULT`), `static/css/escaperoom.css` mit Präfix `esc-`. **Die `choice`/`order`/`match`-Renderer aus `devlabgame.js` (Vertrag `{render, collect, lock, mark}`) portieren**, nur ein `code`-Renderer (Terminal-Artefakt + Textfeld) kommt neu dazu. Layout: HUD oben (Raumtitel, Raum-Punkte ● ● ○, Timer-Chip), Mitte Rätselkarten, unten Fragment-Chips + Keypad-Button. Nur Theme-Tokens, Erfolg blau ✓, Fehler rot ✗.
|
||||||
|
|
||||||
@@ -128,7 +154,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Spiel 2: Bug Hunter (Key: `bug-hunter`, Migration V10)
|
### Spiel 2: BugHunter (Key: `bughunter`, Migration V12)
|
||||||
|
|
||||||
**Konzept:** Der Spieler ist Debugging-Detektiv und arbeitet **Fallakten** ab. Ein Fall („Hunt") ist ein Mini-Projekt aus 2–4 Dateien, das sich falsch verhält; Beweismittel sind simulierte Konsolen-Ausgabe / Stacktrace / Ticket-Text. Pro Bug eine Drei-Phasen-Schleife: **ORTEN** (fehlerhafte Zeile im Tab-Code-Viewer anklicken) → **KLASSIFIZIEREN** (Syntax / Logik / Laufzeit / Sicherheit) → **FIXEN** (korrigierte Zeile aus 4 Kandidaten wählen). Saubere Bugs bauen eine **Combo-Serie** auf. Stufen: Junior / Senior / Principal.
|
**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.
|
||||||
|
|
||||||
@@ -138,9 +164,9 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
- Phasengewichte pro Bug: Orten 40 %, Klassifizieren 20 %, Fixen 40 %. Phasen-Award = `gewicht × punkte × versuchsfaktor` (1.0/0.6/0.3, aufgedeckt 0).
|
- Phasengewichte pro Bug: Orten 40 %, Klassifizieren 20 %, Fixen 40 %. Phasen-Award = `gewicht × punkte × versuchsfaktor` (1.0/0.6/0.3, aufgedeckt 0).
|
||||||
- Sterne aus `baseScore/maxScore` (Genauigkeitsmaß, ohne Combo).
|
- Sterne aus `baseScore/maxScore` (Genauigkeitsmaß, ohne Combo).
|
||||||
- **Combo:** Ein Bug ist _sauber_, wenn alle 3 Phasen im 1. Versuch ohne Aufdecken gelingen. Serienposition n bringt `(min(n,4) − 1) × 10 % × punkte` Bonus (2. saubere in Folge +10 %, … 4.+ +30 %); Bonus wird **nach** der Sternberechnung addiert. Kein Timer – Bug Hunter belohnt Präzision, Zeitdruck gehört dem Escape Room.
|
- **Combo:** Ein Bug ist _sauber_, wenn alle 3 Phasen im 1. Versuch ohne Aufdecken gelingen. Serienposition n bringt `(min(n,4) − 1) × 10 % × punkte` Bonus (2. saubere in Folge +10 %, … 4.+ +30 %); Bonus wird **nach** der Sternberechnung addiert. Kein Timer – Bug Hunter belohnt Präzision, Zeitdruck gehört dem Escape Room.
|
||||||
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bug-hunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
|
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bughunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
|
||||||
|
|
||||||
**Datenmodell (`V10__bug_hunter.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', 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"}`
|
||||||
@@ -151,7 +177,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
- `checkPhase(bugId, phase, reveal, submission)`: `locate` vergleicht file+line, liefert bei Erfolg/Aufdecken `location` zurück; `classify` vergleicht die Kategorie und liefert bei Auflösung erst die **gemischten `fixOptions`** (Phasen-Gate: der Fix-Text würde sonst die Position verraten); `fix` prüft die Wahl (Logik = Dev Labs `checkChoice`) und liefert `fixedLine` + `explanation` fürs Debriefing.
|
- `checkPhase(bugId, phase, reveal, submission)`: `locate` vergleicht file+line, liefert bei Erfolg/Aufdecken `location` zurück; `classify` vergleicht die Kategorie und liefert bei Auflösung erst die **gemischten `fixOptions`** (Phasen-Gate: der Fix-Text würde sonst die Position verraten); `fix` prüft die Wahl (Logik = Dev Labs `checkChoice`) und liefert `fixedLine` + `explanation` fürs Debriefing.
|
||||||
- `finish(...)`: prüft alle Phasen aus den Roh-Antworten neu, Gewichte + Versuchsfaktoren, Streak/Combo, Sterne, `recordScore`.
|
- `finish(...)`: prüft alle Phasen aus den Roh-Antworten neu, Gewichte + Versuchsfaktoren, Streak/Combo, Sterne, `recordScore`.
|
||||||
|
|
||||||
**API (`/api/bug-hunter`):** `GET /hunts`, `GET /session/{slug}`, `POST /check` (`{bugId, phase, reveal, submission}`), `POST /finish` (`{slug, durationSeconds, items:[{bugId, locate:{file,line,attempts,revealed}, classify:{category,…}, fix:{choice,…}}]}` → `{score, baseScore, maxScore, comboBonus, maxCombo, bugsSolved, totalBugs, stars, highscore, newHighscore, plays}`).
|
**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/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).
|
||||||
|
|
||||||
@@ -163,7 +189,7 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Spiel 3: Service Clash (Key: `service-clash`, Migration V11)
|
### Spiel 3: ServiceClash (Key: `serviceclash`, Migration V14)
|
||||||
|
|
||||||
**Konzept: „Deine Schicht am Service Desk."** Der Spieler ist Junior-Admin im First-Level-Support. Tickets treffen auf einer Zeitleiste ein; jedes muss **eingeordnet** (Incident vs. Service Request), **priorisiert** (Hoch/Mittel/Niedrig anhand von Impact-×-Dringlichkeit-Hinweisen im Tickettext) und **gelöst** werden (richtige Erstmaßnahme aus 3–4 Optionen) – bevor sein SLA-Timer abläuft. Unbehandelte Tickets beschädigen die „Systemgesundheit"; bei 0 endet die Schicht vorzeitig.
|
**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.
|
||||||
|
|
||||||
@@ -188,14 +214,14 @@ 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 (`V11__service_clash.sql`):**
|
**Datenmodell (`V14__serviceclash.sql`):**
|
||||||
- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active)
|
- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active)
|
||||||
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index
|
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index
|
||||||
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt).
|
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt).
|
||||||
|
|
||||||
**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild.
|
**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild.
|
||||||
|
|
||||||
**API (`/api/service-clash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`.
|
**API (`/api/serviceclash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`.
|
||||||
|
|
||||||
**Frontend:** `templates/service-clash.html`, `static/js/serviceclash.js` (State-Machine `SHIFT_SELECT → BRIEFING → RUNNING ⇄ WAVE_BREAK → DEBRIEF`; **ein** `setInterval(250 ms)`-Tick: fällige Tickets spawnen, SLA-Ringe aktualisieren), `static/css/service-clash.css` Präfix `sc-`. Layout: Topbar (Schicht-Uhr, Wellen-Pips, Gesundheits-Meter, „Gelöst 4/8"), links die Ticket-Queue (Karten mit SLA-Ring, pulsierender roter Rand bei Überfälligkeit), Mitte das aktive Ticket mit 3-Stufen-Flow, unten ein Event-Log („14:02 Ticket ‚ERP down' eingegangen"). SLA-Ringe: `var(--accent)`, unter 25 % `var(--danger)`.
|
**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)`.
|
||||||
|
|
||||||
@@ -209,7 +235,7 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Spiel 4: Netzwerker – Das Router-Rätselspiel (Key: `netzwerker`, Migration V12)
|
### Spiel 4: NetWorker (Key: `networker`, Migration V13)
|
||||||
|
|
||||||
**Konzept:** Netzwerk-Puzzle in der Tradition von Schaltkreis-Spielen. Jedes Level zeigt eine **SVG-Topologie** (PCs, Switch, Router, Server, Cloud). Geräte haben fehlende oder kaputte Konfiguration (IP, Maske, Gateway, Kabel, statische Routen). Ziel: alle **Ping-Ziele** erfüllen („PC1 muss SRV1 erreichen", später auch negativ: „Gast-PC darf den Server NICHT erreichen"). „Pings testen" ruft einen **serverseitigen Netzwerk-Simulator**, der jeden Ping Hop-für-Hop auflöst und Pass/Fail plus didaktische Diagnose liefert („Das Paket verlässt PC1 nicht: Laut Maske liegt das Ziel im eigenen Netz – stimmt die Maske?").
|
**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?").
|
||||||
|
|
||||||
@@ -219,7 +245,7 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
**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, "netzwerker", weltSlug, score, gelöste, sekunden)`.
|
||||||
|
|
||||||
**Datenmodell (`V12__netzwerker.sql`):**
|
**Datenmodell (`V13__networker.sql`):**
|
||||||
- `netzwerker_worlds` (id, slug UNIQUE, title, description, difficulty INT, sort_order, active)
|
- `netzwerker_worlds` (id, slug UNIQUE, title, description, difficulty INT, sort_order, active)
|
||||||
- `netzwerker_levels` (id, world_id FK CASCADE, title, level_type VARCHAR(30) /* assign|cable|fix|route|vlan */, points INT DEFAULT 100, sort_order, payload VARCHAR(8000) /* JSON-Topologie inkl. Musterlösung */) + FK-Index
|
- `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
|
||||||
|
|
||||||
@@ -230,7 +256,7 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
**`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.**
|
**`NetzwerkerService`:** `buildCatalog()`, `buildSession(slug)` (Level per `sanitizeLevel()`: Werte editierbarer Felder, zu ziehende Links, Routen-Lösungszeilen und `solution`-Block entfernt; gesperrte Werte, Positionen, Ziele, Hints gehen mit), `check(levelId, reveal, config)` (`mergeSubmission()` legt eingereichte Werte **nur über editierbare Felder** – Manipulation gesperrter Felder wird ignoriert; alle Ziele durch `NetSimulator.ping`; Antwort mit Trace/Diagnose pro Ziel), `finish(...)`, Admin-CRUD **plus Qualitäts-Gate beim Speichern: die hinterlegte Musterlösung wird durch den Simulator geschickt – Speichern wird abgelehnt, wenn sie nicht alle Ziele erfüllt.**
|
||||||
|
|
||||||
**API (`/api/netzwerker`):** `GET /catalog`, `GET /session/{slug}`, `POST /check` (`{levelId, reveal, config:{ifaces[], links[], routes[], vlans[]}}` → `{allPassed, validation[], goals:[{id, passed, trace[] | failAt, reason, message}]}`; Masken-Eingabe akzeptiert `24`, `/24` oder `255.255.255.0` – Server normalisiert), `POST /finish` → Dev-Lab-Result-Form.
|
**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/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.
|
||||||
|
|
||||||
@@ -246,11 +272,16 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
|
|||||||
|
|
||||||
### Empfohlene Baureihenfolge & Gesamtaufwand
|
### Empfohlene Baureihenfolge & Gesamtaufwand
|
||||||
|
|
||||||
0. **Vorarbeiten:** Namensschema umsetzen (Dashboard-Namen, Keys, `devlab` in `ProgressService` = W1) · K2 (Legacy-Endpunkt löschen) und W8 (N+1-Muster) beheben, damit die neuen Services das saubere Muster übernehmen · Spiel-Akzent-Tokens in `theme.css` anlegen. ≈ 1 PT — **✅ erledigt bis auf die Spiel-Akzent-Tokens.**
|
0. **Vorarbeiten ✅:** Namensschema, Dashboard-Namen und Keys, `devlab` in `ProgressService`, K2,
|
||||||
1. **CodeEscape (V9)** – validiert die Wiederverwendung der DevLab-Checker/-Renderer mit minimalem Risiko. ≈ 5,5 PT
|
W8, V11–V14-Migrationsreservierung, `exam_scope`-Konvention, S3-Sicherheitsvertrag und
|
||||||
2. **BugHunter (V10)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 1. ≈ 4,5 PT
|
Spiel-Akzent-Tokens in `theme.css` sind umgesetzt.
|
||||||
3. **NetWorker (V12)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
|
1. **FluxTimer ✅** – risikoarmer, rein lokaler Ausbau; validiert den globalen UI-Bootstrap und belegt die Glocke vor FluxNet. ≈ 1,5–2 PT
|
||||||
4. **ServiceClash (V11)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT
|
2. **SystemEscape (V11) ✅** – serverautoritatives Spiel mit 3 Szenarien, 24 Räumen, 69 Rätseln
|
||||||
|
und vier zufälligen Räumen pro Run; Admin-CRUD, `exam_scope`, festgeschriebene Run-Auswahl,
|
||||||
|
dynamische Wertung und Anti-Cheat-Negativtests umgesetzt.
|
||||||
|
3. **BugHunter (V12)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 2. ≈ 4,5 PT
|
||||||
|
4. **NetWorker (V13)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
|
||||||
|
5. **ServiceClash (V14)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT
|
||||||
|
|
||||||
**Gesamt-MVP aller vier Spiele: ≈ 28 Personentage.** Der Navigations-Umbau (Abschnitt 2.3) und die restlichen Teil-2-Punkte können parallel oder dazwischen erfolgen; empfohlen ist der Sidebar-/Tab-Umbau **vor** dem vierten Spiel, damit alle neuen Seiten die Fragmente nutzen.
|
**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.
|
||||||
|
|
||||||
@@ -307,7 +338,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 ☐ | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Akzeptables Risiko (nur eigener Highscore), aber bei den neuen Spielen von Anfang an minimieren: Server generiert Runden-Daten mit IDs, `finish` validiert nur bekannte IDs, Zeit-Plausibilitätscheck (z. B. `durationSeconds ≥ 1`, Obergrenzen für `pairs`/`moves`). |
|
| S3 ✅ Fundament | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Verbindlicher Sicherheitsvertrag ist oben dokumentiert und gilt für jedes neue Spiel; konkrete Implementierung und Negativtests entstehen jeweils mit dem Spiel. |
|
||||||
| S4 ✅ | **Infrastruktur**: kein Actuator/Healthcheck, Dockerfile läuft als root, kein Dependency-Layer-Caching, CI ohne Tests, Java-Versionskonflikt (pom: 17, Dockerfile: 21) | `spring-boot-starter-actuator` (+ `HEALTHCHECK` in compose), `USER`-Directive, Dockerfile zweistufig mit `dependency:go-offline`-Layer, Test-Stage in `.gitea/workflows/mindflux.yml`, Java-Version angleichen (empfohlen: beide auf 21). |
|
| 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). |
|
||||||
@@ -433,11 +464,17 @@ Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = re
|
|||||||
|
|
||||||
## Teil 4: FluxTimer – Der Pausen-Lerntimer
|
## Teil 4: FluxTimer – Der Pausen-Lerntimer
|
||||||
|
|
||||||
|
> ✅ **MVP umgesetzt:** lokaler Zustand, globaler Countdown, Einstellungen, eigener Uhr-mit-Blitz-
|
||||||
|
> Schnellzugriff in der Topbar, In-App-Overlay, Snooze, Probelauf sowie optionale Browser-
|
||||||
|
> Benachrichtigung und Web-Audio-Signal. Die Glocke bleibt für FluxNet-/Systembenachrichtigungen
|
||||||
|
> reserviert. Eine serverseitige Synchronisation bleibt bewusst optional.
|
||||||
|
|
||||||
> Opt-in-Erinnerungstimer im Look der Zeitschaltkreise des DeLorean: Nach frei wählbarer Zeit erinnert Mindflux ans Aufstehen, Trinken, Essen – oder was immer man einträgt. Standardmäßig **deaktiviert**, jederzeit abschaltbar, komplett frei einstellbar.
|
> Opt-in-Erinnerungstimer im Look der Zeitschaltkreise des DeLorean: Nach frei wählbarer Zeit erinnert Mindflux ans Aufstehen, Trinken, Essen – oder was immer man einträgt. Standardmäßig **deaktiviert**, jederzeit abschaltbar, komplett frei einstellbar.
|
||||||
|
|
||||||
### 4.1 Funktionsumfang
|
### 4.1 Funktionsumfang
|
||||||
|
|
||||||
- **Aktivieren/Deaktivieren** per Toggle (Einstellungen + Schnellzugriff über das Glocken-Icon der Dashboard-Topbar).
|
- **Aktivieren/Deaktivieren ✅** per Toggle in den Einstellungen und über das eigene
|
||||||
|
Uhr-mit-Blitz-Icon der Dashboard-Topbar.
|
||||||
- **Intervall frei wählbar:** freie Minuteneingabe, sinnvolle Grenzen 5–240 Minuten (Default-Vorschlag 45).
|
- **Intervall frei wählbar:** freie Minuteneingabe, sinnvolle Grenzen 5–240 Minuten (Default-Vorschlag 45).
|
||||||
- **Erinnerungsarten wählbar** (Mehrfachauswahl, Rotation): Aufstehen 🧍 · Trinken 💧 · Essen 🍎 · Augenpause 👀 · **eigener Text** (Freitextfeld).
|
- **Erinnerungsarten wählbar** (Mehrfachauswahl, Rotation): Aufstehen 🧍 · Trinken 💧 · Essen 🍎 · Augenpause 👀 · **eigener Text** (Freitextfeld).
|
||||||
- **Snooze** (+5 Minuten) und „Pause erledigt“ (startet das Intervall neu).
|
- **Snooze** (+5 Minuten) und „Pause erledigt“ (startet das Intervall neu).
|
||||||
@@ -465,10 +502,10 @@ Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = re
|
|||||||
|
|
||||||
| Ort | Änderung |
|
| Ort | Änderung |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| `settings.html` | Neue Sektion „FluxTimer“: Toggle, Minuten-Eingabe, Erinnerungs-Checkboxen, Freitext, Benachrichtigung/Sound-Optionen, Probelauf-Button |
|
| `settings.html` ✅ | Sektion „FluxTimer“: Toggle, Minuten-Eingabe, Erinnerungs-Checkboxen, Freitext, Benachrichtigung/Sound-Optionen, Probelauf-Button |
|
||||||
| `dashboard.html` (Topbar) | Das bislang funktionslose Glocken-Icon öffnet ein FluxTimer-Popover: Status, Countdown, Schnell-Toggle, Link zu den Einstellungen |
|
| `dashboard.html` (Topbar) ✅ | Eigenes Uhr-mit-Blitz-Icon öffnet das Popover mit Status, Countdown, Schnell-Toggle und Link zu den Einstellungen; Glocke bleibt frei |
|
||||||
| `templates/fragments/layout.html` | Script-Einbindung, damit der Timer auf allen eingeloggten Seiten weiterläuft |
|
| `theme.js` ✅ | Zentraler, idempotenter Asset-Bootstrap, damit der Timer über Seitennavigationen weiterläuft |
|
||||||
| NEU `static/js/fluxtimer.js`, `static/css/fluxtimer.css` | Timer-Logik + Zeitschaltkreis-Optik |
|
| `static/js/fluxtimer.js`, `static/css/fluxtimer.css` ✅ | Timer-Logik + Zeitschaltkreis-Optik |
|
||||||
|
|
||||||
### 4.5 Aufwand
|
### 4.5 Aufwand
|
||||||
|
|
||||||
@@ -484,7 +521,7 @@ Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = re
|
|||||||
| `src/main/resources/static/js/devlabgame.js` | Renderer-Vertrag `{render, collect, lock, mark}`, Versuchsleiter-Frontend-Muster |
|
| `src/main/resources/static/js/devlabgame.js` | Renderer-Vertrag `{render, collect, lock, mark}`, Versuchsleiter-Frontend-Muster |
|
||||||
| `src/main/resources/db/migration/V8__devlab.sql` | SQL-Stilvorlage für V9–V12 |
|
| `src/main/resources/db/migration/V8__devlab.sql` | SQL-Stilvorlage für V9–V12 |
|
||||||
| `src/main/java/com/example/mindflux/config/SecurityConfig.java` (Zeile 43) | CSRF-Ignore-Liste: `/api/codeescape/**`, `/api/bughunter/**`, `/api/serviceclash/**`, `/api/networker/**` ergänzen |
|
| `src/main/java/com/example/mindflux/config/SecurityConfig.java` (Zeile 43) | CSRF-Ignore-Liste: `/api/codeescape/**`, `/api/bughunter/**`, `/api/serviceclash/**`, `/api/networker/**` ergänzen |
|
||||||
| `src/main/resources/templates/dashboard.html` (Zeilen 186–196) | `GAMES`-Array: Namen aufs neue Schema umstellen (DevLab, SyntaxSaga, CodeEscape, ServiceClash, BugHunter, NetWorker) und die vier `'/comingsoon'`-Hrefs gegen echte Routen tauschen (Icons `lock`/`sword`/`bug`/`network` existieren bereits) |
|
| `src/main/resources/templates/dashboard.html` (Zeilen 186–196) | `GAMES`-Array: Namen aufs neue Schema umstellen (DevLab, SyntaxSaga, SystemEscape, ServiceClash, BugHunter, NetWorker) und die verbliebenen `'/comingsoon'`-Hrefs gegen echte Routen tauschen (Icons `lock`/`sword`/`bug`/`network` existieren bereits) |
|
||||||
| `GameScoreController.KNOWN_GAMES` + `ProgressService.GAMES` | Vier reservierte Keys aufs neue Schema umbenennen (`codeescape`, `serviceclash`, `bughunter`, `networker`), `devlab` in `ProgressService` ergänzen (W1) |
|
| `GameScoreController.KNOWN_GAMES` + `ProgressService.GAMES` | Vier reservierte Keys aufs neue Schema umbenennen (`codeescape`, `serviceclash`, `bughunter`, `networker`), `devlab` in `ProgressService` ergänzen (W1) |
|
||||||
| `src/main/java/com/example/mindflux/service/GameScoreService.java` | `recordScore(...)`-Anschlusspunkt aller Spiele |
|
| `src/main/java/com/example/mindflux/service/GameScoreService.java` | `recordScore(...)`-Anschlusspunkt aller Spiele |
|
||||||
| `STYLEGUIDE.md`, `CHANGELOG.md` | Design-Regeln (blau ✓ / rot ✗, nie grün; Theme-Tokens; `?v=N`) und Pflege der Historie |
|
| `STYLEGUIDE.md`, `CHANGELOG.md` | Design-Regeln (blau ✓ / rot ✗, nie grün; Theme-Tokens; `?v=N`) und Pflege der Historie |
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.config; import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.*;import org.slf4j.*;import org.springframework.boot.CommandLineRunner;import org.springframework.core.io.ClassPathResource;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional;
|
||||||
|
@Component public class CodeEscapeSeeder implements CommandLineRunner {private static final Logger log=LoggerFactory.getLogger(CodeEscapeSeeder.class);private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public CodeEscapeSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;}@Override @Transactional public void run(String...a){if(repo.count()>0)return;try{JsonNode root=mapper.readTree(new ClassPathResource("codeescape/content.json").getInputStream());int si=0,pc=0;for(JsonNode sn:root.path("scenarios")){EscapeScenario s=new EscapeScenario(sn.path("slug").asText(),sn.path("title").asText(),sn.path("difficulty").asText(),sn.path("examScope").asText("AP1"),sn.path("description").asText(),sn.path("timeLimitSeconds").asInt(900),si++);int ri=0;for(JsonNode rn:sn.path("rooms")){EscapeRoom r=new EscapeRoom(rn.path("title").asText(),rn.path("intro").asText(),rn.path("doorPrompt").asText(),rn.path("doorCode").asText(),rn.path("successText").asText(),ri++);int pi=0;for(JsonNode pn:rn.path("puzzles")){r.addPuzzle(new EscapePuzzle(pn.path("type").asText(),pn.path("prompt").asText(),pn.path("points").asInt(100),pn.path("fragment").asText(),pi++,mapper.writeValueAsString(pn.path("payload"))));pc++;}s.addRoom(r);}repo.save(s);}log.info("CodeEscape: {} Szenarien / {} Rätsel geladen.",si,pc);}catch(Exception e){log.error("CodeEscape-Seed fehlgeschlagen",e);}}}
|
||||||
@@ -42,7 +42,7 @@ public class SecurityConfig {
|
|||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
||||||
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
|
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
|
||||||
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**")
|
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**")
|
||||||
);
|
);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.mindflux.config;
|
||||||
|
import com.example.mindflux.model.*;import com.example.mindflux.repository.EscapeScenarioRepository;import com.fasterxml.jackson.databind.ObjectMapper;import com.fasterxml.jackson.databind.node.ObjectNode;import org.springframework.boot.context.event.ApplicationReadyEvent;import org.springframework.context.event.EventListener;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Transactional;import java.util.*;
|
||||||
|
@Component public class SystemEscapePoolSeeder{
|
||||||
|
private final EscapeScenarioRepository repo;private final ObjectMapper mapper;public SystemEscapePoolSeeder(EscapeScenarioRepository r,ObjectMapper m){repo=r;mapper=m;}
|
||||||
|
@EventListener(ApplicationReadyEvent.class) @Transactional public void expand()throws Exception{
|
||||||
|
add("verlassener-serverraum","DNS-Kammer","Ein Resolver hält die nächste Schleuse gesperrt.",q("DNS-Record für IPv4-Adresse?","A","A"),q("DNS-Record für Mailserver?","MX","MX"),q("DNS-Standardport?","53","53"));
|
||||||
|
add("verlassener-serverraum","VLAN-Brücke","Getrennte Segmente müssen korrekt verbunden werden.",q("VLAN-Tagging-Standard IEEE 802.?","1Q","1Q"),q("Gerät zwischen IP-Netzen?","Router","RTR"),q("VLAN ohne Tag am Trunk?","Native VLAN","NAT"));
|
||||||
|
add("verlassener-serverraum","Routing-Schacht","Das Gateway erwartet drei Routing-Grundlagen.",q("Route für alle unbekannten Ziele?","Default Route","DEF"),q("IPv4-Loopback-Adresse?","127.0.0.1","127"),q("Befehl zum Weg eines Pakets?","traceroute","TRC"));
|
||||||
|
add("verlassener-serverraum","Firewall-Gang","Nur korrekt gefilterter Verkehr öffnet den Gang.",q("Prinzip: alles verbieten, nur Nötiges erlauben?","Default Deny","DENY"),q("Firewall mit Verbindungszustand?","Stateful","STATE"),q("HTTPS-Protokoll über TCP-Port?","443","443"));
|
||||||
|
add("verlassener-serverraum","Monitoring-Zentrale","Metriken und Ereignisse liefern die Fragmente.",q("Protokoll für Geräteüberwachung?","SNMP","SNMP"),q("Zentrales Systemprotokoll?","Syslog","LOG"),q("ICMP-Werkzeug für Erreichbarkeit?","ping","PING"));
|
||||||
|
add("notfall-im-rechenzentrum","Identity-Vault","Der Identitätsdienst verlangt sichere Grundlagen.",q("Zweiter Faktor neben Passwort?","MFA","MFA"),q("Zentraler Verzeichnisdienst von Microsoft?","Active Directory","AD"),q("Minimal notwendige Rechte?","Least Privilege","LP"));
|
||||||
|
add("notfall-im-rechenzentrum","Container-Dock","Isolierte Workloads blockieren den Ausgang.",q("Container teilen den Kernel des?","Hosts","HOST"),q("Deklarative Container-Zusammenstellung?","Compose","COMP"),q("Persistenter Containerspeicher?","Volume","VOL"));
|
||||||
|
add("notfall-im-rechenzentrum","Patch-Leitstand","Ein ungepatchtes System hält die Tür verriegelt.",q("Bewertung bekannter Schwachstellen?","CVSS","CVSS"),q("Kennung einer veröffentlichten Schwachstelle?","CVE","CVE"),q("Testumgebung vor Produktion?","Staging","STG"));
|
||||||
|
add("notfall-im-rechenzentrum","Cluster-Korridor","Hochverfügbarkeit muss wiederhergestellt werden.",q("Automatischer Rollenwechsel bei Ausfall?","Failover","FAIL"),q("Verteilung von Anfragen?","Load Balancing","LB"),q("Prüfung der Dienstverfügbarkeit?","Health Check","HC"));
|
||||||
|
add("notfall-im-rechenzentrum","Storage-Array","Das Speichernetz fordert drei Begriffe.",q("Blockzugriff über IP?","iSCSI","ISCSI"),q("Dateifreigabe für Linux/Unix?","NFS","NFS"),q("Redundanz ist kein Ersatz für ein?","Backup","BAK"));
|
||||||
|
add("blackout-protokoll","PKI-Tresor","Die Zertifikatskette muss rekonstruiert werden.",q("Widerrufsliste für Zertifikate?","CRL","CRL"),q("Online-Prüfung eines Zertifikatsstatus?","OCSP","OCSP"),q("Oberste Vertrauensinstanz?","Root CA","ROOT"));
|
||||||
|
add("blackout-protokoll","API-Gateway","Die Schnittstelle akzeptiert nur sichere Antworten.",q("HTTP-Methode zum Lesen?","GET","GET"),q("Statuscode für nicht autorisiert?","401","401"),q("Datenformat mit Objekten und Arrays?","JSON","JSON"));
|
||||||
|
add("blackout-protokoll","Git-Archiv","Die Versionshistorie enthält den nächsten Schlüssel.",q("Lokale Änderungen vorm Commit vormerken?","git add","ADD"),q("Neuen Entwicklungszweig anlegen?","git branch","BR"),q("Historie eines Repositories anzeigen?","git log","LOG"));
|
||||||
|
add("blackout-protokoll","Datenbank-Kern","Transaktionen und Indizes sichern den Weg.",q("SQL-Befehl zum Lesen?","SELECT","SEL"),q("Index mit eindeutigen Werten?","UNIQUE","UNQ"),q("Transaktion zurücknehmen?","ROLLBACK","RB"));
|
||||||
|
add("blackout-protokoll","Zero-Trust-Schleuse","Jeder Zugriff muss erneut bewiesen werden.",q("Zero-Trust-Grundsatz: never trust, always?","verify","VERIFY"),q("Netze in kleine Zonen teilen?","Microsegmentation","MICRO"),q("Zugriffsentscheidung anhand von Rollen?","RBAC","RBAC"));
|
||||||
|
}
|
||||||
|
private Q q(String p,String a,String f){return new Q(p,a,f);}
|
||||||
|
private void add(String slug,String title,String intro,Q...qs)throws Exception{EscapeScenario s=repo.findBySlug(slug).orElse(null);if(s==null||s.getRooms().stream().anyMatch(r->r.getTitle().equals(title)))return;String code=Arrays.stream(qs).map(Q::fragment).reduce("",String::concat);EscapeRoom r=new EscapeRoom(title,intro,"Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.",code,"Die Schleuse entriegelt und gibt den nächsten Abschnitt frei.",s.getRooms().size());int i=0;for(Q q:qs){ObjectNode p=mapper.createObjectNode();p.put("instruction",q.prompt());p.putArray("solutions").add(q.answer());p.putArray("hints").add("Ordne den gesuchten Begriff dem IT-Kontext zu.").add("Der gesuchte Wert lautet: "+q.answer()+".");p.put("explanation",q.answer()+" ist die korrekte Lösung.");r.addPuzzle(new EscapePuzzle("code",q.prompt(),120,q.fragment(),i++,mapper.writeValueAsString(p)));}s.addRoom(r);repo.save(s);}
|
||||||
|
private record Q(String prompt,String answer,String fragment){}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.controller; import com.example.mindflux.service.CodeEscapeService;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.*;
|
||||||
|
@Controller @RequestMapping("/admin/codeescape") public class AdminCodeEscapeController {private final CodeEscapeService service;public AdminCodeEscapeController(CodeEscapeService s){service=s;}@GetMapping public String list(Model m){m.addAttribute("scenarios",service.all());return"admin/codeescape";}@PostMapping("/add")public String add(@RequestParam String title,@RequestParam String difficulty,@RequestParam String examScope,@RequestParam(required=false)String description,@RequestParam(defaultValue="900")int timeLimitSeconds){Long id=service.addScenario(title,difficulty,examScope,description,timeLimitSeconds);return"redirect:/admin/codeescape/"+id;}@GetMapping("/{id}")public String scenario(@PathVariable Long id,Model m){m.addAttribute("scenario",service.scenario(id));return"admin/codeescape-scenario";}@PostMapping("/{id}/rooms")public String room(@PathVariable Long id,@RequestParam String title,@RequestParam String intro,@RequestParam String doorPrompt,@RequestParam String doorCode,@RequestParam String successText){service.addRoom(id,title,intro,doorPrompt,doorCode,successText);return"redirect:/admin/codeescape/"+id;}@GetMapping("/rooms/{id}")public String roomPage(@PathVariable Long id,Model m){m.addAttribute("room",service.room(id));return"admin/codeescape-room";}@PostMapping("/rooms/{id}/puzzles")public String puzzle(@PathVariable Long id,@RequestParam String type,@RequestParam String prompt,@RequestParam int points,@RequestParam String codeFragment,@RequestParam String payload){service.addPuzzle(id,type,prompt,points,codeFragment,payload);return"redirect:/admin/codeescape/rooms/"+id;}@PostMapping("/{id}/toggle")public String toggle(@PathVariable Long id){service.toggle(id);return"redirect:/admin/codeescape";}@PostMapping("/{id}/delete")public String del(@PathVariable Long id){service.deleteScenario(id);return"redirect:/admin/codeescape";}@PostMapping("/rooms/{id}/delete")public String delRoom(@PathVariable Long id){var r=service.room(id);Long sid=r.getScenario().getId();service.deleteRoom(id);return"redirect:/admin/codeescape/"+sid;}@PostMapping("/puzzles/{id}/delete")public String delPuzzle(@PathVariable Long id,@RequestParam Long roomId){service.deletePuzzle(id);return"redirect:/admin/codeescape/rooms/"+roomId;}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.controller; import com.example.mindflux.dto.*;import com.example.mindflux.service.CodeEscapeService;import com.fasterxml.jackson.databind.JsonNode;import jakarta.servlet.http.HttpSession;import org.springframework.web.bind.annotation.*;import java.security.Principal;
|
||||||
|
@RestController @RequestMapping("/api/codeescape") public class CodeEscapeApiController {private final CodeEscapeService service;public CodeEscapeApiController(CodeEscapeService s){service=s;}@GetMapping("/scenarios")public JsonNode catalog(){return service.catalog();}@GetMapping("/session/{slug}")public JsonNode session(@PathVariable String slug,HttpSession s){return service.session(slug,s);}@PostMapping("/check")public JsonNode check(@RequestBody EscapeCheckRequest r,HttpSession s){return service.check(r.getPuzzleId(),r.isReveal(),r.getSubmission(),s);}@PostMapping("/door")public JsonNode door(@RequestBody EscapeDoorRequest r,HttpSession s){return service.door(r.getRoomId(),r.getCode(),s);}@PostMapping("/hint")public JsonNode hint(@RequestBody EscapeHintRequest r,HttpSession s){return service.hint(r.getPuzzleId(),r.getLevel(),s);}@PostMapping("/finish")public EscapeResult finish(@RequestBody EscapeFinishRequest r,Principal p,HttpSession s){return service.finish(p.getName(),r,s);}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class CodeEscapeController{@GetMapping("/codeescape")public String page(){return "codeescape";}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; public class EscapeCheckRequest {private Long puzzleId;private boolean reveal;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public boolean isReveal(){return reveal;}public void setReveal(boolean v){reveal=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.dto; public class EscapeDoorRequest {private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.dto; import com.fasterxml.jackson.databind.JsonNode; import java.util.*; public class EscapeFinishRequest {private String slug;private int durationSeconds;private boolean escaped;private List<Item> items=new ArrayList<>();private List<Door> doors=new ArrayList<>();public String getSlug(){return slug;}public void setSlug(String v){slug=v;}public int getDurationSeconds(){return durationSeconds;}public void setDurationSeconds(int v){durationSeconds=v;}public boolean isEscaped(){return escaped;}public void setEscaped(boolean v){escaped=v;}public List<Item> getItems(){return items;}public void setItems(List<Item> v){items=v;}public List<Door> getDoors(){return doors;}public void setDoors(List<Door> v){doors=v;}public static class Item{private Long puzzleId;private JsonNode submission;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public JsonNode getSubmission(){return submission;}public void setSubmission(JsonNode v){submission=v;}}public static class Door{private Long roomId;private String code;public Long getRoomId(){return roomId;}public void setRoomId(Long v){roomId=v;}public String getCode(){return code;}public void setCode(String v){code=v;}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.dto; public class EscapeHintRequest {private Long puzzleId;private int level;public Long getPuzzleId(){return puzzleId;}public void setPuzzleId(Long v){puzzleId=v;}public int getLevel(){return level;}public void setLevel(int v){level=v;}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.dto; public record EscapeResult(int score,int baseScore,int maxScore,int timeBonus,int solvedPuzzles,int roomsOpened,boolean escaped,int stars,int highscore,boolean newHighscore,long plays){}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
@Entity @Table(name="escape_puzzles") public class EscapePuzzle {
|
||||||
|
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="room_id") private EscapeRoom room;
|
||||||
|
@Column(name="puzzle_type") private String type; private String prompt; private int points=100; @Column(name="code_fragment") private String codeFragment; @Column(name="sort_order") private int sortOrder; private String payload;
|
||||||
|
public EscapePuzzle(){} public EscapePuzzle(String t,String p,int pts,String f,int o,String j){type=t;prompt=p;points=pts;codeFragment=f;sortOrder=o;payload=j;} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeRoom getRoom(){return room;} public void setRoom(EscapeRoom v){room=v;} public String getType(){return type;} public void setType(String v){type=v;} public String getPrompt(){return prompt;} public void setPrompt(String v){prompt=v;} public int getPoints(){return points;} public void setPoints(int v){points=v;} public String getCodeFragment(){return codeFragment;} public void setCodeFragment(String v){codeFragment=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public String getPayload(){return payload;} public void setPayload(String v){payload=v;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
import jakarta.persistence.*; import java.util.*;
|
||||||
|
@Entity @Table(name="escape_rooms")
|
||||||
|
public class EscapeRoom {
|
||||||
|
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="scenario_id") private EscapeScenario scenario;
|
||||||
|
private String title; private String intro; @Column(name="door_prompt") private String doorPrompt; @Column(name="door_code") private String doorCode; @Column(name="success_text") private String successText; @Column(name="sort_order") private int sortOrder;
|
||||||
|
@OneToMany(mappedBy="room",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List<EscapePuzzle> puzzles=new ArrayList<>();
|
||||||
|
public EscapeRoom(){} public EscapeRoom(String t,String i,String p,String c,String s,int o){title=t;intro=i;doorPrompt=p;doorCode=c;successText=s;sortOrder=o;} public void addPuzzle(EscapePuzzle p){p.setRoom(this);puzzles.add(p);} public Long getId(){return id;} public void setId(Long v){id=v;} public EscapeScenario getScenario(){return scenario;} public void setScenario(EscapeScenario v){scenario=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getIntro(){return intro;} public void setIntro(String v){intro=v;} public String getDoorPrompt(){return doorPrompt;} public void setDoorPrompt(String v){doorPrompt=v;} public String getDoorCode(){return doorCode;} public void setDoorCode(String v){doorCode=v;} public String getSuccessText(){return successText;} public void setSuccessText(String v){successText=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public List<EscapePuzzle> getPuzzles(){return puzzles;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
import jakarta.persistence.*; import java.util.*;
|
||||||
|
@Entity @Table(name="escape_scenarios")
|
||||||
|
public class EscapeScenario {
|
||||||
|
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id;
|
||||||
|
@Column(unique=true,nullable=false) private String slug; private String title; private String difficulty;
|
||||||
|
@Column(name="exam_scope") private String examScope="AP1"; private String description;
|
||||||
|
@Column(name="time_limit_seconds") private int timeLimitSeconds=900;
|
||||||
|
@Column(name="sort_order") private int sortOrder; private boolean active=true;
|
||||||
|
@OneToMany(mappedBy="scenario",cascade=CascadeType.ALL,orphanRemoval=true) @OrderBy("sortOrder ASC, id ASC") private List<EscapeRoom> rooms=new ArrayList<>();
|
||||||
|
public EscapeScenario(){} public EscapeScenario(String slug,String title,String difficulty,String examScope,String description,int limit,int order){this.slug=slug;this.title=title;this.difficulty=difficulty;this.examScope=examScope;this.description=description;this.timeLimitSeconds=limit;this.sortOrder=order;}
|
||||||
|
public void addRoom(EscapeRoom r){r.setScenario(this);rooms.add(r);} public Long getId(){return id;} public void setId(Long v){id=v;} public String getSlug(){return slug;} public void setSlug(String v){slug=v;} public String getTitle(){return title;} public void setTitle(String v){title=v;} public String getDifficulty(){return difficulty;} public void setDifficulty(String v){difficulty=v;} public String getExamScope(){return examScope;} public void setExamScope(String v){examScope=v;} public String getDescription(){return description;} public void setDescription(String v){description=v;} public int getTimeLimitSeconds(){return timeLimitSeconds;} public void setTimeLimitSeconds(int v){timeLimitSeconds=v;} public int getSortOrder(){return sortOrder;} public void setSortOrder(int v){sortOrder=v;} public boolean isActive(){return active;} public void setActive(boolean v){active=v;} public List<EscapeRoom> getRooms(){return rooms;}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.repository; import com.example.mindflux.model.EscapePuzzle; import org.springframework.data.jpa.repository.JpaRepository; public interface EscapePuzzleRepository extends JpaRepository<EscapePuzzle,Long>{long countByRoomId(Long id);}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package com.example.mindflux.repository; import com.example.mindflux.model.EscapeRoom; import org.springframework.data.jpa.repository.JpaRepository; public interface EscapeRoomRepository extends JpaRepository<EscapeRoom,Long>{long countByScenarioId(Long id);}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.example.mindflux.repository; import com.example.mindflux.model.EscapeScenario; import org.springframework.data.jpa.repository.JpaRepository; import java.util.*;
|
||||||
|
public interface EscapeScenarioRepository extends JpaRepository<EscapeScenario,Long>{List<EscapeScenario> findAllByActiveTrueOrderBySortOrderAscIdAsc();List<EscapeScenario> findAllByOrderBySortOrderAscIdAsc();Optional<EscapeScenario> findBySlug(String slug);boolean existsBySlug(String slug);}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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 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;
|
||||||
|
private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;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 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.");}
|
||||||
|
@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 session(String slug,HttpSession http){EscapeScenario s=scenarios.findBySlug(slug).filter(EscapeScenario::isActive).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));List<EscapeRoom>selected=new ArrayList<>(s.getRooms());Collections.shuffle(selected);selected=selected.subList(0,Math.min(4,selected.size()));RunState st=new RunState();st.scenarioId=s.getId();st.slug=s.getSlug();selected.forEach(r->{st.roomIds.add(r.getId());r.getPuzzles().forEach(p->st.puzzleIds.add(p.getId()));});http.setAttribute("codeescape.run",st);ObjectNode root=mapper.createObjectNode();root.put("slug",s.getSlug());root.put("title",s.getTitle());root.put("difficulty",s.getDifficulty());root.put("examScope",s.getExamScope());root.put("description",s.getDescription());root.put("timeLimitSeconds",s.getTimeLimitSeconds());root.put("poolSize",s.getRooms().size());ArrayNode rs=root.putArray("rooms");int max=0;for(EscapeRoom r:selected){ObjectNode rv=rs.addObject();rv.put("roomId",r.getId());rv.put("title",r.getTitle());rv.put("intro",r.getIntro());rv.put("doorPrompt",r.getDoorPrompt());ArrayNode ps=rv.putArray("puzzles");for(EscapePuzzle p:r.getPuzzles()){ObjectNode pv=sanitize(p);if(pv!=null){ps.add(pv);max+=p.getPoints();}}}root.put("maxScore",max+selected.size()*50);return root;}
|
||||||
|
private ObjectNode sanitize(EscapePuzzle p){JsonNode j=parse(p.getPayload());if(j==null||!TYPES.contains(p.getType()))return null;ObjectNode v=mapper.createObjectNode();v.put("puzzleId",p.getId());v.put("type",p.getType());v.put("prompt",p.getPrompt());v.put("points",p.getPoints());copy(j,v,"narrative");copy(j,v,"instruction");switch(p.getType()){case"code"->copy(j,v,"placeholder");case"choice"->v.set("options",shuffle(strings(j,"options")));case"order"->v.set("items",shuffle(strings(j,"items")));case"match"->{ArrayNode l=v.putArray("left"),r=v.putArray("right");List<String>rr=new ArrayList<>();for(JsonNode pair:array(j,"pairs"))if(pair.isArray()&&pair.size()>1){l.add(pair.get(0).asText());rr.add(pair.get(1).asText());}Collections.shuffle(rr);rr.forEach(r::add);}}return v;}
|
||||||
|
@Transactional(readOnly=true) public JsonNode check(Long id,boolean reveal,JsonNode submission,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 attempts=st.attempts.merge(id,1,Integer::sum);if(attempts>3&&!reveal)throw new IllegalArgumentException("Maximale Versuchszahl erreicht.");boolean correct=matches(p,submission);if(reveal)st.revealed.add(id);if(correct||reveal)st.resolved.add(id);ObjectNode out=mapper.createObjectNode();out.put("correct",correct);out.put("resolved",correct||reveal);out.put("attempts",attempts);if(correct||reveal)out.put("fragment",p.getCodeFragment());if(reveal){JsonNode j=parse(p.getPayload());if(j!=null&&j.has("explanation"))out.put("explanation",j.get("explanation").asText());}return out;}
|
||||||
|
@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 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());}
|
||||||
|
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 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());}
|
||||||
|
// Admin
|
||||||
|
@Transactional(readOnly=true) public List<EscapeScenario> all(){return scenarios.findAllByOrderBySortOrderAscIdAsc();}@Transactional(readOnly=true) public EscapeScenario scenario(Long id){EscapeScenario s=scenarios.findById(id).orElseThrow();s.getRooms().size();return s;}@Transactional(readOnly=true) public EscapeRoom room(Long id){EscapeRoom r=rooms.findById(id).orElseThrow();r.getPuzzles().size();return r;}
|
||||||
|
@Transactional public Long addScenario(String title,String difficulty,String scope,String description,int limit){String slug=title.toLowerCase().replaceAll("[^a-z0-9]+","-").replaceAll("(^-|-$)","");if(slug.isBlank())slug="scenario";String b=slug;int i=2;while(scenarios.existsBySlug(slug))slug=b+"-"+i++;EscapeScenario s=new EscapeScenario(slug,title.trim(),difficulty,SCOPES.contains(scope)?scope:"AP1",description,Math.max(300,Math.min(3600,limit)),(int)scenarios.count()+1);return scenarios.save(s).getId();}
|
||||||
|
@Transactional public void addRoom(Long sid,String title,String intro,String prompt,String code,String success){EscapeScenario s=scenarios.findById(sid).orElseThrow();s.addRoom(new EscapeRoom(title,intro,prompt,code,success,s.getRooms().size()));}
|
||||||
|
@Transactional public void addPuzzle(Long rid,String type,String prompt,int points,String fragment,String payload){if(!TYPES.contains(type)||parse(payload)==null)throw new IllegalArgumentException("Ungültiger Rätseltyp oder Payload.");EscapeRoom r=rooms.findById(rid).orElseThrow();r.addPuzzle(new EscapePuzzle(type,prompt,Math.max(10,Math.min(1000,points)),fragment,r.getPuzzles().size(),payload));}
|
||||||
|
@Transactional public void deleteScenario(Long id){scenarios.deleteById(id);}@Transactional public void deleteRoom(Long id){rooms.deleteById(id);}@Transactional public void deletePuzzle(Long id){puzzles.deleteById(id);}@Transactional public void toggle(Long id){scenarios.findById(id).ifPresent(s->s.setActive(!s.isActive()));}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ public class ProgressService {
|
|||||||
GAMES.put("techtacular", "TechTacular");
|
GAMES.put("techtacular", "TechTacular");
|
||||||
GAMES.put("devlab", "DevLab");
|
GAMES.put("devlab", "DevLab");
|
||||||
GAMES.put("syntax-saga", "SyntaxSaga");
|
GAMES.put("syntax-saga", "SyntaxSaga");
|
||||||
GAMES.put("codeescape", "CodeEscape");
|
GAMES.put("codeescape", "SystemEscape");
|
||||||
GAMES.put("serviceclash", "ServiceClash");
|
GAMES.put("serviceclash", "ServiceClash");
|
||||||
GAMES.put("bughunter", "BugHunter");
|
GAMES.put("bughunter", "BugHunter");
|
||||||
GAMES.put("networker", "NetWorker");
|
GAMES.put("networker", "NetWorker");
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{"scenarios":[
|
||||||
|
{"slug":"verlassener-serverraum","title":"Der verlassene Serverraum","difficulty":"leicht","examScope":"AP1","description":"Zahlensysteme, Ports und Netzwerkgrundlagen öffnen den Weg nach draußen.","timeLimitSeconds":900,"rooms":[
|
||||||
|
{"title":"Rack 01","intro":"Notlicht flackert über einem verriegelten Rack. Das Terminal verlangt drei Fragmente.","doorPrompt":"Alle Fragmente in Fundreihenfolge eingeben.","doorCode":"4122SYN","successText":"Das Rack entriegelt. Hinter der Rückwand führt ein Wartungsgang weiter.","puzzles":[
|
||||||
|
{"type":"code","prompt":"Binärterminal","points":100,"fragment":"41","payload":{"narrative":"Auf dem Display pulsiert 0b101001.","instruction":"Wandle die Binärzahl in Dezimal um.","placeholder":"Dezimalwert","solutions":["41"],"hints":["Gewichte von rechts: 1, 2, 4, 8, 16, 32.","32 + 8 + 1"],"explanation":"101001₂ entspricht 32+8+1 = 41."}},
|
||||||
|
{"type":"choice","prompt":"Offener Wartungsport","points":100,"fragment":"22","payload":{"instruction":"Welcher Standardport gehört zu SSH?","options":["21","22","23","25"],"correct":"22","hints":["SSH ersetzt unsichere Fernwartung per Telnet.","Der Port liegt direkt nach FTP."],"explanation":"SSH nutzt standardmäßig TCP-Port 22."}},
|
||||||
|
{"type":"order","prompt":"TCP-Verbindung","points":120,"fragment":"SYN","payload":{"instruction":"Bringe den TCP-Drei-Wege-Handshake in Reihenfolge.","items":["SYN","SYN-ACK","ACK"],"solution":["SYN","SYN-ACK","ACK"],"hints":["Der Client beginnt mit einer Synchronisationsanfrage.","Die Mitte bestätigt und synchronisiert zugleich."],"explanation":"SYN → SYN-ACK → ACK."}}]},
|
||||||
|
{"title":"Patchfeld","intro":"Lose Leitungen hängen über dem Boden. Ein altes Patchpanel blockiert die Schleuse.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"6253UP","successText":"Die Link-LEDs springen an und die Schleuse gleitet auf.","puzzles":[
|
||||||
|
{"type":"code","prompt":"Subnetz-Kapazität","points":120,"fragment":"62","payload":{"instruction":"Wie viele nutzbare IPv4-Hosts bietet ein /26-Netz klassisch?","solutions":["62"],"hints":["Ein /26 lässt 6 Hostbits.","2⁶ Adressen minus Netz und Broadcast."],"explanation":"64 Gesamtadressen minus 2 = 62 Hosts."}},
|
||||||
|
{"type":"match","prompt":"Dienste verbinden","points":120,"fragment":"53","payload":{"instruction":"Ordne Dienst und Standardport zu.","pairs":[["DNS","53"],["HTTP","80"],["HTTPS","443"]],"hints":["Web unverschlüsselt liegt auf 80.","DNS nutzt 53, TLS-Web 443."],"explanation":"DNS 53, HTTP 80, HTTPS 443."}},
|
||||||
|
{"type":"choice","prompt":"Kabeldiagnose","points":100,"fragment":"UP","payload":{"instruction":"Welche Anzeige bestätigt eine physische Ethernet-Verbindung?","options":["Link LED","DNS Cache","Default Route","TTL"],"correct":"Link LED","hints":["Gesucht ist Schicht 1.","Sie sitzt meist direkt an der Buchse."],"explanation":"Die Link-LED zeigt den physischen Link."}}]},
|
||||||
|
{"title":"Kontrollraum","intro":"Die Hauptkonsole wartet auf die letzte Startsequenz.","doorPrompt":"Nutze die Fragmente der beiden Kontrollrätsel.","doorCode":"DHCP67","successText":"Die Außentür öffnet sich. Frische Nachtluft strömt herein.","puzzles":[
|
||||||
|
{"type":"order","prompt":"DHCP-Ablauf","points":140,"fragment":"DHCP","payload":{"instruction":"Sortiere den DORA-Ablauf.","items":["Discover","Offer","Request","Acknowledge"],"solution":["Discover","Offer","Request","Acknowledge"],"hints":["Der Client sucht zuerst.","Discover, Offer, Request, Acknowledge."],"explanation":"DORA beschreibt die DHCP-Adressvergabe."}},
|
||||||
|
{"type":"code","prompt":"DHCP-Serverport","points":100,"fragment":"67","payload":{"instruction":"Welchen UDP-Port nutzt der DHCP-Server?","solutions":["67"],"hints":["Client und Server verwenden zwei benachbarte Ports.","Der Server nutzt den kleineren: 67."],"explanation":"DHCP-Server: UDP 67, Client: UDP 68."}}]}]},
|
||||||
|
{"slug":"lockdown-rechenzentrum","title":"Lockdown im Rechenzentrum","difficulty":"mittel","examScope":"AP2_FISI","description":"RAID, USV, Backup und Virtualisierung unter Zeitdruck.","timeLimitSeconds":1100,"rooms":[
|
||||||
|
{"title":"Stromschleuse","intro":"Die USV hält nur noch wenige Minuten. Das Energiemanagement ist verriegelt.","doorPrompt":"Gib alle drei Fragmente in Fundreihenfolge ein.","doorCode":"3TBUSVR5","successText":"Die Last wechselt auf den sicheren Pfad.","puzzles":[
|
||||||
|
{"type":"code","prompt":"RAID-5-Kapazität","points":150,"fragment":"3TB","payload":{"instruction":"Vier 1-TB-Platten im RAID 5: nutzbare Kapazität?","solutions":["3 TB","3TB","3"],"hints":["Eine Plattenkapazität geht für Parität verloren.","(4−1) × 1 TB"],"explanation":"RAID 5 nutzt n−1 Plattenkapazitäten."}},
|
||||||
|
{"type":"choice","prompt":"USV-Typ","points":140,"fragment":"USV","payload":{"instruction":"Welche USV entkoppelt Verbraucher dauerhaft über den Wechselrichter?","options":["Offline","Line-interactive","Online-Doppelwandler","Steckdosenleiste"],"correct":"Online-Doppelwandler","hints":["Die Last wird permanent neu erzeugt.","VFI/Online-Doppelwandler."],"explanation":"Online-USV: Netz → Gleichrichter → Zwischenkreis → Wechselrichter."}},
|
||||||
|
{"type":"match","prompt":"RAID-Eigenschaften","points":140,"fragment":"R5","payload":{"instruction":"Ordne Level und Kerneigenschaft.","pairs":[["RAID 0","Striping"],["RAID 1","Spiegelung"],["RAID 5","verteilte Parität"]],"hints":["RAID 1 dupliziert Daten.","RAID 5 verteilt Paritätsinformationen."],"explanation":"Die Zuordnung beschreibt die Grundverfahren."}}]},
|
||||||
|
{"title":"Backup-Tresor","intro":"Ein Ransomware-Alarm sperrt das Backup-System.","doorPrompt":"Verbinde Backupfolge und Schutzprinzip.","doorCode":"VOLL321","successText":"Das unveränderliche Backup wird freigegeben.","puzzles":[
|
||||||
|
{"type":"order","prompt":"GFS-Wiederherstellung","points":160,"fragment":"VOLL","payload":{"instruction":"Sortiere eine Wiederherstellung mit inkrementellen Backups.","items":["Vollsicherung","Inkrement 1","Inkrement 2","Inkrement 3"],"solution":["Vollsicherung","Inkrement 1","Inkrement 2","Inkrement 3"],"hints":["Basis ist immer das letzte Vollbackup.","Danach jedes Inkrement chronologisch."],"explanation":"Inkrementelle Ketten benötigen Vollbackup plus alle Folgeinkremente."}},
|
||||||
|
{"type":"code","prompt":"3-2-1-Regel","points":140,"fragment":"321","payload":{"instruction":"Welche drei Ziffern beschreiben Kopien, Medientypen und externen Standort?","solutions":["321","3-2-1"],"hints":["Drei Kopien auf zwei Medien.","Eine Kopie außer Haus."],"explanation":"3 Kopien, 2 Medientypen, 1 extern."}}]},
|
||||||
|
{"title":"Hypervisor-Kern","intro":"Die Managementkonsole meldet Ressourcenüberbuchung.","doorPrompt":"Kombiniere alle Fragmente in Fundreihenfolge.","doorCode":"TYPE1NOSNAPISO","successText":"Der Cluster übernimmt und der Lockdown endet.","puzzles":[
|
||||||
|
{"type":"choice","prompt":"Bare-Metal-Hypervisor","points":150,"fragment":"TYPE1","payload":{"instruction":"Welcher Hypervisor läuft direkt auf der Hardware?","options":["Typ 1","Typ 2","Container Runtime","JVM"],"correct":"Typ 1","hints":["Kein Hostbetriebssystem dazwischen.","Auch Bare-Metal genannt."],"explanation":"Typ-1-Hypervisor laufen direkt auf der Hardware."}},
|
||||||
|
{"type":"code","prompt":"Snapshot-Falle","points":150,"fragment":"NOSNAP","payload":{"instruction":"Ist ein VM-Snapshot ein vollwertiges Backup? Antworte ja oder nein.","solutions":["nein","no"],"hints":["Snapshot und Basisdatenträger bleiben gekoppelt.","Ein Defekt des Datastores betrifft beides."],"explanation":"Snapshots ersetzen kein unabhängiges Backup."}},
|
||||||
|
{"type":"match","prompt":"Virtualisierungsgrenzen","points":150,"fragment":"ISO","payload":{"instruction":"Ordne Technik und Isolation.","pairs":[["VM","eigener Kernel"],["Container","geteilter Host-Kernel"],["VLAN","logische Netzsegmentierung"]],"hints":["Container teilen den Kernel.","VLAN segmentiert das Netz, nicht Prozesse."],"explanation":"VM, Container und VLAN isolieren auf verschiedenen Ebenen."}}]}]},
|
||||||
|
{"slug":"gehacktes-buero","title":"Das gehackte Büro","difficulty":"schwer","examScope":"CROSS","description":"Zertifikate, Kryptografie, SQL und Incident Response auf AP2-Niveau.","timeLimitSeconds":1200,"rooms":[
|
||||||
|
{"title":"Forensik-Labor","intro":"Ein kompromittierter Client hält die Beweise verschlüsselt.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"SHA256443CIA","successText":"Das Beweisarchiv wird lesbar.","puzzles":[
|
||||||
|
{"type":"choice","prompt":"Hashverfahren","points":180,"fragment":"SHA256","payload":{"instruction":"Welches Verfahren ist für Integritätsprüfungen heute geeignet?","options":["MD5","SHA-1","SHA-256","ROT13"],"correct":"SHA-256","hints":["MD5 und SHA-1 gelten als kollisionsgeschwächt.","Die gesuchte SHA-2-Variante hat 256 Bit."],"explanation":"SHA-256 ist eine verbreitete kryptografische Hashfunktion."}},
|
||||||
|
{"type":"code","prompt":"TLS-Port","points":140,"fragment":"443","payload":{"instruction":"Standardport für HTTPS?","solutions":["443"],"hints":["HTTP nutzt 80.","HTTPS nutzt 443."],"explanation":"HTTPS verwendet TCP 443."}},
|
||||||
|
{"type":"match","prompt":"Schutzziele","points":170,"fragment":"CIA","payload":{"instruction":"Ordne CIA-Schutzziel und deutsche Bedeutung.","pairs":[["Confidentiality","Vertraulichkeit"],["Integrity","Integrität"],["Availability","Verfügbarkeit"]],"hints":["CIA ist nicht der Nachrichtendienst in diesem Kontext.","C=Confidentiality, I=Integrity, A=Availability."],"explanation":"Die CIA-Triade bildet zentrale Informationssicherheitsziele."}}]},
|
||||||
|
{"title":"Datenbank-Bunker","intro":"Die Angreifer haben Spuren in einer unsicheren Suchmaske hinterlassen.","doorPrompt":"Verbinde SQL-Schutz und Transaktionsfragment.","doorCode":"PREPAREDACID","successText":"Der Datenbankzugang ist wieder unter Kontrolle.","puzzles":[
|
||||||
|
{"type":"choice","prompt":"SQL-Injection stoppen","points":190,"fragment":"PREPARED","payload":{"instruction":"Welche Maßnahme trennt SQL-Code zuverlässig von Eingabedaten?","options":["Prepared Statements","HTML-Escaping","Base64","Mehr RAM"],"correct":"Prepared Statements","hints":["Parameter werden gebunden statt konkateniert.","Prepared Statements."],"explanation":"Parametrisierte Statements verhindern das Einschleusen von SQL-Syntax."}},
|
||||||
|
{"type":"order","prompt":"Sichere Transaktion","points":180,"fragment":"ACID","payload":{"instruction":"Ordne den vereinfachten Ablauf.","items":["BEGIN","Änderungen ausführen","Validieren","COMMIT"],"solution":["BEGIN","Änderungen ausführen","Validieren","COMMIT"],"hints":["Transaktion öffnen, arbeiten, prüfen, bestätigen.","COMMIT steht am Ende."],"explanation":"Erst nach erfolgreicher Validierung wird committed."}}]},
|
||||||
|
{"title":"Incident-Leitstand","intro":"Die letzte Tür verlangt eine saubere Reaktion statt hektischer Einzelaktionen.","doorPrompt":"Setze alle Fragmente in Fundreihenfolge zusammen.","doorCode":"ISOLATECALEAST","successText":"Die Systeme sind eingedämmt. Der Ausgang ist frei.","puzzles":[
|
||||||
|
{"type":"order","prompt":"Incident Response","points":200,"fragment":"ISOLATE","payload":{"instruction":"Sortiere die ersten Reaktionsschritte.","items":["Erkennen","Eindämmen","Beweise sichern","Bereinigen","Wiederherstellen"],"solution":["Erkennen","Eindämmen","Beweise sichern","Bereinigen","Wiederherstellen"],"hints":["Vor Bereinigung muss der Vorfall eingedämmt und dokumentiert sein.","Erkennen → Eindämmen → Beweise → Bereinigen → Wiederherstellen."],"explanation":"Die Reihenfolge erhält Beweise und begrenzt Schaden."}},
|
||||||
|
{"type":"choice","prompt":"Zertifikatsvertrauen","points":180,"fragment":"CA","payload":{"instruction":"Wer bestätigt die Bindung zwischen Identität und öffentlichem Schlüssel?","options":["Certificate Authority","DHCP-Server","Hypervisor","Switch"],"correct":"Certificate Authority","hints":["Sie signiert Zertifikate.","Certificate Authority (CA)."],"explanation":"Eine CA validiert und signiert Zertifikate."}},
|
||||||
|
{"type":"code","prompt":"Prinzip der Rechte","points":160,"fragment":"LEAST","payload":{"instruction":"Ergänze: Principle of ___ Privilege.","solutions":["least"],"caseSensitive":false,"hints":["Nur minimal notwendige Rechte.","Das englische Wort bedeutet ‚am wenigsten‘."],"explanation":"Least Privilege reduziert Angriffsfläche."}}]}]}]}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
UPDATE escape_rooms SET door_code = '4122SYN', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Rack 01';
|
||||||
|
UPDATE escape_rooms SET door_code = '6253UP', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Patchfeld';
|
||||||
|
UPDATE escape_rooms SET door_code = 'DHCP67', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Kontrollraum';
|
||||||
|
UPDATE escape_rooms SET door_code = '3TBUSVR5', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Stromschleuse';
|
||||||
|
UPDATE escape_rooms SET door_code = 'VOLL321', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Backup-Tresor';
|
||||||
|
UPDATE escape_rooms SET door_code = 'TYPE1NOSNAPISO', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Hypervisor-Kern';
|
||||||
|
UPDATE escape_rooms SET door_code = 'SHA256443CIA', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Forensik-Labor';
|
||||||
|
UPDATE escape_rooms SET door_code = 'PREPAREDACID', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Datenbank-Bunker';
|
||||||
|
UPDATE escape_rooms SET door_code = 'ISOLATECALEAST', door_prompt = 'Setze alle Fragmente von links nach rechts ohne Leerzeichen zusammen.' WHERE title = 'Incident-Leitstand';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE escape_scenarios (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
difficulty VARCHAR(30) NOT NULL DEFAULT 'leicht',
|
||||||
|
exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1',
|
||||||
|
description VARCHAR(1200),
|
||||||
|
time_limit_seconds INT NOT NULL DEFAULT 900,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
CONSTRAINT chk_escape_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS'))
|
||||||
|
);
|
||||||
|
CREATE TABLE escape_rooms (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
scenario_id BIGINT NOT NULL REFERENCES escape_scenarios(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(200) NOT NULL, intro VARCHAR(2000), door_prompt VARCHAR(500),
|
||||||
|
door_code VARCHAR(50) NOT NULL, success_text VARCHAR(1000), sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE escape_puzzles (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
room_id BIGINT NOT NULL REFERENCES escape_rooms(id) ON DELETE CASCADE,
|
||||||
|
puzzle_type VARCHAR(30) NOT NULL, prompt VARCHAR(500) NOT NULL,
|
||||||
|
points INT NOT NULL DEFAULT 100, code_fragment VARCHAR(20) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0, payload VARCHAR(8000) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_escape_rooms_scenario ON escape_rooms(scenario_id);
|
||||||
|
CREATE INDEX idx_escape_puzzles_room ON escape_puzzles(room_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
body{padding-left:0!important;padding-bottom:0!important}
|
||||||
|
.mf-a11y-trigger{position:static!important;width:38px;height:38px;z-index:auto;flex:0 0 38px;box-shadow:0 0 14px var(--glow-w);border-radius:10px}
|
||||||
|
.mf-a11y-trigger svg{width:18px;height:18px}
|
||||||
|
.mf-a11y-panel{inset:76px 16px auto auto!important;width:min(450px,calc(100vw - 32px));max-height:calc(100dvh - 92px)}
|
||||||
|
.mf-a11y-utility{position:sticky;top:0;z-index:1700;min-height:60px;display:flex;align-items:center;justify-content:flex-end;padding:.5rem 1rem;background:var(--panel);border-bottom:1px solid var(--border)}
|
||||||
|
.mf-theme-float{display:flex;align-items:center;gap:.55rem}
|
||||||
|
@media(max-width:700px){.mf-a11y-panel{inset:70px 6px auto 6px!important;width:auto;max-height:calc(100dvh - 82px)}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
html[data-mf-font="115"]{font-size:115%!important}html[data-mf-font="130"]{font-size:130%!important}html[data-mf-font="150"]{font-size:150%!important}
|
||||||
|
html[data-mf-readable="true"] body,html[data-mf-readable="true"] button,html[data-mf-readable="true"] input,html[data-mf-readable="true"] select,html[data-mf-readable="true"] textarea{font-family:Verdana,Arial,sans-serif!important;letter-spacing:.025em;word-spacing:.1em;line-height:1.65}
|
||||||
|
html[data-mf-contrast="true"]{--bg:#000;--panel:#050505;--surface:#090909;--surface-2:#111;--text:#fff;--heading:#fff;--muted:#e7e7e7;--border:#fff;--border-accent:#ffd21a;--accent:#ffd21a;--accent-2:#fff}
|
||||||
|
html[data-mf-color="safe"]{--success:#2f9bff;--danger:#ff8a3d;--secondary:#c58cff;--esc-accent:#ffd166}
|
||||||
|
html[data-mf-mono="true"] body{filter:grayscale(1)}html[data-mf-links="true"] a{text-decoration:underline!important;text-underline-offset:.18em;text-decoration-thickness:.12em}
|
||||||
|
html[data-mf-motion="false"] *,html[data-mf-motion="false"] *::before,html[data-mf-motion="false"] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.001ms!important}
|
||||||
|
html[data-mf-cursor="true"],html[data-mf-cursor="true"] *{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cpath d='M2 1v25l7-7 5 11 5-2-5-10h10z' fill='white' stroke='black' stroke-width='2'/%3E%3C/svg%3E") 2 1,auto!important}
|
||||||
|
body{padding-left:72px}.mf-a11y-dock{position:fixed;inset:0 auto 0 0;z-index:1799;width:72px;display:flex;align-items:flex-end;justify-content:center;padding:0 0 18px;background:color-mix(in srgb,var(--panel) 94%,transparent);border-right:1px solid var(--border);box-shadow:5px 0 18px rgba(0,0,0,.16)}
|
||||||
|
.mf-a11y-trigger{position:fixed;left:8px;bottom:18px;z-index:1800;width:56px;height:56px;flex:0 0 auto;border:1px solid var(--border-accent);border-radius:50%;display:grid;place-items:center;background:linear-gradient(135deg,var(--accent),var(--accent-2));color:var(--accent-ink);box-shadow:0 0 22px var(--glow-s),0 7px 24px rgba(0,0,0,.38);cursor:pointer}.mf-a11y-trigger svg{width:31px;height:31px}.mf-a11y-trigger:focus-visible,.mf-a11y-panel button:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
||||||
|
.mf-a11y-panel{position:fixed;left:82px;bottom:18px;z-index:1801;width:min(450px,calc(100vw - 100px));max-height:calc(100dvh - 36px);overflow:auto;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);box-shadow:0 12px 45px rgba(0,0,0,.55)}.mf-a11y-panel[hidden]{display:none}.mf-a11y-head{position:sticky;top:0;display:flex;align-items:center;gap:1rem;padding:.65rem;background:#07121f;color:#fff;z-index:1}.mf-a11y-head h2{font:800 1.05rem Arial,sans-serif;margin:0 auto}.mf-a11y-close{width:44px;height:44px;border:0;border-radius:6px;background:#fff;color:#07121f;font-size:1.8rem;cursor:pointer}.mf-a11y-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:10px;background:var(--surface)}.mf-a11y-option{min-height:88px;border:1px solid var(--border);border-radius:6px;background:var(--surface-2);color:var(--text);padding:.65rem;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;font:inherit;cursor:pointer}.mf-a11y-option strong{font-size:1.35rem}.mf-a11y-option[aria-pressed="true"]{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 18%,var(--surface));box-shadow:inset 0 0 0 2px var(--accent)}.mf-a11y-option small{color:var(--muted)}@media(max-width:700px){body{padding-left:0;padding-bottom:68px}.mf-a11y-dock{inset:auto 0 0 0;width:auto;height:68px;padding:0 10px;justify-content:flex-start;align-items:center;border-right:0;border-top:1px solid var(--border);box-shadow:0 -5px 18px rgba(0,0,0,.18)}.mf-a11y-trigger{width:52px;height:52px}.mf-a11y-panel{left:6px;bottom:74px;width:calc(100vw - 12px);max-height:calc(100dvh - 86px)}.mf-a11y-grid{grid-template-columns:1fr 1fr}.mf-a11y-option{min-height:78px;font-size:.82rem}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.esc-global-tools{justify-self:end;display:flex;align-items:center;gap:.65rem}.mf-a11y-host{display:flex}.esc-back,.esc-timer{font-family:"Inter","Segoe UI",system-ui,sans-serif}.esc-back{font-weight:600}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.esc-how{display:grid;grid-template-columns:repeat(3,1fr);gap:.65rem;margin:0 0 1rem}.esc-how>div{display:flex;align-items:center;gap:.65rem;padding:.7rem;background:var(--surface);border:1px solid var(--border);border-radius:9px;color:var(--muted)}.esc-how b{display:grid;place-items:center;min-width:1.7rem;height:1.7rem;border:1px solid var(--border);border-radius:50%;color:var(--muted)}.esc-how strong,.esc-how small{display:block}.esc-how strong{color:var(--heading);font-size:.78rem}.esc-how small{margin-top:.15rem;font-size:.65rem}.esc-how .done,.esc-how .active{border-color:var(--esc-accent)}.esc-how .done b{background:var(--success);border-color:var(--success);color:#06120a}.esc-how .active{box-shadow:0 0 18px var(--esc-glow)}.esc-puzzle:not(.done) .status{color:var(--esc-accent)}.esc-puzzle:not(.done):hover{border-color:var(--esc-accent);transform:translateY(-2px)}.esc-fragment{display:flex;align-items:center;gap:.45rem}.esc-fragment small{display:grid;place-items:center;width:1.25rem;height:1.25rem;border-radius:50%;background:var(--esc-accent);color:#160d00;font-size:.6rem}.esc-inventory.complete{border-style:solid;border-color:var(--success)}.esc-ready{margin:.7rem 0 0;color:var(--success);font-size:.75rem}.esc-door{flex-wrap:wrap}.esc-door.ready{border-color:var(--esc-accent);box-shadow:0 0 22px color-mix(in srgb,var(--esc-accent) 14%,transparent)}.esc-door-title{flex-basis:100%;display:flex;justify-content:space-between;align-items:center}.esc-door-title strong{color:var(--heading);font-size:.8rem}.esc-door-feedback{flex-basis:100%;min-height:1.2rem;font-size:.75rem}.esc-door-feedback.ok{color:var(--success)}.esc-door-feedback.bad{color:var(--danger)}.esc-door-feedback.wait{color:var(--esc-accent)}.esc-input.bad{border-color:var(--danger);box-shadow:0 0 0 2px color-mix(in srgb,var(--danger) 28%,transparent)}.esc-btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}@media(max-width:800px){.esc-how{grid-template-columns:1fr}.esc-door-title{align-items:flex-start}}
|
||||||
File diff suppressed because one or more lines are too long
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
@keyframes dlgIn { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: translateY(0); } }
|
@keyframes dlgIn { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
html { font-size: clamp(16px, calc(12px + 0.34vw), 21px); }
|
||||||
body.dlg-body { min-height: 100vh; display: flex; flex-direction: column; color: var(--text); }
|
body.dlg-body { min-height: 100vh; display: flex; flex-direction: column; color: var(--text); }
|
||||||
.dlg-shell { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; padding: 0.7rem 0.9rem 1.4rem; gap: 0.7rem; }
|
.dlg-shell { flex: 1; width: min(96vw, 2200px); min-height: 100vh; margin: 0 auto; display: flex; flex-direction: column; padding: clamp(.7rem, 1.2vw, 1.4rem); gap: clamp(.7rem, 1vw, 1.15rem); }
|
||||||
|
|
||||||
.dlg-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.5rem 1.1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--g-soft); }
|
.dlg-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.5rem 1.1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--g-soft); }
|
||||||
.dlg-head-title { text-align: center; }
|
.dlg-head-title { text-align: center; }
|
||||||
@@ -18,8 +19,8 @@ body.dlg-body { min-height: 100vh; display: flex; flex-direction: column; color:
|
|||||||
.dlg-stat { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.35rem 0.7rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); white-space: nowrap; }
|
.dlg-stat { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.35rem 0.7rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); white-space: nowrap; }
|
||||||
.dlg-stat b { font-size: 1.05rem; color: var(--accent); }
|
.dlg-stat b { font-size: 1.05rem; color: var(--accent); }
|
||||||
|
|
||||||
.dlg-stage { flex: 1; display: grid; grid-template-columns: 1fr 300px; gap: 0.8rem; align-items: start; }
|
.dlg-stage { flex: 1; display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); gap: clamp(.8rem, 1.2vw, 1.4rem); align-items: stretch; }
|
||||||
.dlg-card { background: var(--surface); border: 1px solid var(--border-accent); border-radius: 16px; padding: 1.3rem 1.5rem 1.4rem; box-shadow: 0 0 24px var(--glow-w); animation: dlgIn 0.32s ease-out; min-width: 0; }
|
.dlg-card { background: var(--surface); border: 1px solid var(--border-accent); border-radius: 16px; padding: clamp(1.3rem, 1.7vw, 2.2rem); box-shadow: 0 0 24px var(--glow-w); animation: dlgIn 0.32s ease-out; min-width: 0; }
|
||||||
.dlg-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
.dlg-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||||
.dlg-points { color: var(--accent); font-weight: 800; }
|
.dlg-points { color: var(--accent); font-weight: 800; }
|
||||||
.dlg-card h2 { color: var(--heading); font-size: 1.3rem; font-weight: 800; margin-bottom: 0.4rem; }
|
.dlg-card h2 { color: var(--heading); font-size: 1.3rem; font-weight: 800; margin-bottom: 0.4rem; }
|
||||||
@@ -111,7 +112,7 @@ body.dlg-body { min-height: 100vh; display: flex; flex-direction: column; color:
|
|||||||
.dlg-result-high[hidden] { display: none; }
|
.dlg-result-high[hidden] { display: none; }
|
||||||
.dlg-result-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; margin-top: 1rem; }
|
.dlg-result-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; margin-top: 1rem; }
|
||||||
|
|
||||||
@media (max-width: 820px) { .dlg-stage { grid-template-columns: 1fr; } }
|
@media (max-width: 820px) { html { font-size: 16px; } .dlg-shell { width: 100%; } .dlg-stage { grid-template-columns: 1fr; } }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.dlg-head-title h1 { font-size: 1.05rem; }
|
.dlg-head-title h1 { font-size: 1.05rem; }
|
||||||
.dlg-stat { font-size: 0.62rem; padding: 0.3rem 0.5rem; }
|
.dlg-stat { font-size: 0.62rem; padding: 0.3rem 0.5rem; }
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* FluxTimer – Zeitschaltkreis-Optik, rein clientseitig. */
|
||||||
|
.ft-trigger { position:relative; color:var(--fluxtimer-secondary); }
|
||||||
|
.ft-trigger-icon { width:22px!important; height:22px!important; fill:none; stroke:currentColor; stroke-width:1.35; }
|
||||||
|
.ft-trigger-bolt { fill:color-mix(in srgb,currentColor 24%,transparent); stroke-width:1.5; stroke-linejoin:round; }
|
||||||
|
.ft-trigger-dot { position:absolute; right:4px; top:4px; width:6px; height:6px; border-radius:50%; background:var(--muted); border:1px solid var(--surface); opacity:.5; }
|
||||||
|
.ft-trigger.ft-active { color:var(--fluxtimer-primary); border-color:color-mix(in srgb,var(--fluxtimer-primary) 65%,var(--border)); box-shadow:0 0 14px color-mix(in srgb,var(--fluxtimer-primary) 35%,transparent); }
|
||||||
|
.ft-trigger.ft-active .ft-trigger-dot { background:var(--fluxtimer-primary); box-shadow:0 0 8px var(--fluxtimer-primary); opacity:1; }
|
||||||
|
.ft-popover { position:fixed; z-index:1600; width:min(340px,calc(100vw - 24px)); padding:1rem; border:1px solid color-mix(in srgb,var(--fluxtimer-secondary) 55%,var(--border)); border-radius:16px; background:var(--panel); box-shadow:0 18px 55px rgba(0,0,0,.38),0 0 24px color-mix(in srgb,var(--fluxtimer-secondary) 22%,transparent); display:none; }
|
||||||
|
.ft-popover.open { display:block; animation:ft-in .18s ease-out; }
|
||||||
|
.ft-popover-head,.ft-popover-actions,.ft-settings-head,.ft-settings-actions,.ft-options { display:flex; align-items:center; gap:.7rem; }
|
||||||
|
.ft-popover-head { justify-content:space-between; margin-bottom:.75rem; }
|
||||||
|
.ft-popover-head b { color:var(--heading); letter-spacing:.08em; }
|
||||||
|
.ft-close { border:0; background:none; color:var(--muted); cursor:pointer; font-size:1.25rem; }
|
||||||
|
.ft-circuit { display:grid; gap:.55rem; padding:.8rem; border-radius:12px; background:var(--fluxtimer-display); border:1px solid color-mix(in srgb,var(--fluxtimer-primary) 45%,var(--border)); box-shadow:inset 0 0 22px rgba(0,0,0,.28); }
|
||||||
|
.ft-row { display:grid; grid-template-columns:1fr auto; align-items:center; gap:.7rem; }
|
||||||
|
.ft-row span { font-size:.65rem; letter-spacing:.12em; color:var(--muted); }
|
||||||
|
.ft-row strong,.ft-preview strong { color:var(--fluxtimer-segment); font-family:"Courier New",monospace; font-variant-numeric:tabular-nums; letter-spacing:.08em; text-shadow:0 0 9px color-mix(in srgb,var(--fluxtimer-segment) 58%,transparent); }
|
||||||
|
.ft-row.primary strong { font-size:1.3rem; }
|
||||||
|
.ft-popover-status { color:var(--muted); font-size:.8rem; margin:.7rem 0; }
|
||||||
|
.ft-popover-actions { justify-content:space-between; flex-wrap:wrap; }
|
||||||
|
.ft-mini-toggle { border:1px solid var(--border-accent); background:var(--surface-2); color:var(--text); border-radius:9px; padding:.45rem .7rem; cursor:pointer; font-weight:700; }
|
||||||
|
.ft-settings-head { justify-content:space-between; align-items:flex-start; margin-bottom:1rem; }
|
||||||
|
.ft-settings-head p { color:var(--muted); font-size:.88rem; margin-top:.25rem; }
|
||||||
|
.ft-settings-grid { display:grid; grid-template-columns:1fr 220px; gap:1rem; align-items:stretch; }
|
||||||
|
.ft-preview { display:flex; flex-direction:column; justify-content:center; align-items:center; padding:.8rem; border-radius:12px; background:var(--fluxtimer-display); border:1px solid color-mix(in srgb,var(--fluxtimer-primary) 42%,var(--border)); }
|
||||||
|
.ft-preview span { color:var(--muted); font-size:.65rem; letter-spacing:.13em; }
|
||||||
|
.ft-preview strong { font-size:1.55rem; margin-top:.25rem; }
|
||||||
|
.ft-switch { display:inline-flex; align-items:center; gap:.5rem; cursor:pointer; color:var(--text); }
|
||||||
|
.ft-switch input { position:absolute; opacity:0; }
|
||||||
|
.ft-switch span { width:44px; height:24px; border-radius:99px; background:var(--surface-2); border:1px solid var(--border); position:relative; transition:.2s; }
|
||||||
|
.ft-switch span::after { content:""; position:absolute; width:18px; height:18px; left:2px; top:2px; border-radius:50%; background:var(--muted); transition:.2s; }
|
||||||
|
.ft-switch input:checked + span { background:color-mix(in srgb,var(--fluxtimer-secondary) 34%,var(--surface)); border-color:var(--fluxtimer-secondary); }
|
||||||
|
.ft-switch input:checked + span::after { transform:translateX(20px); background:var(--fluxtimer-secondary); box-shadow:0 0 8px var(--fluxtimer-secondary); }
|
||||||
|
.ft-reminders { display:flex; flex-wrap:wrap; gap:.2rem 1rem; border:0; margin:.3rem 0 .8rem; }
|
||||||
|
.ft-reminders legend { width:100%; margin-bottom:.25rem; }
|
||||||
|
.ft-options { flex-wrap:wrap; margin:.25rem 0 .8rem; }
|
||||||
|
.ft-settings-actions { justify-content:space-between; flex-wrap:wrap; }
|
||||||
|
.ft-overlay { position:fixed; inset:0; z-index:1900; display:none; place-items:center; padding:1rem; background:rgba(5,3,2,.76); backdrop-filter:blur(7px); }
|
||||||
|
.ft-overlay.open { display:grid; }
|
||||||
|
.ft-overlay::before { content:""; position:absolute; inset:0; pointer-events:none; background:linear-gradient(115deg,transparent 35%,rgba(255,255,255,.7) 49%,transparent 55%); transform:translateX(-120%); }
|
||||||
|
.ft-overlay.open::before { animation:ft-flash .7s ease-out; }
|
||||||
|
.ft-modal { position:relative; width:min(560px,100%); text-align:center; padding:1.5rem; border-radius:20px; background:var(--panel); border:1px solid var(--fluxtimer-secondary); box-shadow:0 0 50px color-mix(in srgb,var(--fluxtimer-secondary) 35%,transparent); }
|
||||||
|
.ft-modal .ft-kicker { color:var(--fluxtimer-secondary); text-transform:uppercase; letter-spacing:.18em; font-size:.72rem; font-weight:800; }
|
||||||
|
.ft-modal h2 { color:var(--heading); font-size:1.7rem; margin:.7rem 0 .4rem; }
|
||||||
|
.ft-modal p { color:var(--muted); line-height:1.55; }
|
||||||
|
.ft-modal-actions { display:flex; justify-content:center; flex-wrap:wrap; gap:.7rem; margin-top:1.2rem; }
|
||||||
|
@keyframes ft-in { from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:none} }
|
||||||
|
@keyframes ft-flash { to{transform:translateX(120%)} }
|
||||||
|
@media (max-width:620px){ .ft-settings-grid{grid-template-columns:1fr}.ft-settings-head{align-items:stretch;flex-direction:column}.ft-popover{left:12px!important;right:12px!important;top:64px!important;width:auto} }
|
||||||
|
@media (prefers-reduced-motion:reduce){ .ft-popover.open,.ft-overlay.open::before{animation:none} }
|
||||||
@@ -136,7 +136,8 @@ body.tt-body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tt-cat-title {
|
.tt-cat-title {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 3.5rem;
|
||||||
|
min-height: 3.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
--secondary:#2d9cce; --secondary-2:#5cc4e8;
|
--secondary:#2d9cce; --secondary-2:#5cc4e8;
|
||||||
--text:#ece3d2; --muted:#8c8170; --heading:#ffffff;
|
--text:#ece3d2; --muted:#8c8170; --heading:#ffffff;
|
||||||
--success:#2d9cce; --danger:#ff4d6d;
|
--success:#2d9cce; --danger:#ff4d6d;
|
||||||
|
--esc-accent:#f0a81a; --esc-glow:rgba(240,168,26,.48);
|
||||||
|
--bh-accent:#d94b64; --bh-glow:rgba(217,75,100,.42);
|
||||||
|
--sc-accent:#38b9d6; --sc-glow:rgba(56,185,214,.40);
|
||||||
|
--nw-accent:#4f8ff7; --nw-glow:rgba(79,143,247,.42);
|
||||||
|
--fluxtimer-primary:#ff5a5f; --fluxtimer-secondary:#f0a81a;
|
||||||
|
--fluxtimer-display:#170704; --fluxtimer-segment:#ff765f;
|
||||||
--glow-s:rgba(240,168,26,.55); --glow-w:rgba(240,168,26,.16);
|
--glow-s:rgba(240,168,26,.55); --glow-w:rgba(240,168,26,.16);
|
||||||
--g-soft:0 0 18px var(--glow-w);
|
--g-soft:0 0 18px var(--glow-w);
|
||||||
}
|
}
|
||||||
@@ -25,6 +31,12 @@
|
|||||||
--secondary:#1f86b8; --secondary-2:#2d9cce;
|
--secondary:#1f86b8; --secondary-2:#2d9cce;
|
||||||
--text:#33271c; --muted:#8a7d6c; --heading:#241a12;
|
--text:#33271c; --muted:#8a7d6c; --heading:#241a12;
|
||||||
--success:#1f86b8; --danger:#be123c;
|
--success:#1f86b8; --danger:#be123c;
|
||||||
|
--esc-accent:#c96a12; --esc-glow:rgba(201,106,18,.30);
|
||||||
|
--bh-accent:#b42342; --bh-glow:rgba(180,35,66,.26);
|
||||||
|
--sc-accent:#147f9e; --sc-glow:rgba(20,127,158,.26);
|
||||||
|
--nw-accent:#2868c7; --nw-glow:rgba(40,104,199,.26);
|
||||||
|
--fluxtimer-primary:#b42342; --fluxtimer-secondary:#c96a12;
|
||||||
|
--fluxtimer-display:#fff5ec; --fluxtimer-segment:#a93131;
|
||||||
--glow-s:rgba(214,81,22,.40); --glow-w:rgba(214,81,22,.12);
|
--glow-s:rgba(214,81,22,.40); --glow-w:rgba(214,81,22,.12);
|
||||||
--g-soft:0 6px 18px var(--glow-w);
|
--g-soft:0 6px 18px var(--glow-w);
|
||||||
}
|
}
|
||||||
@@ -84,8 +96,6 @@ a { color:var(--secondary); }
|
|||||||
.mf-hello h1 { color:var(--heading); font-size:1.85rem; font-weight:800; letter-spacing:-.01em; }
|
.mf-hello h1 { color:var(--heading); font-size:1.85rem; font-weight:800; letter-spacing:-.01em; }
|
||||||
.mf-hello h1 .nm { color:var(--accent); text-shadow:0 0 16px var(--glow-s); }
|
.mf-hello h1 .nm { color:var(--accent); text-shadow:0 0 16px var(--glow-s); }
|
||||||
.mf-hello p { color:var(--muted); margin-top:.35rem; font-size:.95rem; }
|
.mf-hello p { color:var(--muted); margin-top:.35rem; font-size:.95rem; }
|
||||||
.mf-pulse { flex:1; min-width:0; display:flex; justify-content:center; align-items:center; }
|
|
||||||
.mf-pulse svg { width:200px; max-width:100%; filter:drop-shadow(0 0 7px var(--glow-s)); }
|
|
||||||
.mf-tools { display:flex; align-items:center; gap:.6rem; margin-left:auto; }
|
.mf-tools { display:flex; align-items:center; gap:.6rem; margin-left:auto; }
|
||||||
.mf-iconbtn { width:38px; height:38px; border-radius:10px; border:1px solid var(--border); background:var(--surface); color:var(--muted); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; text-decoration:none; transition:all .18s; }
|
.mf-iconbtn { width:38px; height:38px; border-radius:10px; border:1px solid var(--border); background:var(--surface); color:var(--muted); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; text-decoration:none; transition:all .18s; }
|
||||||
.mf-iconbtn:hover { color:var(--accent); border-color:var(--border-accent); box-shadow:0 0 14px var(--glow-w); }
|
.mf-iconbtn:hover { color:var(--accent); border-color:var(--border-accent); box-shadow:0 0 14px var(--glow-w); }
|
||||||
@@ -113,6 +123,8 @@ a { color:var(--secondary); }
|
|||||||
.mf-play { margin-top:.9rem; align-self:flex-start; display:inline-flex; align-items:center; gap:.45rem; font-size:.74rem; font-weight:800; letter-spacing:.08em; color:var(--accent); border:1px solid var(--border-accent); border-radius:8px; padding:.5rem .85rem; text-decoration:none; transition:all .18s; }
|
.mf-play { margin-top:.9rem; align-self:flex-start; display:inline-flex; align-items:center; gap:.45rem; font-size:.74rem; font-weight:800; letter-spacing:.08em; color:var(--accent); border:1px solid var(--border-accent); border-radius:8px; padding:.5rem .85rem; text-decoration:none; transition:all .18s; }
|
||||||
.mf-card:hover .mf-play { background:var(--accent); color:var(--accent-ink); box-shadow:0 0 16px var(--glow-s); }
|
.mf-card:hover .mf-play { background:var(--accent); color:var(--accent-ink); box-shadow:0 0 16px var(--glow-s); }
|
||||||
.mf-play svg { width:14px; height:14px; }
|
.mf-play svg { width:14px; height:14px; }
|
||||||
|
.mf-card-codeescape { --accent:var(--esc-accent); --glow-s:var(--esc-glow); --glow-w:color-mix(in srgb,var(--esc-accent) 16%,transparent); background:repeating-linear-gradient(0deg,rgba(255,255,255,.015) 0 1px,transparent 1px 4px),var(--surface); }
|
||||||
|
.mf-card-codeescape::after { content:"ACCESS"; position:absolute; right:12px; top:9px; color:var(--esc-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.6; }
|
||||||
|
|
||||||
/* ===== Panels / Widgets ===== */
|
/* ===== Panels / Widgets ===== */
|
||||||
.mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); }
|
.mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); }
|
||||||
@@ -186,7 +198,7 @@ body.mf-nav-open .mf-nav-backdrop { opacity:1; pointer-events:auto; }
|
|||||||
.mf-main { padding:1.1rem 1rem 1.4rem; }
|
.mf-main { padding:1.1rem 1rem 1.4rem; }
|
||||||
.mf-rightbar { padding:0 1rem 1.4rem; }
|
.mf-rightbar { padding:0 1rem 1.4rem; }
|
||||||
.mf-grid { grid-template-columns:repeat(2,1fr); }
|
.mf-grid { grid-template-columns:repeat(2,1fr); }
|
||||||
.mf-pulse, .mf-search { display:none; }
|
.mf-search { display:none; }
|
||||||
.mf-hello h1 { font-size:1.4rem; }
|
.mf-hello h1 { font-size:1.4rem; }
|
||||||
.mf-tools { margin-left:auto; }
|
.mf-tools { margin-left:auto; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(function(){'use strict';var KEY='mf-accessibility',levels=[100,115,130,150],defaults={font:100,readable:false,contrast:false,color:false,mono:false,links:false,motion:true,cursor:false};
|
||||||
|
function load(){try{return Object.assign({},defaults,JSON.parse(localStorage.getItem(KEY)||'{}'));}catch(e){return Object.assign({},defaults);}}var state=load();
|
||||||
|
function apply(){var h=document.documentElement;h.setAttribute('data-mf-font',String(state.font));h.setAttribute('data-mf-readable',state.readable);h.setAttribute('data-mf-contrast',state.contrast);h.setAttribute('data-mf-color',state.color?'safe':'standard');h.setAttribute('data-mf-mono',state.mono);h.setAttribute('data-mf-links',state.links);h.setAttribute('data-mf-motion',state.motion);h.setAttribute('data-mf-cursor',state.cursor);try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
|
||||||
|
apply();
|
||||||
|
function button(id,icon,label,detail){return '<button type="button" class="mf-a11y-option" data-a11y="'+id+'"><strong aria-hidden="true">'+icon+'</strong><span>'+label+'</span>'+(detail?'<small>'+detail+'</small>':'')+'</button>';}
|
||||||
|
function init(){if(document.querySelector('.mf-a11y-trigger'))return;var trigger=document.createElement('button');trigger.type='button';trigger.className='mf-a11y-trigger';trigger.setAttribute('aria-label','Barrierefreiheit öffnen');trigger.setAttribute('aria-expanded','false');trigger.setAttribute('aria-controls','mf-a11y-panel');trigger.innerHTML='<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="18" cy="5" r="3" fill="currentColor"/><path d="M13 11l5-2 5 4m-7-2-2 7-6 1m8-4 6 2 3 7M13 18a7 7 0 1 0 6 7"/></svg>';
|
||||||
|
var panel=document.createElement('section');panel.id='mf-a11y-panel';panel.className='mf-a11y-panel';panel.hidden=true;panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','false');panel.setAttribute('aria-labelledby','mf-a11y-title');panel.innerHTML='<div class="mf-a11y-head"><button type="button" class="mf-a11y-close" aria-label="Barrierefreiheit schließen">×</button><h2 id="mf-a11y-title">Barrierefreiheit</h2></div><div class="mf-a11y-grid">'+button('font','T⁺','Text vergrößern','100 %')+button('contrast','◐','Hochkontrast')+button('color','◉','Farbenblind-Modus')+button('mono','◒','Monochrom')+button('readable','Aa','Lesemodus (Dyslexie)','Verdana + Abstände')+button('links','🔗','Links unterstreichen')+button('motion','Ⅱ','Animationen stoppen')+button('cursor','➤','Großer Cursor')+button('reset','↺','Alles zurücksetzen')+'</div>';
|
||||||
|
document.body.appendChild(trigger);document.body.appendChild(panel);var close=panel.querySelector('.mf-a11y-close');function paint(){panel.querySelectorAll('[data-a11y]').forEach(function(b){var id=b.dataset.a11y,on=id==='font'?state.font>100:id==='motion'?!state.motion:id==='reset'?false:!!state[id];b.setAttribute('aria-pressed',on);if(id==='font')b.querySelector('small').textContent=state.font+' %';});}
|
||||||
|
var host=document.querySelector('.mf-a11y-host,.mf-tools,.tt-header-tools,.mm-header-tools,.ss-header-tools,.dlg-header-tools,.mf-theme-float');if(host){host.insertBefore(trigger,host.firstChild);}else{var utility=document.createElement('div');utility.className='mf-a11y-utility';utility.setAttribute('aria-label','Globale Werkzeuge');utility.appendChild(trigger);document.body.insertBefore(utility,document.body.firstChild);}
|
||||||
|
function hide(){panel.hidden=true;trigger.setAttribute('aria-expanded','false');trigger.focus();}function show(){panel.hidden=false;trigger.setAttribute('aria-expanded','true');paint();close.focus();}trigger.addEventListener('click',function(){panel.hidden?show():hide();});close.addEventListener('click',hide);window.addEventListener('keydown',function(e){if(e.key==='Escape'&&!panel.hidden)hide();});panel.addEventListener('click',function(e){var b=e.target.closest('[data-a11y]');if(!b)return;var id=b.dataset.a11y;if(id==='reset')state=Object.assign({},defaults);else if(id==='font')state.font=levels[(levels.indexOf(state.font)+1)%levels.length];else if(id==='motion')state.motion=!state.motion;else state[id]=!state[id];apply();paint();});paint();}
|
||||||
|
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();})();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
(function(){'use strict';const view=document.getElementById('escView'),timerEl=document.getElementById('escTimer');let catalog=[],session=null,roomIndex=0,start=0,tickId=null,answers=new Map(),fragments=new Map(),selected=null;
|
||||||
|
const api=(url,opt)=>fetch('/api/codeescape'+url,{headers:{'Content-Type':'application/json'},...opt}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.message||'Serverfehler');return j});
|
||||||
|
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function post(url,data){return api(url,{method:'POST',body:JSON.stringify(data)});}function seconds(){return Math.max(1,Math.round((Date.now()-start)/1000));}
|
||||||
|
function startTimer(){clearInterval(tickId);start=Date.now();tickId=setInterval(()=>{let left=session.timeLimitSeconds-seconds();timerEl.textContent=(left<0?'+':'')+fmt(Math.abs(left));timerEl.classList.toggle('alarm',left<=0);},1000);}function fmt(s){return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
|
||||||
|
function loadCatalog(){api('/scenarios').then(d=>{catalog=d.scenarios;renderLobby();}).catch(error);}
|
||||||
|
function renderLobby(){timerEl.textContent='--:--';view.innerHTML='<div class="esc-hero"><span class="esc-eyebrow">FACILITY ARCHIVE // VARIABLE RUNS</span><h1>Wähle deinen Fluchtweg</h1><p>Jeder Run stellt vier Räume neu aus einem größeren Pool zusammen. Löse IT-Rätsel, sichere Fragmente und entkomme – der nächste Durchlauf wird anders.</p></div><div class="esc-grid">'+catalog.map(s=>`<article class="esc-card"><span class="esc-eyebrow">${esc(s.examScope)} · ${esc(s.difficulty)}</span><h2>${esc(s.title)}</h2><p>${esc(s.description)}</p><div class="esc-meta"><span class="esc-chip">${s.roomCount} Räume pro Run</span><span class="esc-chip">${s.poolSize} im Pool</span><span class="esc-chip">${Math.round(s.timeLimitSeconds/60)} Minuten</span></div><button class="esc-btn" data-start="${esc(s.slug)}">NEUEN RUN GENERIEREN</button></article>`).join('')+'</div>';view.querySelectorAll('[data-start]').forEach(b=>b.onclick=()=>begin(b.dataset.start));}
|
||||||
|
function begin(slug){api('/session/'+encodeURIComponent(slug)).then(d=>{session=d;roomIndex=0;answers.clear();fragments.clear();startTimer();renderRoom();}).catch(error);}
|
||||||
|
function renderRoom(){const r=session.rooms[roomIndex],complete=r.puzzles.every(p=>answers.has(p.puzzleId));view.innerHTML=`<div class="esc-room-head"><div><span class="esc-eyebrow">RAUM ${roomIndex+1} / ${session.rooms.length} · ${esc(session.examScope)}</span><h1>${esc(r.title)}</h1><p>${esc(r.intro)}</p></div><div class="esc-progress">${'● '.repeat(roomIndex+1)}${'○ '.repeat(session.rooms.length-roomIndex-1)}</div></div><div class="esc-how"><div class="${complete?'done':''}"><b>1</b><span><strong>Rätsel lösen</strong><small>Die Rätsel sind frei wählbar.</small></span></div><div class="${complete?'done':''}"><b>2</b><span><strong>Fragmente sammeln</strong><small>Ihre Reihenfolge ist links → rechts.</small></span></div><div class="${complete?'active':''}"><b>3</b><span><strong>Türcode bilden</strong><small>Ohne Leerzeichen zusammensetzen.</small></span></div></div><div class="esc-puzzles">${r.puzzles.map((p,i)=>`<button class="esc-puzzle ${answers.has(p.puzzleId)?'done':''}" data-puzzle="${p.puzzleId}"><span class="esc-eyebrow">RÄTSEL ${i+1} · ${esc(p.type)}</span><h3>${esc(p.prompt)}</h3><div class="status">${answers.has(p.puzzleId)?'✓ FRAGMENT GESICHERT':'ANKLICKEN UND LÖSEN →'}</div></button>`).join('')}</div><div class="esc-inventory ${complete?'complete':''}"><span class="esc-eyebrow">2 · FRAGMENTE VON LINKS NACH RECHTS</span><div class="esc-fragments">${r.puzzles.map((p,i)=>`<span class="esc-fragment"><small>${i+1}</small>${fragments.has(p.puzzleId)?esc(fragments.get(p.puzzleId)):'•••'}</span>`).join('')}</div>${complete?'<p class="esc-ready">✓ Alle Fragmente gesichert. Bilde jetzt unten den Türcode.</p>':''}</div><div class="esc-door ${complete?'ready':''}"><div class="esc-door-title"><span class="esc-eyebrow">3 · TÜRTERMINAL</span><strong>${complete?'Schleuse bereit':'Noch nicht verfügbar'}</strong></div><label>${complete?esc(r.doorPrompt):'Löse zuerst alle Rätsel, um das Terminal freizuschalten.'}<input class="esc-input" id="doorCode" autocomplete="off" spellcheck="false" ${complete?'':'disabled'}></label>${complete?'<button class="esc-btn" id="assembleBtn">FRAGMENTE EINSETZEN</button>':''}<button class="esc-btn primary" id="doorBtn" ${complete?'':'disabled'}>CODE PRÜFEN & ÖFFNEN</button><button class="esc-btn danger" id="quitBtn">AUFGEBEN</button><div class="esc-door-feedback" id="doorFeedback" role="status" aria-live="polite"></div></div>`;view.querySelectorAll('[data-puzzle]').forEach(b=>b.onclick=()=>openPuzzle(r.puzzles.find(p=>p.puzzleId==b.dataset.puzzle)));if(complete){document.getElementById('assembleBtn').onclick=()=>{document.getElementById('doorCode').value=r.puzzles.map(p=>fragments.get(p.puzzleId)).join('');document.getElementById('doorCode').focus();doorMessage('Fragmente eingesetzt. Prüfe den Code und öffne die Schleuse.','ok');};document.getElementById('doorCode').onkeydown=e=>{if(e.key==='Enter')openDoor(r);};}document.getElementById('doorBtn').onclick=()=>openDoor(r);document.getElementById('quitBtn').onclick=()=>finish(false);}
|
||||||
|
function control(p){if(p.type==='code')return `<input class="esc-input" id="answer" placeholder="${esc(p.placeholder||'Antwort')}">`;if(p.type==='choice')return `<div class="esc-options">${p.options.map(x=>`<button class="esc-option" data-value="${esc(x)}">${esc(x)}</button>`).join('')}</div>`;if(p.type==='order')return `<div class="esc-options">${p.items.map((x,i)=>`<label>${i+1}.<select class="esc-select order">${p.items.map(y=>`<option>${esc(y)}</option>`).join('')}</select></label>`).join('')}</div>`;if(p.type==='match')return `<div class="esc-pairs">${p.left.map(x=>`<label>${esc(x)}<select class="esc-select pair" data-left="${esc(x)}">${p.right.map(y=>`<option>${esc(y)}</option>`).join('')}</select></label>`).join('')}</div>`;return'';}
|
||||||
|
function openPuzzle(p){selected=null;document.body.insertAdjacentHTML('beforeend',`<div class="esc-modal"><div class="esc-dialog"><span class="esc-eyebrow">SECURE TERMINAL · ${esc(p.type)}</span><h2>${esc(p.prompt)}</h2><p>${esc(p.narrative||p.instruction||'Löse das Zugriffsrätsel.')}</p>${p.narrative&&p.instruction?`<p>${esc(p.instruction)}</p>`:''}${control(p)}<div class="esc-feedback" id="feedback"></div><div class="esc-actions"><button class="esc-btn primary" id="checkBtn">PRÜFEN</button><button class="esc-btn" id="hintBtn">HINWEIS</button><button class="esc-btn danger" id="revealBtn">AUFLÖSEN</button><button class="esc-btn" id="closeBtn">SCHLIESSEN</button></div></div></div>`);const modal=document.querySelector('.esc-modal');modal.querySelectorAll('.esc-option').forEach(b=>b.onclick=()=>{modal.querySelectorAll('.esc-option').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');selected=b.dataset.value;});document.getElementById('closeBtn').onclick=()=>modal.remove();let hintLevel=0;document.getElementById('hintBtn').onclick=()=>post('/hint',{puzzleId:p.puzzleId,level:Math.min(2,++hintLevel)}).then(x=>feedback(x.text,'ok'));document.getElementById('checkBtn').onclick=()=>checkPuzzle(p,false,modal);document.getElementById('revealBtn').onclick=()=>checkPuzzle(p,true,modal);}
|
||||||
|
function submission(p,modal){if(p.type==='code')return{answer:modal.querySelector('#answer').value};if(p.type==='choice')return{answer:selected||''};if(p.type==='order')return{items:[...modal.querySelectorAll('.order')].map(x=>x.value)};if(p.type==='match')return{pairs:[...modal.querySelectorAll('.pair')].map(x=>[x.dataset.left,x.value])};return{};}
|
||||||
|
function checkPuzzle(p,reveal,modal){const sub=submission(p,modal);post('/check',{puzzleId:p.puzzleId,reveal,submission:sub}).then(x=>{if(x.resolved){answers.set(p.puzzleId,{puzzleId:p.puzzleId,submission:sub});fragments.set(p.puzzleId,x.fragment);feedback(reveal?'Auflösung freigegeben. Fragment gesichert.':'Zugriff bestätigt. Fragment gesichert.','ok');setTimeout(()=>{modal.remove();renderRoom();},700);}else feedback('Zugriff verweigert. Prüfe deine Eingabe.','bad');}).catch(e=>feedback(e.message,'bad'));}
|
||||||
|
function feedback(t,c){const f=document.getElementById('feedback');if(f){f.textContent=t;f.className='esc-feedback '+c;}}
|
||||||
|
function doorMessage(text,type){const box=document.getElementById('doorFeedback');if(box){box.textContent=text;box.className='esc-door-feedback '+type;}}
|
||||||
|
function openDoor(r){const input=document.getElementById('doorCode'),button=document.getElementById('doorBtn'),code=input.value.trim();input.classList.remove('bad');if(!code){input.classList.add('bad');doorMessage('Setze zuerst die Fragmente zu einem Code zusammen.','bad');input.focus();return;}button.disabled=true;button.textContent='CODE WIRD GEPRÜFT …';doorMessage('Türterminal prüft den Code …','wait');post('/door',{roomId:r.roomId,code}).then(x=>{if(!x.open){input.classList.add('bad');doorMessage(x.message||'Code abgelehnt. Prüfe die Reihenfolge der Fragmente.','bad');button.disabled=false;button.textContent='CODE PRÜFEN & ÖFFNEN';input.focus();return;}doorMessage('✓ '+(x.successText||'Schleuse geöffnet.'),'ok');setTimeout(()=>{if(roomIndex<session.rooms.length-1){roomIndex++;renderRoom();}else finish(true);},1100);}).catch(e=>{doorMessage(e.message||'Das Türterminal ist nicht erreichbar.','bad');button.disabled=false;button.textContent='ERNEUT VERSUCHEN';});}
|
||||||
|
function finish(escaped){clearInterval(tickId);post('/finish',{slug:session.slug,durationSeconds:seconds(),escaped,items:[...answers.values()],doors:[]}).then(renderResult).catch(error);}
|
||||||
|
function renderResult(r){timerEl.textContent='ESCAPED';view.innerHTML=`<section class="esc-result"><span class="esc-eyebrow">FACILITY RUN ABGESCHLOSSEN</span><h1>${r.escaped?'AUSGANG ERREICHT':'RUN BEENDET'}</h1><div class="score">${r.score}</div><p>${'★'.repeat(r.stars)}${'☆'.repeat(3-r.stars)}</p><div class="esc-stats"><div><b>${r.baseScore}</b>Basis</div><div><b>+${r.timeBonus}</b>Zeitbonus</div><div><b>${r.solvedPuzzles}</b>Rätsel</div><div><b>${r.roomsOpened}</b>Räume</div><div><b>${r.highscore}</b>Highscore</div><div><b>${r.plays}</b>Runs</div></div><button class="esc-btn primary" id="again">NEUER RUN</button></section>`;document.getElementById('again').onclick=loadCatalog;}
|
||||||
|
function error(e){view.innerHTML=`<div class="esc-result"><h1>SYSTEMFEHLER</h1><p>${esc(e.message||e)}</p><button class="esc-btn" onclick="location.reload()">NEU LADEN</button></div>`;}loadCatalog();})();
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/* FluxTimer – lokaler Pausen-Timer ohne Server- oder Drittdienst. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.MindfluxTimer) return;
|
||||||
|
|
||||||
|
var KEY = 'fluxtimer';
|
||||||
|
var MIN_INTERVAL = 5;
|
||||||
|
var MAX_INTERVAL = 240;
|
||||||
|
var DEFAULTS = { enabled:false, intervalMinutes:45, reminders:['aufstehen','trinken','augenpause'], customText:'', sound:false, notify:false, nextFireAt:null, lastFiredAt:null, reminderIndex:0 };
|
||||||
|
var LABELS = {
|
||||||
|
aufstehen:'88 mph erreicht – kurz aufstehen, McFly!',
|
||||||
|
trinken:'Großer Scott! Zeit, etwas zu trinken.',
|
||||||
|
essen:'Dein Fluxkompensator braucht einen Snack.',
|
||||||
|
augenpause:'Blick in die Zukunft: Gönn deinen Augen eine Pause.'
|
||||||
|
};
|
||||||
|
var state = load();
|
||||||
|
var ringing = false;
|
||||||
|
var intervalId;
|
||||||
|
|
||||||
|
function number(value, fallback) { var n = Number(value); return Number.isFinite(n) ? n : fallback; }
|
||||||
|
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
|
||||||
|
function normalize(raw) {
|
||||||
|
raw = raw && typeof raw === 'object' ? raw : {};
|
||||||
|
var reminders = Array.isArray(raw.reminders) ? raw.reminders.filter(function (x) { return Object.prototype.hasOwnProperty.call(LABELS, x); }) : DEFAULTS.reminders.slice();
|
||||||
|
return {
|
||||||
|
enabled: raw.enabled === true,
|
||||||
|
intervalMinutes: clamp(Math.round(number(raw.intervalMinutes, 45)), MIN_INTERVAL, MAX_INTERVAL),
|
||||||
|
reminders: reminders,
|
||||||
|
customText: typeof raw.customText === 'string' ? raw.customText.trim().slice(0,120) : '',
|
||||||
|
sound: raw.sound === true,
|
||||||
|
notify: raw.notify === true,
|
||||||
|
nextFireAt: Number.isFinite(Number(raw.nextFireAt)) ? Number(raw.nextFireAt) : null,
|
||||||
|
lastFiredAt: Number.isFinite(Number(raw.lastFiredAt)) ? Number(raw.lastFiredAt) : null,
|
||||||
|
reminderIndex: Math.max(0, Math.round(number(raw.reminderIndex, 0)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function load() { try { return normalize(JSON.parse(localStorage.getItem(KEY) || '{}')); } catch (e) { return normalize({}); } }
|
||||||
|
function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} syncUi(); }
|
||||||
|
function schedule(from) { state.nextFireAt = (from || Date.now()) + state.intervalMinutes * 60000; ringing = false; save(); }
|
||||||
|
function setEnabled(enabled) { state.enabled = !!enabled; if (state.enabled) schedule(Date.now()); else { state.nextFireAt = null; ringing = false; closeOverlay(); save(); } }
|
||||||
|
function remainingMs(now) { return state.enabled && state.nextFireAt ? Math.max(0, state.nextFireAt - (now || Date.now())) : 0; }
|
||||||
|
function pad(n) { return String(n).padStart(2,'0'); }
|
||||||
|
function formatDuration(ms) { var seconds=Math.max(0,Math.ceil(ms/1000)); return pad(Math.floor(seconds/3600))+':'+pad(Math.floor((seconds%3600)/60))+':'+pad(seconds%60); }
|
||||||
|
function formatClock(ts) { if (!ts) return '--:--:--'; return new Date(ts).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'}); }
|
||||||
|
function reminderText() {
|
||||||
|
var pool = state.reminders.slice(); if (state.customText) pool.push('custom');
|
||||||
|
if (!pool.length) return 'Zeit für eine kurze Pause.';
|
||||||
|
var key = pool[state.reminderIndex % pool.length];
|
||||||
|
state.reminderIndex = (state.reminderIndex + 1) % pool.length;
|
||||||
|
return key === 'custom' ? state.customText : LABELS[key];
|
||||||
|
}
|
||||||
|
function ensureUi() {
|
||||||
|
if (!document.querySelector('.ft-popover')) {
|
||||||
|
var pop=document.createElement('section'); pop.className='ft-popover'; pop.setAttribute('aria-label','FluxTimer');
|
||||||
|
pop.innerHTML='<div class="ft-popover-head"><b>FLUXTIMER</b><button class="ft-close" type="button" data-ft-close aria-label="Schließen">×</button></div><div class="ft-circuit"><div class="ft-row primary"><span>NÄCHSTE PAUSE</span><strong data-ft-countdown>--:--:--</strong></div><div class="ft-row"><span>JETZT</span><strong data-ft-now>--:--:--</strong></div><div class="ft-row"><span>LETZTE PAUSE</span><strong data-ft-last>--:--:--</strong></div></div><p class="ft-popover-status" data-ft-status></p><div class="ft-popover-actions"><button class="ft-mini-toggle" type="button" data-ft-quick-toggle></button><a class="mf-btn outline" href="/profile/settings">Einstellungen</a></div>';
|
||||||
|
document.body.appendChild(pop);
|
||||||
|
}
|
||||||
|
if (!document.querySelector('.ft-overlay')) {
|
||||||
|
var overlay=document.createElement('div'); overlay.className='ft-overlay'; overlay.setAttribute('role','dialog'); overlay.setAttribute('aria-modal','true'); overlay.setAttribute('aria-labelledby','ft-title');
|
||||||
|
overlay.innerHTML='<div class="ft-modal"><span class="ft-kicker">ZEITSCHALTKREISE AKTIV</span><h2 id="ft-title">Zeit für eine Pause</h2><p data-ft-message></p><div class="ft-modal-actions"><button class="mf-btn outline" type="button" data-ft-snooze>+5 Minuten</button><button class="mf-btn" type="button" data-ft-done>Pause erledigt</button></div></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function positionPopover() {
|
||||||
|
var trigger=document.querySelector('[data-fluxtimer-toggle]'), pop=document.querySelector('.ft-popover'); if (!trigger || !pop) return;
|
||||||
|
var r=trigger.getBoundingClientRect(); pop.style.top=(r.bottom+8)+'px'; pop.style.left=Math.max(12,Math.min(window.innerWidth-pop.offsetWidth-12,r.right-pop.offsetWidth))+'px';
|
||||||
|
}
|
||||||
|
function togglePopover() { var pop=document.querySelector('.ft-popover'); if (!pop) return; pop.classList.toggle('open'); var open=pop.classList.contains('open'); var trigger=document.querySelector('[data-fluxtimer-toggle]'); if(trigger) trigger.setAttribute('aria-expanded',String(open)); if(open){positionPopover();syncUi();} }
|
||||||
|
function closePopover() { var pop=document.querySelector('.ft-popover'); if(pop) pop.classList.remove('open'); var trigger=document.querySelector('[data-fluxtimer-toggle]'); if(trigger) trigger.setAttribute('aria-expanded','false'); }
|
||||||
|
function closeOverlay() { var overlay=document.querySelector('.ft-overlay'); if(overlay) overlay.classList.remove('open'); }
|
||||||
|
function playSound() { if (!state.sound) return; try { var C=window.AudioContext||window.webkitAudioContext; if(!C)return; var ctx=new C(),o=ctx.createOscillator(),g=ctx.createGain(); o.frequency.value=523.25; g.gain.setValueAtTime(.05,ctx.currentTime); g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+.35); o.connect(g);g.connect(ctx.destination);o.start();o.stop(ctx.currentTime+.35); } catch(e){} }
|
||||||
|
function notify(text) { if (!state.notify || !('Notification' in window) || Notification.permission!=='granted') return; try { new Notification('Mindflux FluxTimer',{body:text,tag:'mindflux-fluxtimer'}); } catch(e){} }
|
||||||
|
function fire(testOnly) { ensureUi(); if(ringing&&!testOnly)return; ringing=true; var text=reminderText(); state.lastFiredAt=Date.now(); save(); var msg=document.querySelector('[data-ft-message]'); if(msg)msg.textContent=text; document.querySelector('.ft-overlay').classList.add('open'); playSound(); notify(text); }
|
||||||
|
function done() { closeOverlay(); schedule(Date.now()); }
|
||||||
|
function snooze() { closeOverlay(); state.nextFireAt=Date.now()+5*60000; ringing=false; save(); }
|
||||||
|
function tick() { var now=Date.now(); if(state.enabled&&state.nextFireAt&&now>=state.nextFireAt)fire(false); syncUi(now); }
|
||||||
|
function syncUi(now) {
|
||||||
|
now=now||Date.now(); var countdown=state.enabled?formatDuration(remainingMs(now)):'PAUSIERT';
|
||||||
|
document.querySelectorAll('[data-ft-countdown]').forEach(function(el){el.textContent=countdown;});
|
||||||
|
document.querySelectorAll('[data-ft-now]').forEach(function(el){el.textContent=formatClock(now);});
|
||||||
|
document.querySelectorAll('[data-ft-last]').forEach(function(el){el.textContent=formatClock(state.lastFiredAt);});
|
||||||
|
document.querySelectorAll('[data-ft-status]').forEach(function(el){el.textContent=state.enabled?'Aktiv · '+state.intervalMinutes+' Minuten':'Der Timer ist deaktiviert.';});
|
||||||
|
document.querySelectorAll('[data-ft-quick-toggle]').forEach(function(el){el.textContent=state.enabled?'Timer pausieren':'Timer aktivieren';});
|
||||||
|
document.querySelectorAll('[data-fluxtimer-toggle]').forEach(function(el){el.classList.toggle('ft-active',state.enabled);});
|
||||||
|
var fields=document.querySelector('[data-fluxtimer-settings]'); if(fields){
|
||||||
|
var enabled=fields.querySelector('[data-ft-field="enabled"]'); if(enabled&&document.activeElement!==enabled)enabled.checked=state.enabled;
|
||||||
|
var interval=fields.querySelector('[data-ft-field="intervalMinutes"]'); if(interval&&document.activeElement!==interval)interval.value=state.intervalMinutes;
|
||||||
|
var custom=fields.querySelector('[data-ft-field="customText"]'); if(custom&&document.activeElement!==custom)custom.value=state.customText;
|
||||||
|
['sound','notify'].forEach(function(k){var el=fields.querySelector('[data-ft-field="'+k+'"]');if(el&&document.activeElement!==el)el.checked=state[k];});
|
||||||
|
fields.querySelectorAll('[data-ft-reminder]').forEach(function(el){if(document.activeElement!==el)el.checked=state.reminders.indexOf(el.value)>=0;});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function readSettings(container) {
|
||||||
|
var interval=container.querySelector('[data-ft-field="intervalMinutes"]'), previousInterval=state.intervalMinutes; state.intervalMinutes=clamp(Math.round(number(interval.value,45)),MIN_INTERVAL,MAX_INTERVAL); interval.value=state.intervalMinutes;
|
||||||
|
state.customText=container.querySelector('[data-ft-field="customText"]').value.trim().slice(0,120);
|
||||||
|
state.sound=container.querySelector('[data-ft-field="sound"]').checked;
|
||||||
|
state.notify=container.querySelector('[data-ft-field="notify"]').checked;
|
||||||
|
state.reminders=Array.prototype.map.call(container.querySelectorAll('[data-ft-reminder]:checked'),function(el){return el.value;});
|
||||||
|
if(state.enabled&&previousInterval!==state.intervalMinutes)schedule(Date.now());else save();
|
||||||
|
var status=container.querySelector('[data-ft-save-status]');if(status){status.textContent='Auf diesem Gerät gespeichert.';setTimeout(function(){status.textContent='Wird nur auf diesem Gerät gespeichert.';},1800);}
|
||||||
|
}
|
||||||
|
function init() {
|
||||||
|
if(location.pathname==='/'||location.pathname==='/login'||location.pathname==='/register')return;
|
||||||
|
ensureUi();
|
||||||
|
if(state.enabled&&!state.nextFireAt)schedule(Date.now());
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
if(e.target.closest('[data-fluxtimer-toggle]')){togglePopover();return;}
|
||||||
|
if(e.target.closest('[data-ft-close]')){closePopover();return;}
|
||||||
|
if(e.target.closest('[data-ft-quick-toggle]')){setEnabled(!state.enabled);return;}
|
||||||
|
if(e.target.closest('[data-ft-done]')){done();return;}
|
||||||
|
if(e.target.closest('[data-ft-snooze]')){snooze();return;}
|
||||||
|
if(e.target.closest('[data-ft-test]')){fire(true);return;}
|
||||||
|
var pop=e.target.closest('.ft-popover'); if(!pop&&!e.target.closest('[data-fluxtimer-toggle]'))closePopover();
|
||||||
|
});
|
||||||
|
var settings=document.querySelector('[data-fluxtimer-settings]'); if(settings){
|
||||||
|
settings.addEventListener('change',function(e){
|
||||||
|
if(e.target.matches('[data-ft-field="enabled"]')){setEnabled(e.target.checked);return;}
|
||||||
|
if(e.target.matches('[data-ft-field="notify"]')&&e.target.checked&&'Notification' in window&&Notification.permission==='default') Notification.requestPermission().then(function(p){if(p!=='granted'){e.target.checked=false;state.notify=false;save();}});
|
||||||
|
readSettings(settings);
|
||||||
|
});
|
||||||
|
settings.addEventListener('input',function(e){if(e.target.matches('[data-ft-field="customText"],[data-ft-field="intervalMinutes"]'))readSettings(settings);});
|
||||||
|
}
|
||||||
|
window.addEventListener('storage',function(e){if(e.key===KEY){state=load();syncUi();}});
|
||||||
|
window.addEventListener('resize',positionPopover);
|
||||||
|
intervalId=window.setInterval(tick,1000); tick();
|
||||||
|
}
|
||||||
|
window.MindfluxTimer={getState:function(){return JSON.parse(JSON.stringify(state));},setEnabled:setEnabled,remainingMs:remainingMs,formatDuration:formatDuration,test:function(){fire(true);},reset:function(){state=normalize({});save();}};
|
||||||
|
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
|
||||||
|
})();
|
||||||
@@ -8,6 +8,42 @@
|
|||||||
|
|
||||||
apply(read()); // früh -> kein Aufblitzen
|
apply(read()); // früh -> kein Aufblitzen
|
||||||
|
|
||||||
|
// Globale, rein clientseitige Komfortfunktionen. FluxTimer bleibt auf öffentlichen
|
||||||
|
// Seiten inaktiv, läuft bei aktivierter Einstellung aber über Seitennavigationen weiter.
|
||||||
|
if (!document.querySelector('link[data-fluxtimer]')) {
|
||||||
|
var timerCss = document.createElement('link');
|
||||||
|
timerCss.rel = 'stylesheet';
|
||||||
|
timerCss.href = '/css/fluxtimer.css?v=3';
|
||||||
|
timerCss.setAttribute('data-fluxtimer', '');
|
||||||
|
document.head.appendChild(timerCss);
|
||||||
|
}
|
||||||
|
if (!document.querySelector('script[data-fluxtimer]')) {
|
||||||
|
var timerScript = document.createElement('script');
|
||||||
|
timerScript.src = '/js/fluxtimer.js?v=1';
|
||||||
|
timerScript.defer = true;
|
||||||
|
timerScript.setAttribute('data-fluxtimer', '');
|
||||||
|
document.head.appendChild(timerScript);
|
||||||
|
}
|
||||||
|
if (!document.querySelector('link[data-accessibility]')) {
|
||||||
|
var accessibilityCss = document.createElement('link');
|
||||||
|
accessibilityCss.rel = 'stylesheet';
|
||||||
|
accessibilityCss.href = '/css/accessibility.css?v=3';
|
||||||
|
accessibilityCss.setAttribute('data-accessibility', '');
|
||||||
|
document.head.appendChild(accessibilityCss);
|
||||||
|
var accessibilityPlacementCss = document.createElement('link');
|
||||||
|
accessibilityPlacementCss.rel = 'stylesheet';
|
||||||
|
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=2';
|
||||||
|
accessibilityPlacementCss.setAttribute('data-accessibility', '');
|
||||||
|
document.head.appendChild(accessibilityPlacementCss);
|
||||||
|
}
|
||||||
|
if (!document.querySelector('script[data-accessibility]')) {
|
||||||
|
var accessibilityScript = document.createElement('script');
|
||||||
|
accessibilityScript.src = '/js/accessibility.js?v=4';
|
||||||
|
accessibilityScript.defer = true;
|
||||||
|
accessibilityScript.setAttribute('data-accessibility', '');
|
||||||
|
document.head.appendChild(accessibilityScript);
|
||||||
|
}
|
||||||
|
|
||||||
var SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
var SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||||
var MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
var MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>CodeEscape Raum</title><link rel="stylesheet" href="/css/theme.css"><script src="/js/theme.js"></script></head><body><main class="mf-container"><a class="mf-back" th:href="@{/admin/codeescape/{id}(id=${room.scenario.id})}">← Szenario</a><h1 th:text="${room.title}" style="margin:1rem 0;color:var(--heading)"></h1><form class="mf-panel" method="post" th:action="@{/admin/codeescape/rooms/{id}/puzzles(id=${room.id})}"><h2>Rätsel hinzufügen</h2><div class="mf-field"><label class="mf-label">Typ</label><select class="mf-select" name="type"><option>code</option><option>choice</option><option>order</option><option>match</option></select></div><div class="mf-field"><label class="mf-label">Titel</label><input class="mf-input" name="prompt" required></div><div class="mf-field"><label class="mf-label">Punkte</label><input class="mf-input" type="number" name="points" value="100"></div><div class="mf-field"><label class="mf-label">Fragment</label><input class="mf-input" name="codeFragment" required></div><div class="mf-field"><label class="mf-label">Payload JSON</label><textarea class="mf-input" rows="10" name="payload" required>{"instruction":"...","solutions":["..."]}</textarea></div><button class="mf-btn">Rätsel anlegen</button></form><table class="mf-table" style="margin-top:1rem"><tr><th>Typ</th><th>Titel</th><th>Fragment</th><th></th></tr><tr th:each="p:${room.puzzles}"><td th:text="${p.type}"></td><td th:text="${p.prompt}"></td><td th:text="${p.codeFragment}"></td><td><form method="post" th:action="@{/admin/codeescape/puzzles/{id}/delete(id=${p.id})}"><input type="hidden" name="roomId" th:value="${room.id}"><button class="mf-btn danger">Löschen</button></form></td></tr></table></main></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>CodeEscape Szenario</title><link rel="stylesheet" href="/css/theme.css"><script src="/js/theme.js"></script></head><body><main class="mf-container"><a class="mf-back" href="/admin/codeescape">← Szenarien</a><h1 th:text="${scenario.title}" style="margin:1rem 0;color:var(--heading)"></h1><form class="mf-panel" method="post" th:action="@{/admin/codeescape/{id}/rooms(id=${scenario.id})}"><h2>Raum hinzufügen</h2><div class="mf-field"><label class="mf-label">Titel</label><input class="mf-input" name="title" required></div><div class="mf-field"><label class="mf-label">Intro</label><textarea class="mf-input" name="intro"></textarea></div><div class="mf-field"><label class="mf-label">Tür-Hinweis</label><input class="mf-input" name="doorPrompt"></div><div class="mf-field"><label class="mf-label">Türcode</label><input class="mf-input" name="doorCode" required></div><div class="mf-field"><label class="mf-label">Erfolgstext</label><input class="mf-input" name="successText"></div><button class="mf-btn">Raum anlegen</button></form><div class="mf-grid" style="margin-top:1rem"><article class="mf-panel" th:each="r:${scenario.rooms}"><h2><a th:href="@{/admin/codeescape/rooms/{id}(id=${r.id})}" th:text="${r.title}"></a></h2><p th:text="${r.intro}"></p><form method="post" th:action="@{/admin/codeescape/rooms/{id}/delete(id=${r.id})}"><button class="mf-btn danger">Raum löschen</button></form></article></div></main></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>Admin · CodeEscape</title><link rel="stylesheet" href="/css/theme.css"><script src="/js/theme.js"></script></head><body><main class="mf-container"><a class="mf-back" href="/admin">← Admin</a><h1 style="margin:1rem 0;color:var(--heading)">CodeEscape · Szenarien</h1><form class="mf-panel" method="post" action="/admin/codeescape/add"><div class="mf-field"><label class="mf-label">Titel</label><input class="mf-input" name="title" required></div><div class="mf-field"><label class="mf-label">Schwierigkeit</label><select class="mf-select" name="difficulty"><option>leicht</option><option>mittel</option><option>schwer</option></select></div><div class="mf-field"><label class="mf-label">Prüfungsbereich</label><select class="mf-select" name="examScope"><option>AP1</option><option>AP2_FIAE</option><option>AP2_FISI</option><option>CROSS</option></select></div><div class="mf-field"><label class="mf-label">Beschreibung</label><textarea class="mf-input" name="description"></textarea></div><div class="mf-field"><label class="mf-label">Zeitlimit Sekunden</label><input class="mf-input" type="number" name="timeLimitSeconds" value="900"></div><button class="mf-btn">Szenario anlegen</button></form><table class="mf-table" style="margin-top:1rem"><tr><th>Titel</th><th>Scope</th><th>Status</th><th>Aktion</th></tr><tr th:each="s:${scenarios}"><td><a th:href="@{/admin/codeescape/{id}(id=${s.id})}" th:text="${s.title}"></a></td><td th:text="${s.examScope}"></td><td th:text="${s.active?'aktiv':'inaktiv'}"></td><td><form style="display:inline" method="post" th:action="@{/admin/codeescape/{id}/toggle(id=${s.id})}"><button class="mf-btn outline">Toggle</button></form> <form style="display:inline" method="post" th:action="@{/admin/codeescape/{id}/delete(id=${s.id})}"><button class="mf-btn danger">Löschen</button></form></td></tr></table></main></body></html>
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
<a th:href="@{/admin/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a>
|
<a th:href="@{/admin/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a>
|
||||||
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
|
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
|
||||||
<a th:href="@{/admin/devlab}" class="admin-card">⚗️ Dev-Lab-Aufgaben verwalten</a>
|
<a th:href="@{/admin/devlab}" class="admin-card">⚗️ Dev-Lab-Aufgaben verwalten</a>
|
||||||
|
<a th:href="@{/admin/codeescape}" class="admin-card">🚪 SystemEscape-Szenarien verwalten</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin-top:1.1rem; color:var(--muted);">
|
<p style="margin-top:1.1rem; color:var(--muted);">
|
||||||
Hier entsteht noch Großes... ✨ Bleib dran!
|
Hier entsteht noch Großes... ✨ Bleib dran!
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SystemEscape · Mindflux</title><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/codeescape.css?v=2"><link rel="stylesheet" href="/css/codeescape-guide.css?v=3"><link rel="stylesheet" href="/css/codeescape-global.css?v=1"><script src="/js/theme.js"></script></head><body><main class="esc-app"><header class="esc-top"><a href="/dashboard" class="esc-back">← Dashboard</a><div class="esc-brand"><b>SYSTEMESCAPE</b><span>FACILITY ACCESS PROTOCOL</span></div><div class="esc-global-tools"><span class="mf-a11y-host"></span><div class="esc-timer" id="escTimer">--:--</div></div></header><section id="escView" class="esc-view"><div class="esc-loading">FACILITY WIRD INITIALISIERT …</div></section></main><script src="/js/codeescape.js?v=2"></script></body></html>
|
||||||
@@ -41,18 +41,23 @@
|
|||||||
<!-- ===== Topbar ===== -->
|
<!-- ===== Topbar ===== -->
|
||||||
<header class="mf-topbar">
|
<header class="mf-topbar">
|
||||||
<div class="mf-hello">
|
<div class="mf-hello">
|
||||||
<h1>Willkommen zurück, <span class="nm" sec:authentication="name">Alice</span>! 👋</h1>
|
<h1>Willkommen zurück, <span class="nm" sec:authentication="name">Alice</span>!</h1>
|
||||||
<p>Wähle ein Spielmodus, um dein Wissen auf die Probe zu stellen.</p>
|
<p>Wähle ein Spielmodus, um dein Wissen auf die Probe zu stellen.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mf-pulse">
|
|
||||||
<svg viewBox="0 0 200 50" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M2 26h40l8-18 10 34 9-26 7 14h26l6-12 9 22 7-24h59"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="mf-tools">
|
<div class="mf-tools">
|
||||||
<button class="mf-iconbtn" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>
|
<button class="mf-iconbtn ft-trigger" type="button" data-fluxtimer-toggle aria-label="FluxTimer öffnen" aria-expanded="false">
|
||||||
|
<svg class="ft-trigger-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="10.5" cy="12" r="7.5"/>
|
||||||
|
<path d="M10.5 7.5V12l3 2M10.5 2v2M3 12H1.5M4.3 5.8 2.8 4.3"/>
|
||||||
|
<path class="ft-trigger-bolt" d="M17 8.5h4l-2.5 4H22l-6.2 8 1.7-5H14z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ft-trigger-dot" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<button class="mf-iconbtn" type="button" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>
|
||||||
<div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div>
|
<div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div>
|
||||||
<button class="mf-theme"></button>
|
<button class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -130,14 +135,14 @@
|
|||||||
['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'],
|
['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'],
|
||||||
['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'],
|
['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'],
|
||||||
['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
|
['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
|
||||||
['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
|
['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/codeescape'],
|
||||||
['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
|
['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
|
||||||
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
|
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
|
||||||
['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
|
['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
|
||||||
['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
|
['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
|
||||||
];
|
];
|
||||||
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
|
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
|
||||||
<a class="mf-card" href="${href}">
|
<a class="mf-card ${t === 'SystemEscape' ? 'mf-card-codeescape' : ''}" href="${href}">
|
||||||
<span class="ico"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${GI[ic]}</svg></span>
|
<span class="ico"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${GI[ic]}</svg></span>
|
||||||
<h3>${t}</h3><p>${d}</p>
|
<h3>${t}</h3><p>${d}</p>
|
||||||
<span class="mf-play">SPIEL STARTEN <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
<span class="mf-play">SPIEL STARTEN <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Dev Lab Spiel – Mindflux</title>
|
<title>Dev Lab Spiel – Mindflux</title>
|
||||||
<link rel="stylesheet" href="/css/theme.css">
|
<link rel="stylesheet" href="/css/theme.css">
|
||||||
<script src="/js/theme.js"></script>
|
<script src="/js/theme.js"></script>
|
||||||
<link rel="stylesheet" href="/css/devlabgame.css?v=3">
|
<link rel="stylesheet" href="/css/devlabgame.css?v=4">
|
||||||
</head>
|
</head>
|
||||||
<body class="dlg-body" th:attr="data-game=${gameSlug}">
|
<body class="dlg-body" th:attr="data-game=${gameSlug}">
|
||||||
<div class="dlg-shell">
|
<div class="dlg-shell">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="dlg-header-tools">
|
<div class="dlg-header-tools">
|
||||||
<button type="button" class="mf-theme"></button>
|
<button type="button" class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="dlg-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<div class="mf-tools">
|
<div class="mf-tools">
|
||||||
<button type="button" class="mf-theme"></button>
|
<button type="button" class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<label class="mf-label" for="login-password">Passwort</label>
|
<label class="mf-label" for="login-password">Passwort</label>
|
||||||
<input id="login-password" class="mf-input" type="password" name="password" placeholder="Passwort" required>
|
<input id="login-password" class="mf-input" type="password" name="password" placeholder="Passwort" required>
|
||||||
</div>
|
</div>
|
||||||
<button class="neon-btn" type="submit"><span class="lock">🔒</span> Login</button>
|
<button class="neon-btn" type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mobile-switch">Noch keinen Account? <a data-goto="signup">Jetzt registrieren</a></p>
|
<p class="mobile-switch">Noch keinen Account? <a data-goto="signup">Jetzt registrieren</a></p>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Datenschutzerklärung</h1>
|
<h1>Datenschutzerklärung</h1>
|
||||||
<p class="stand">Stand: Juni 2026</p>
|
<p class="stand">Stand: Juli 2026</p>
|
||||||
|
|
||||||
<div class="dev-note">
|
<div class="dev-note">
|
||||||
Hinweis: Mindflux befindet sich in aktiver Entwicklung und ist derzeit nicht für die öffentliche
|
Hinweis: Mindflux befindet sich in aktiver Entwicklung und ist derzeit nicht für die öffentliche
|
||||||
@@ -87,6 +87,18 @@
|
|||||||
im Rahmen von Tests zurückgesetzt werden.
|
im Rahmen von Tests zurückgesetzt werden.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>6a. Lokale Einstellungen, Barrierefreiheit und FluxTimer</h2>
|
||||||
|
<p>
|
||||||
|
Theme-Auswahl, Barrierefreiheitsoptionen und FluxTimer-Einstellungen werden ausschließlich im
|
||||||
|
lokalen Browserspeicher (<code>localStorage</code>) des verwendeten Geräts abgelegt. Dazu gehören
|
||||||
|
gewählte Textgröße, Kontrast-, Farb-, Schrift-, Bewegungs- und Cursorhilfen sowie Aktivierungsstatus,
|
||||||
|
Intervall, ausgewählte Erinnerungsarten, optionaler eigener Erinnerungstext sowie der nächste
|
||||||
|
und letzte Auslösezeitpunkt. Diese Angaben werden nicht an den Mindflux-Server übertragen und
|
||||||
|
können durch Löschen der Website-Daten im Browser entfernt werden. Browser-Benachrichtigungen
|
||||||
|
werden nur nach ausdrücklicher Freigabe lokal erzeugt; es gibt keinen Web-Push- oder externen
|
||||||
|
Benachrichtigungsdienst.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>7. Empfänger und Hosting</h2>
|
<h2>7. Empfänger und Hosting</h2>
|
||||||
<p>
|
<p>
|
||||||
Die Daten werden auf einer vom Anbieter betriebenen Server-Infrastruktur verarbeitet und nicht zu
|
Die Daten werden auf einer vom Anbieter betriebenen Server-Infrastruktur verarbeitet und nicht zu
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="mm-header-tools">
|
<div class="mm-header-tools">
|
||||||
<button type="button" class="mf-theme"></button>
|
<button type="button" class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="mm-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -52,6 +52,50 @@
|
|||||||
<small class="mf-hint">Die Auswahl gilt sofort und wird auf diesem Gerät gespeichert.</small>
|
<small class="mf-hint">Die Auswahl gilt sofort und wird auf diesem Gerät gespeichert.</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- FluxTimer: vollständig lokal im Browser, keine Serverdaten -->
|
||||||
|
<section class="mf-panel ft-settings" style="margin-bottom:1.2rem;" data-fluxtimer-settings>
|
||||||
|
<div class="ft-settings-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="mf-section-title">⏱ FluxTimer</h2>
|
||||||
|
<p>Erinnert dich während des Lernens ans Aufstehen, Trinken oder Durchatmen.</p>
|
||||||
|
</div>
|
||||||
|
<label class="ft-switch">
|
||||||
|
<input id="ft-enabled" type="checkbox" data-ft-field="enabled">
|
||||||
|
<span aria-hidden="true"></span><b>Aktiv</b>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ft-settings-grid">
|
||||||
|
<div class="mf-field">
|
||||||
|
<label class="mf-label" for="ft-interval">Intervall in Minuten</label>
|
||||||
|
<input class="mf-input" id="ft-interval" type="number" min="5" max="240" step="1" value="45" data-ft-field="intervalMinutes">
|
||||||
|
<small class="mf-hint">Zwischen 5 und 240 Minuten.</small>
|
||||||
|
</div>
|
||||||
|
<div class="ft-preview" aria-live="polite">
|
||||||
|
<span>NÄCHSTE PAUSE</span>
|
||||||
|
<strong data-ft-countdown>--:--:--</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fieldset class="ft-reminders">
|
||||||
|
<legend class="mf-label">Erinnerungen rotieren</legend>
|
||||||
|
<label class="mf-check"><input type="checkbox" value="aufstehen" data-ft-reminder> Aufstehen 🧍</label>
|
||||||
|
<label class="mf-check"><input type="checkbox" value="trinken" data-ft-reminder> Trinken 💧</label>
|
||||||
|
<label class="mf-check"><input type="checkbox" value="essen" data-ft-reminder> Essen 🍎</label>
|
||||||
|
<label class="mf-check"><input type="checkbox" value="augenpause" data-ft-reminder> Augenpause 👀</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="mf-field">
|
||||||
|
<label class="mf-label" for="ft-custom">Eigener Erinnerungstext</label>
|
||||||
|
<input class="mf-input" id="ft-custom" maxlength="120" placeholder="Zum Beispiel: Schultern lockern" data-ft-field="customText">
|
||||||
|
</div>
|
||||||
|
<div class="ft-options">
|
||||||
|
<label class="mf-check"><input type="checkbox" data-ft-field="notify"> Browser-Benachrichtigung</label>
|
||||||
|
<label class="mf-check"><input type="checkbox" data-ft-field="sound"> Dezenter Signalton</label>
|
||||||
|
</div>
|
||||||
|
<div class="ft-settings-actions">
|
||||||
|
<button class="mf-btn outline" type="button" data-ft-test>Probelauf</button>
|
||||||
|
<small class="mf-hint" data-ft-save-status>Wird nur auf diesem Gerät gespeichert.</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Passwort ändern -->
|
<!-- Passwort ändern -->
|
||||||
<form th:action="@{/profile/change-password}" method="post" class="mf-panel" style="margin-bottom:1.2rem;">
|
<form th:action="@{/profile/change-password}" method="post" class="mf-panel" style="margin-bottom:1.2rem;">
|
||||||
<h2 class="mf-section-title">🔒 Passwort ändern</h2>
|
<h2 class="mf-section-title">🔒 Passwort ändern</h2>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="ss-header-tools">
|
<div class="ss-header-tools">
|
||||||
<button type="button" class="mf-theme"></button>
|
<button type="button" class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="ss-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="tt-header-tools">
|
<div class="tt-header-tools">
|
||||||
<button type="button" class="mf-theme"></button>
|
<button type="button" class="mf-theme"></button>
|
||||||
<form th:action="@{/logout}" method="post" style="display:inline">
|
<form th:action="@{/logout}" method="post" style="display:inline">
|
||||||
<button type="submit" class="tt-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
|
<button type="submit" class="mf-iconbtn" aria-label="Abmelden" title="Abmelden" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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 org.junit.jupiter.api.*;import org.springframework.mock.web.MockHttpSession;import java.util.*;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;
|
||||||
|
class CodeEscapeServiceTest {
|
||||||
|
EscapeScenarioRepository scenarios=mock(EscapeScenarioRepository.class);EscapeRoomRepository rooms=mock(EscapeRoomRepository.class);EscapePuzzleRepository puzzles=mock(EscapePuzzleRepository.class);GameScoreService scores=mock(GameScoreService.class);ObjectMapper mapper=new ObjectMapper();CodeEscapeService service;EscapeScenario scenario;EscapeRoom room;EscapePuzzle puzzle;
|
||||||
|
@BeforeEach void setup(){service=new CodeEscapeService(scenarios,rooms,puzzles,scores,mapper);scenario=new EscapeScenario("test","Test","leicht","AP1","desc",900,0);scenario.setId(1L);room=new EscapeRoom("Raum","Intro","Code?","41","Offen",0);room.setId(2L);puzzle=new EscapePuzzle("code","Binär",100,"41",0,"{\"solutions\":[\"41\"],\"hints\":[\"h1\",\"h2\"],\"explanation\":\"secret\"}");puzzle.setId(3L);room.addPuzzle(puzzle);scenario.addRoom(room);when(scenarios.findBySlug("test")).thenReturn(Optional.of(scenario));when(puzzles.findById(3L)).thenReturn(Optional.of(puzzle));when(rooms.findById(2L)).thenReturn(Optional.of(room));when(scores.recordScore(anyString(),anyString(),anyString(),anyInt(),anyInt(),anyInt())).thenAnswer(i->new ScoreResult(i.getArgument(3),i.getArgument(3),true,1));}
|
||||||
|
@Test void sessionLeaksNoSolutionsHintsFragmentsOrDoorCodes(){JsonNode json=service.session("test",new MockHttpSession());String raw=json.toString();assertFalse(raw.contains("solutions"));assertFalse(raw.contains("hints"));assertFalse(raw.contains("secret"));assertFalse(raw.contains("\"41\""));}
|
||||||
|
@Test void sessionSelectsFourRoomsAndPinsThemToTheRun(){for(long i=4;i<=7;i++){EscapeRoom extra=new EscapeRoom("R"+i,"I","P","X","S",(int)i);extra.setId(i);EscapePuzzle p=new EscapePuzzle("code","P",10,"X",0,"{\"solutions\":[\"X\"]}");p.setId(i+10);extra.addPuzzle(p);scenario.addRoom(extra);}MockHttpSession http=new MockHttpSession();JsonNode json=service.session("test",http);assertEquals(4,json.path("rooms").size());CodeEscapeService.RunState state=(CodeEscapeService.RunState)http.getAttribute("codeescape.run");assertEquals(4,state.roomIds.size());assertEquals(4,state.puzzleIds.size());}
|
||||||
|
@Test void hintAndAttemptAreServerAuthoritative(){MockHttpSession session=new MockHttpSession();service.session("test",session);service.hint(3L,1,session);service.check(3L,false,mapper.createObjectNode().put("answer","41"),session);service.door(2L,"41",session);EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(450);req.setEscaped(true);EscapeFinishRequest.Item item=new EscapeFinishRequest.Item();item.setPuzzleId(3L);item.setSubmission(mapper.createObjectNode().put("answer","41"));req.setItems(List.of(item));EscapeResult result=service.finish("alice",req,session);assertEquals(130,result.baseScore());assertEquals(250,result.timeBonus());assertTrue(result.escaped());assertEquals(380,result.score());}
|
||||||
|
@Test void doorExplainsWhyItStaysClosedUntilPuzzlesAreResolved(){MockHttpSession session=new MockHttpSession();service.session("test",session);JsonNode early=service.door(2L,"41",session);assertFalse(early.path("open").asBoolean());assertFalse(early.path("complete").asBoolean());assertTrue(early.path("message").asText().contains("Löse zuerst"));service.check(3L,false,mapper.createObjectNode().put("answer","41"),session);JsonNode open=service.door(2L,"41",session);assertTrue(open.path("open").asBoolean());assertTrue(open.path("complete").asBoolean());}
|
||||||
|
@Test void finishRejectsForeignPuzzleIds(){MockHttpSession session=new MockHttpSession();service.session("test",session);EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(10);EscapeFinishRequest.Item item=new EscapeFinishRequest.Item();item.setPuzzleId(999L);item.setSubmission(mapper.createObjectNode());req.setItems(List.of(item));assertThrows(IllegalArgumentException.class,()->service.finish("alice",req,session));}
|
||||||
|
@Test void finishRejectsImplausibleDuration(){EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(0);assertThrows(IllegalArgumentException.class,()->service.finish("alice",req,new MockHttpSession()));}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user