diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cdd53..4b95c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Versionierung [semantisch](https://semver.org/lang/de/). ## [Unreleased] – 2026-07-13 · BugHunter, NetWorker & echter Lernfortschritt +### ServiceClash +- **Letztes geplantes Spiel ausgeliefert:** drei Service-Desk-Schichten mit insgesamt + **24 kuratierten Tickets** aus Incident-, Request-, Change-, Monitoring-, Backup- und + Security-Szenarien. Jede Aufgabe führt verständlich durch Art, Priorität und passende + Erstmaßnahme. +- **Echte Wertung und Fortschritt:** V15-Datenmodell, lösungsfreie Sessions und + serverseitige Prüfung bewerten jeden der drei Schritte samt Versuchsleiter. Score, + Sterne, Highscore, Dauer und Fortschritt werden gespeichert; „Leitstand-Einsatz“ und + der fehlerfreie „Incident Commander“ sind als Achievements integriert. +- **Eigenständiger Leitstand:** Cyanfarbene Queue, Wellen, Fortschrittsanzeige, + Ticket-Debriefs sowie bewusstes Weiterklicken nach einer Lösung. Die Lobby erklärt + Incident vs. Anfrage und die Priorisierung vor der ersten Schicht. +- **Integration abgeschlossen:** Dashboard-Karte öffnet `/serviceclash`, die API ist + CSRF-konform eingebunden und das Accessibility-Widget übernimmt den ServiceClash-Akzent. + ### BugHunter - **Vollständiges Fallakten-Spiel ausgeliefert:** serverautoritatives Drei-Phasen-Debugging (Orten → Klassifizieren → Fixen), Score, Combo, Sterne, Highscore, Admin-Pflege und diff --git a/mindfluxfuture.md b/mindfluxfuture.md index 2c57cd8..a66b550 100644 --- a/mindfluxfuture.md +++ b/mindfluxfuture.md @@ -2,14 +2,14 @@ ## Kontext -Das Dashboard von Mindflux (`mindflux2`, Spring Boot 3.4.4 / Java 21 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier Spiele waren bei Erstellung dieses Plans Platzhalter-Karten. Davon sind inzwischen **SystemEscape, BugHunter und NetWorker umgesetzt**; nur **ServiceClash** bleibt offen. +Das Dashboard von Mindflux (`mindflux2`, Spring Boot 3.4.4 / Java 21 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier Spiele waren bei Erstellung dieses Plans Platzhalter-Karten. **Alle vier – SystemEscape, BugHunter, NetWorker und ServiceClash – sind inzwischen umgesetzt.** 1. **SystemEscape** – „Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System." 2. **Service Clash** – „Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!" 3. **Bug Hunter** – „Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs." 4. **Netzwerker – Das Router-Rätselspiel** – „Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen." -Die Spiel-Keys `codeescape`, `serviceclash`, `bughunter`, `networker` stehen in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`) und in `ProgressService.GAMES`; die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Dieser Plan bleibt die Referenz für den noch offenen ServiceClash-Ausbau sowie spätere Content- und Visual-Vertiefungen. +Die Spiel-Keys `codeescape`, `serviceclash`, `bughunter`, `networker` stehen in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`) und in `ProgressService.GAMES`; die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Dieser Plan dokumentiert den erreichten Stand und bleibt Referenz für spätere Content- und Visual-Vertiefungen. **Zielgruppe & inhaltlicher Anspruch: die GESAMTE Fachinformatiker-Ausbildung.** Nicht nur AP1 – abgedeckt werden muss die komplette Ausbildung bis einschließlich **AP2** (beide Fachrichtungen: Anwendungsentwicklung/FIAE und Systemintegration/FISI). Konsequenzen für alle Spiele und Inhalte: @@ -50,7 +50,8 @@ Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben > ◐ **Stand 2026-07-13:** Die Dark-/Light-Akzent-Tokens für alle vier Spiele sind in > `theme.css` angelegt. SystemEscape, BugHunter und NetWorker haben eigene Spiel-Layouts; > BugHunter nutzt Fallakten-/Aktenschrank-Optik, NetWorker ein sichtbares Blaupausenraster. -> ServiceClash sowie optionale Detailanimationen bleiben offen. +> ServiceClash ist nun als eigenständiger cyanfarbener Leitstand umgesetzt; optionale +> Echtzeit-SLA-Animationen bleiben ein möglicher Ausbau. Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr deutlich** voneinander unterscheiden – nicht nur mechanisch, sondern auf den ersten Blick. Dazu zwei Maßnahmen: @@ -73,7 +74,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`): -1. **Flyway-Migration** ab `db/migration/V11__…` (V9/V10 sind durch User-/Datenintegrität belegt; `ddl-auto=validate` ⇒ Migration ist Pflicht). Umgesetzt: V11 SystemEscape, V12 BugHunter, V13 BugHunter-Fortschritt und V14 NetWorker. ServiceClash benötigt deshalb V15. SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `exam_scope VARCHAR(20)`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes. +1. **Flyway-Migration** ab `db/migration/V11__…` (V9/V10 sind durch User-/Datenintegrität belegt; `ddl-auto=validate` ⇒ Migration ist Pflicht). Umgesetzt: V11 SystemEscape, V12 BugHunter, V13 BugHunter-Fortschritt, V14 NetWorker und V15 ServiceClash. SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `exam_scope VARCHAR(20)`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes. 2. **Entities** in `model/` (Parent mit `@OneToMany(cascade=ALL, orphanRemoval=true)`, Lösungs-Logik nur serverseitig auf der Entity). 3. **Repository** in `repository/` (`findAllByActiveTrueOrderBySortOrderAscIdAsc()`, `findBySlug`, `countBy…Id`). 4. **Service** in `service/` mit Konstante `GAME = ""`, 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. @@ -195,7 +196,14 @@ Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: --- -### Spiel 3: ServiceClash (Key: `serviceclash`, Migration V15) ⏳ offen +### Spiel 3: ServiceClash (Key: `serviceclash`, Migration V15) ✅ umgesetzt + +> ✅ **Stand 2026-07-13:** V15-Datenmodell, Entities, Repository, Seeder, API und ein +> serverautoritatives Drei-Schritte-Scoring sind umgesetzt. Das Spiel enthält drei +> Schichten mit je acht Tickets (24 insgesamt), eine einsteigerfreundliche Briefing-Ansicht, +> Queue-/Wellen-Leitstand, Debriefs, Score-/Highscore-Speicherung und die Achievements +> „Leitstand-Einsatz“ sowie „Incident Commander“. Die technische Echtzeit-SLA-Simulation, +> zufällige Seed-Pläne und Admin-CRUD bleiben bewusste Ausbauschritte. **Konzept: „Deine Schicht am Service Desk."** Der Spieler ist Junior-Admin im First-Level-Support. Tickets treffen auf einer Zeitleiste ein; jedes muss **eingeordnet** (Incident vs. Service Request), **priorisiert** (Hoch/Mittel/Niedrig anhand von Impact-×-Dringlichkeit-Hinweisen im Tickettext) und **gelöst** werden (richtige Erstmaßnahme aus 3–4 Optionen) – bevor sein SLA-Timer abläuft. Unbehandelte Tickets beschädigen die „Systemgesundheit"; bei 0 endet die Schicht vorzeitig. @@ -220,22 +228,41 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention - Jeder Klick geht an `POST /check` (sofortiges ✓/✗-Feedback; Lösungen verlassen den Server nie). - `POST /finish` sendet `{slug, seed, decisions[], durationSeconds}`; der Server **regeneriert die Schicht aus dem Seed und re-simuliert das Entscheidungslog** (Plausibilitätschecks: Zeitstempel ≥ Spawn-Offset, ≤ Schichtdauer, monoton pro Ticket) und berechnet Gesundheit, SLA-Faktoren und Score neu. Härtungs-Ausbau: HMAC-signierter Seed. -**Datenmodell (`V15__serviceclash.sql`):** -- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active) -- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index -- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt). +**Datenmodell (`V15__serviceclash.sql`, umgesetzt):** +- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty, exam_scope, + sort_order, active) +- `service_clash_tickets` (id, shift_id FK CASCADE, title, wave, points, sort_order, + payload VARCHAR(8000)) + FK-Index +- Ticket-Payload enthält Antragsteller, Sachverhalt, Optionen und die ausschließlich + serverseitig ausgewerteten Felder `category`, `priority`, `solution`, `explanation`. -**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild. +**Backend (umgesetzt):** Entities `ServiceClashShift`/`ServiceClashTicket` und +`ServiceClashShiftRepository`; `ServiceClashService` baut den Katalog und eine lösungsfreie +Session, prüft `classify|priority|solve` ausschließlich gegen den serverseitig gehaltenen +Payload und berechnet beim Abschluss Score, Sterne und Highscore mit +`recordScore(user, "serviceclash", slug, score, gelöste, sekunden)`. -**API (`/api/serviceclash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`. +**API (`/api/serviceclash`, umgesetzt):** `GET /catalog`, `GET /session/{slug}` (lösungsfreie +Ticket-Instanzen mit Welle und Auswahlmöglichkeiten), `POST /check` sowie `POST /finish` → +`{score, maxScore, stars, resolved, total, health, highscore, newHighscore, plays}`. -**Frontend:** `templates/service-clash.html`, `static/js/serviceclash.js` (State-Machine `SHIFT_SELECT → BRIEFING → RUNNING ⇄ WAVE_BREAK → DEBRIEF`; **ein** `setInterval(250 ms)`-Tick: fällige Tickets spawnen, SLA-Ringe aktualisieren), `static/css/service-clash.css` Präfix `sc-`. Layout: Topbar (Schicht-Uhr, Wellen-Pips, Gesundheits-Meter, „Gelöst 4/8"), links die Ticket-Queue (Karten mit SLA-Ring, pulsierender roter Rand bei Überfälligkeit), Mitte das aktive Ticket mit 3-Stufen-Flow, unten ein Event-Log („14:02 Ticket ‚ERP down' eingegangen"). SLA-Ringe: `var(--accent)`, unter 25 % `var(--danger)`. +**Frontend (umgesetzt):** `templates/serviceclash.html`, `static/js/serviceclash.js` und +`static/css/serviceclash.css` (Präfix `sc-`). Die Zustände Katalog → Briefing → Leitstand → +Debrief zeigen links Queue und Wellenfortschritt, in der Mitte den dreistufigen Ticket-Flow +und am Ende eine klare Score-Auswertung. Eine Echtzeit-SLA-Uhr ist als späterer Ausbau notiert. -**Schwierigkeitskurve:** Schicht 1 „Erster Arbeitstag" (8 Tickets, großzügige SLAs, Prio-Stufe deaktiviert), Schicht 2 „Normalbetrieb" (12 Tickets, Prio aktiv, erster Phishing-Köder), Schicht 3 „Patch Day Chaos" (16 Tickets, knappe SLAs, Major Incident mit Folgetickets in Welle 3). **Replay-Wert:** Ticket-Pool größer als `spawnCount` → jede Runde ein anderer Mix (Seed). +**Schwierigkeitskurve (umgesetzt):** „Frühschicht: Stabiler Start“ vermittelt Routinefälle, +„Tagesschicht: Prioritäten unter Druck“ ergänzt Phishing und Monitoring, „Bereitschaft: +Kritische Lage“ behandelt Ransomware, Changes, Backups, Zertifikate und DNS. Jede Schicht +enthält acht Tickets in drei Wellen; ein Reset startet die Wertung neu. -**Content & Seeder:** `ServiceClashSeeder` liest `resources/service-clash/content.json`. Startumfang: **3 Schichten × 18–20 Ticket-Vorlagen ≈ 55 Tickets.** Vier Beispiel-Tickets sind voll ausgearbeitet (Drucker-Queue/Incident-mittel, Azubi-Onboarding/Service-Request-niedrig, ERP-Totalausfall/Major-Incident-hoch mit Sofort-Eskalation, Phishing-Mail mit verdächtiger Absenderdomain). +**Content & Seeder (umgesetzt):** `ServiceClashSeeder` versorgt beim ersten Start drei +Schichten mit jeweils acht kuratierten Tickets (**24 insgesamt**). Enthalten sind u. a. +Drucker-Queue, Azubi-Onboarding, ERP-Totalausfall, Phishing, Change-Management, +Backup-/Zertifikats- und DNS-Störungen. -**Admin:** `AdminServiceClashController` (`/admin/service-clash`), Schicht-Liste + Detailseite (Schicht-Formular inkl. Settings-JSON, Ticket-Tabelle, Payload-JSON-Textarea mit Schema-Hinweis; Speichern validiert `correctIndex` gegen `options`). +**Admin:** Die Admin-Pflege bleibt ein Ausbau; der produktive Startkatalog wird aktuell über +den idempotenten Seeder versorgt. **Aufwand:** MVP ≈ 7,5 PT (Service inkl. Seed-Plan + Re-Simulation 2 · Frontend 3 · Content 1 · Admin 1 · Rest 0,5+0,5). Ausbau (+3 PT): Major-Incident-Folgeketten, Combo-Serien, HMAC-Seed, Endlosmodus „Überstunden". @@ -292,7 +319,9 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention dynamische Wertung und Anti-Cheat-Negativtests umgesetzt. 3. **BugHunter (V12) ✅** – Fallakten, serverautoritatives Drei-Phasen-Debugging, Combo-Wertung, Admin-CRUD und zwölf Fälle mit Reset-Varianten umgesetzt. 4. **NetWorker (V14) ✅** – testgetriebener Simulator, SVG-Frontend, drei Welten mit 15 Aufgaben, Score-/Achievement-Integration und Blaupausenraster umgesetzt. -5. **ServiceClash (V15) ⏳** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT +5. **ServiceClash (V15) ✅** – drei Schichten, 24 Tickets, serverautoritatives Triage-Scoring, + Score-/Achievement-Integration und cyanfarbener Leitstand sind umgesetzt. Echtzeit-SLA, + Seed-Mischung und Admin-Pflege bleiben als Ausbau markiert. **Gesamt-MVP aller vier Spiele: ≈ 28 Personentage.** Der Navigations-Umbau (Abschnitt 2.3) und die restlichen Teil-2-Punkte können parallel oder dazwischen erfolgen; empfohlen ist der Sidebar-/Tab-Umbau **vor** dem vierten Spiel, damit alle neuen Seiten die Fragmente nutzen. diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index 7d02e13..2984140 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -42,7 +42,7 @@ public class SecurityConfig { .csrf(csrf -> csrf // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. - .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/networker/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/config/ServiceClashSeeder.java b/src/main/java/com/example/mindflux/config/ServiceClashSeeder.java new file mode 100644 index 0000000..89fa1db --- /dev/null +++ b/src/main/java/com/example/mindflux/config/ServiceClashSeeder.java @@ -0,0 +1,51 @@ +package com.example.mindflux.config; + +import com.example.mindflux.model.*; +import com.example.mindflux.repository.ServiceClashShiftRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +@Component +public class ServiceClashSeeder implements CommandLineRunner { + private final ServiceClashShiftRepository shifts; private final ObjectMapper mapper; + public ServiceClashSeeder(ServiceClashShiftRepository shifts,ObjectMapper mapper){this.shifts=shifts;this.mapper=mapper;} + @Override @Transactional public void run(String... args) { + if(shifts.count()>0)return; + seed("fruehschicht","Frühschicht: Stabiler Start","Lerne das Triage-Prinzip: erst Art, dann Dringlichkeit, dann die passende erste Maßnahme.","Junior",0,List.of( + t("Druckerwarteschlange brennt","Buchhaltung","Drei Druckaufträge hängen seit dem Neustart fest; niemand kann Rechnungen drucken.","incident","medium","queue",1,100,"Störung: Die Warteschlange zurücksetzen und einen Testdruck starten.","queue","Druckwarteschlange prüfen und bereinigen","restart","Drucker ohne Prüfung neu starten","buy","Sofort ein neues Gerät bestellen"), + t("Azubi braucht Zugang","Ausbildung","Die neue Auszubildende startet heute und braucht Zugriff auf das Lernportal.","request","low","access",1,100,"Anfrage: Berechtigung nach Freigabe über den Standardprozess einrichten.","access","Zugang nach Freigabe einrichten","security","Sicherheitsincident ausrufen","ignore","Auf nächste Woche verschieben"), + t("WLAN im Besprechungsraum weg","Vertrieb","Seit 20 Minuten ist das Firmen-WLAN im Raum Elbe nicht sichtbar.","incident","medium","diagnose",1,110,"Störung: AP-Status und Stromversorgung prüfen, dann gezielt eskalieren.","diagnose","Access Point und Uplink prüfen","password","Alle WLAN-Passwörter zurücksetzen","replace","Das gesamte WLAN ersetzen"), + t("Neue Verteilerliste","Personal","Für das Onboarding wird eine Mailgruppe für zehn neue Kolleg:innen benötigt.","request","low","group",1,100,"Anfrage: Gruppe anhand der genehmigten Liste anlegen und Besitzer benennen.","group","Verteilerliste nach Vorlage anlegen","broadcast","An alle Mitarbeitenden mailen","escalate","Sofort an den CISO eskalieren"), + t("Zeiterfassung meldet Fehler","Produktion","Terminals zeigen beim Buchen nur noch „Dienst nicht erreichbar“.","incident","high","escalate",2,130,"Hohe Auswirkung: Incident dokumentieren, Dienst prüfen und an den Bereitschaftsdienst eskalieren.","escalate","Incident eröffnen und Bereitschaft informieren","wait","Bis morgen abwarten","delete","Terminals aus dem Verzeichnis löschen"), + t("USB-Tastatur defekt","Empfang","Eine einzelne Tastatur reagiert nicht mehr; Ersatz liegt im Schrank.","request","low","swap",2,90,"Standard-Request: Ersatzgerät ausgeben und Inventar nachführen.","swap","Ersatz ausgeben und Inventar buchen","outage","Großstörung ausrufen","format","Den Empfangs-PC formatieren"), + t("Dateiserver langsam","Konstruktion","Große CAD-Dateien öffnen seit einer Stunde extrem langsam, kleinere Dateien funktionieren.","incident","medium","monitor",2,120,"Störung: Auslastung und Netzwerkpfad messen, Befund in das Incident aufnehmen.","monitor","Last und Netzwerkpfad prüfen","reboot","Ohne Befund alle Server neu starten","close","Ticket als erledigt schließen"), + t("Meetingraum-Display buchen","Projektbüro","Für morgen soll das Display im Raum Nord für eine Präsentation reserviert werden.","request","low","booking",3,90,"Anfrage: Ressource im Buchungssystem reservieren und bestätigen.","booking","Display im Buchungssystem reservieren","incident","Display als Störung melden","handover","An die Nachtwache delegieren") + )); + seed("tagesschicht","Tagesschicht: Prioritäten unter Druck","Mehr parallele Tickets: Schütze zuerst Menschen, Daten und geschäftskritische Dienste.","Fortgeschritten",1,List.of( + t("ERP nicht erreichbar","Finanzen","Alle Standorte melden beim ERP nur noch Zeitüberschreitungen. Rechnungsfreigaben stehen.","incident","high","major",1,150,"Major Incident: Lagebild eröffnen, Eigentümer informieren und Wiederherstellung koordinieren.","major","Major Incident eröffnen und eskalieren","restart","Einzelne Clients neu starten","close","Ohne Prüfung schließen"), + t("Neue Software beantragt","Marketing","Ein Team möchte eine bereits freigegebene Bildbearbeitung auf fünf Notebooks installieren.","request","medium","deploy",1,110,"Anfrage: Freigegebenes Paket über die Softwareverteilung bereitstellen.","deploy","Paket über Softwareverteilung ausrollen","admin","Allen lokale Adminrechte geben","ignore","Anfrage ignorieren"), + t("Verdächtige Phishing-Mail","Support","Mehrere Mitarbeitende erhielten eine Rechnung von einer ähnlich aussehenden Absenderdomain.","incident","high","contain",1,150,"Security-Incident: Nachricht sichern, Empfänger warnen und den Meldeprozess starten.","contain","Mail sichern, warnen und Security informieren","reply","Auf die Mail antworten","delete","Nur den eigenen Posteingang leeren"), + t("VPN für Dienstreise","Vertrieb","Eine Kollegin reist nächste Woche und benötigt den Standard-VPN-Zugang.","request","medium","vpn",1,110,"Anfrage: VPN nach Richtlinie und Freigabe aktivieren, MFA erklären.","vpn","VPN mit MFA nach Richtlinie einrichten","share","Kolleg:innen-Zugang teilen","exception","MFA dauerhaft umgehen"), + t("Monitoring meldet Platte voll","Monitoring","Das Monitoring meldet 94 % Belegung auf dem Log-Volume eines Anwendungsservers.","incident","high","capacity",2,140,"Hohe Priorität: Wachstum prüfen, sichere Bereinigung oder Erweiterung planen und Dienst beobachten.","capacity","Belegung prüfen und Kapazität sichern","rmall","Alle Logs blind löschen","silence","Alarm dauerhaft stummschalten"), + t("Namensschild-Drucker","Office Management","Für eine Veranstaltung wird ein temporärer Druckerzugang für den Empfang benötigt.","request","low","temporary",2,90,"Anfrage: zeitlich begrenzte Berechtigung dokumentiert einrichten.","temporary","Temporären Zugang dokumentiert einrichten","global","Drucker global freigeben","incident","Als Sicherheitsincident melden"), + t("CRM zeigt falsche Kundendaten","Vertrieb","Einige Kundensätze wirken veraltet, während Änderungen anderer Teams sichtbar sind.","incident","medium","investigate",2,120,"Störung: Reproduzierbarkeit, betroffene Datenquelle und letzte Synchronisation prüfen.","investigate","Datenquelle und Synchronisation prüfen","overwrite","Daten ohne Backup überschreiben","wait","Ticket ohne Rückmeldung parken"), + t("Projektablage anlegen","PMO","Ein neues Projekt braucht eine Teamablage mit den Rollen Lesen, Bearbeiten und Leitung.","request","medium","roles",3,110,"Anfrage: Ablage nach Berechtigungsvorlage erstellen und Rollen testen.","roles","Ablage mit Rollenmodell erstellen","public","Ablage öffentlich freigeben","owner","Alle zu Eigentümer:innen machen") + )); + seed("bereitschaft","Bereitschaft: Kritische Lage","Für die Abschluss-Schicht kombinierst du Security, Changes und Wiederanlauf unter klarer Kommunikation.","Profi",2,List.of( + t("Ransomware-Verdacht","Monitoring","Ein Fileserver erzeugt ungewöhnlich viele umbenannte Dateien; EDR meldet verdächtige Prozesse.","incident","high","isolate",1,160,"Security-Incident: System isolieren, Beweise sichern und Incident-Response alarmieren.","isolate","System isolieren und Incident Response alarmieren","reboot","Server sofort neu starten","delete","Betroffene Dateien ohne Sicherung löschen"), + t("Firewall-Change vor Wartung","Netzwerk","Für ein genehmigtes Wartungsfenster muss eine dokumentierte Regel temporär geöffnet werden.","request","high","change",1,140,"Change: Genehmigung, Zeitfenster, Rückfallplan und Protokoll vor Umsetzung prüfen.","change","Change mit Rückfallplan kontrolliert umsetzen","permanent","Regel dauerhaft und ohne Prüfung öffnen","delegate","Anfrage ohne Dokumentation weitergeben"), + t("Backup-Job fehlgeschlagen","Backup","Der nächtliche Backup-Job für zwei Datenbanken endete mit Fehler; die Produktion läuft noch.","incident","high","restore",1,150,"Hohe Priorität: Fehlerursache und letzte erfolgreiche Sicherung prüfen, Wiederanlauf planen.","restore","Backup-Kette prüfen und Wiederanlauf planen","ignore","Bis zum nächsten Lauf warten","purge","Alte Sicherungen sofort löschen"), + t("Notfall-Handy ausgeben","Bereitschaft","Eine neu eingeteilte Person braucht das Bereitschaftshandy inklusive Rufumleitung.","request","medium","handover",1,110,"Anfrage: Gerät, Rufumleitung und Übergabe nachvollziehbar dokumentieren.","handover","Übergabe und Rufumleitung dokumentieren","share","PIN unverschlüsselt in den Chat posten","skip","Gerät ohne Einweisung übergeben"), + t("Zertifikat läuft heute ab","Webteam","Das TLS-Zertifikat des Kundenportals läuft laut Monitoring um 23:59 Uhr ab.","incident","high","renew",2,150,"Dringend: Zertifikatskette und Erneuerung prüfen, Rollback und Monitoring vorbereiten.","renew","Zertifikat erneuern und Ablauf testen","disable","HTTPS abschalten","wait","Erst nach Ablauf reagieren"), + t("Neue Adminrolle","Entwicklung","Eine Entwicklerin beantragt für einen Testzeitraum erhöhte Rechte auf einem Staging-System.","request","high","least",2,130,"Anfrage: zeitlich begrenzte Least-Privilege-Rolle mit Genehmigung einrichten.","least","Befristete Minimalrechte nach Freigabe geben","root","Dauerhafte Root-Rechte vergeben","shared","Gemeinsames Admin-Konto nutzen"), + t("DNS-Ausfall in Außenstelle","Filiale Ost","Clients können interne Namen nicht auflösen, direkte IP-Verbindungen funktionieren noch.","incident","high","dns",2,150,"Störung: DNS-Pfad und Resolver prüfen, Auswirkung kommunizieren und Eskalation steuern.","dns","Resolver und DNS-Pfad prüfen","format","Alle Clients neu installieren","close","Ticket schließen, weil IPs gehen"), + t("Wartungsfenster verschieben","Change Management","Ein freigegebener Patch kollidiert mit einem wichtigen Kunden-Workshop und soll verschoben werden.","request","medium","reschedule",3,110,"Change-Anfrage: Auswirkungen bewerten, Beteiligte informieren und neues Fenster dokumentieren.","reschedule","Change dokumentiert neu terminieren","patch","Patch trotz Konflikt ausrollen","cancel","Alle offenen Changes löschen") + )); + } + private void seed(String slug,String title,String description,String difficulty,int order,List tickets){ServiceClashShift shift=new ServiceClashShift(slug,title,description,difficulty,"AP2_FISI",order);tickets.forEach(shift::addTicket);shifts.save(shift);} + private ServiceClashTicket t(String title,String requester,String text,String category,String priority,String solution,int wave,int points,String explanation,String... options){try{ObjectNode p=mapper.createObjectNode();p.put("requester",requester);p.put("text",text);p.put("category",category);p.put("priority",priority);p.put("solution",solution);p.put("explanation",explanation);var array=p.putArray("options");for(int i=0;i tickets = new ArrayList<>(); + public ServiceClashShift() { } + public ServiceClashShift(String slug, String title, String description, String difficulty, String examScope, int sortOrder) { this.slug=slug;this.title=title;this.description=description;this.difficulty=difficulty;this.examScope=examScope;this.sortOrder=sortOrder; } + public void addTicket(ServiceClashTicket ticket) { ticket.setShift(this); tickets.add(ticket); } + public Long getId(){return id;} public void setId(Long value){id=value;} public String getSlug(){return slug;} public String getTitle(){return title;} public String getDescription(){return description;} public String getDifficulty(){return difficulty;} public String getExamScope(){return examScope;} public int getSortOrder(){return sortOrder;} public boolean isActive(){return active;} public List getTickets(){return tickets;} +} diff --git a/src/main/java/com/example/mindflux/model/ServiceClashTicket.java b/src/main/java/com/example/mindflux/model/ServiceClashTicket.java new file mode 100644 index 0000000..936c922 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/ServiceClashTicket.java @@ -0,0 +1,17 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +@Entity @Table(name = "service_clash_tickets") +public class ServiceClashTicket { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "shift_id", nullable = false) private ServiceClashShift shift; + @Column(nullable = false) private String title; + private int wave; + private int points; + @Column(name = "sort_order") private int sortOrder; + @Column(length = 8000, nullable = false) private String payload; + public ServiceClashTicket() { } + public ServiceClashTicket(String title,int wave,int points,int sortOrder,String payload){this.title=title;this.wave=wave;this.points=points;this.sortOrder=sortOrder;this.payload=payload;} + public Long getId(){return id;} public void setId(Long value){id=value;} public void setShift(ServiceClashShift value){shift=value;} public ServiceClashShift getShift(){return shift;} public String getTitle(){return title;} public int getWave(){return wave;} public int getPoints(){return points;} public String getPayload(){return payload;} +} diff --git a/src/main/java/com/example/mindflux/repository/ServiceClashShiftRepository.java b/src/main/java/com/example/mindflux/repository/ServiceClashShiftRepository.java new file mode 100644 index 0000000..334f24c --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/ServiceClashShiftRepository.java @@ -0,0 +1,5 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.ServiceClashShift; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.*; +public interface ServiceClashShiftRepository extends JpaRepository { Optional findBySlug(String slug); List findAllByActiveTrueOrderBySortOrderAscIdAsc(); } diff --git a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java index cc3020a..da3a1a8 100644 --- a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java +++ b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java @@ -31,6 +31,8 @@ public class AchievementServiceImpl implements AchievementService { CATALOG.put("Syntax-Spur", "Schließe eine SyntaxSaga-Runde mit Punkten ab."); CATALOG.put("Fluchtprotokoll", "Schließe eine SystemEscape-Runde mit Punkten ab."); CATALOG.put("Erste Fallakte", "Schließe eine BugHunter-Akte mit Punkten ab."); + CATALOG.put("Leitstand-Einsatz", "Schließe eine ServiceClash-Schicht mit Punkten ab."); + CATALOG.put("Incident Commander", "Löse eine ServiceClash-Schicht ohne Fehler oder Aufdecken."); CATALOG.put("Netzwerk-Neustart", "Schließe eine NetWorker-Welt mit Punkten ab."); } @@ -80,7 +82,7 @@ public class AchievementServiceImpl implements AchievementService { String gameAchievement = Map.of( "mindmatch", "Gedächtnis-Impuls", "techtacular", "Quiz-Kapitän", "devlab", "Werkbank aktiv", "syntax-saga", "Syntax-Spur", "codeescape", "Fluchtprotokoll", "bughunter", "Erste Fallakte", - "networker", "Netzwerk-Neustart").get(game); + "serviceclash", "Leitstand-Einsatz", "networker", "Netzwerk-Neustart").get(game); if (gameAchievement != null) award(existing, username, gameAchievement, score > 0); } diff --git a/src/main/java/com/example/mindflux/service/ServiceClashService.java b/src/main/java/com/example/mindflux/service/ServiceClashService.java new file mode 100644 index 0000000..39c3789 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/ServiceClashService.java @@ -0,0 +1,24 @@ +package com.example.mindflux.service; + +import com.example.mindflux.dto.*; +import com.example.mindflux.model.*; +import com.example.mindflux.repository.ServiceClashShiftRepository; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import jakarta.servlet.http.HttpSession; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +@Service +public class ServiceClashService { + private static final String GAME="serviceclash", SESSION="serviceclash.run"; private static final int MAX_DURATION=14_400; + private final ServiceClashShiftRepository shifts; private final GameScoreService scores; private final AchievementService achievements; private final ObjectMapper mapper; + public ServiceClashService(ServiceClashShiftRepository shifts,GameScoreService scores,AchievementService achievements,ObjectMapper mapper){this.shifts=shifts;this.scores=scores;this.achievements=achievements;this.mapper=mapper;} + @Transactional(readOnly=true) public JsonNode catalog(){ObjectNode root=mapper.createObjectNode();ArrayNode rows=root.putArray("shifts");for(ServiceClashShift shift:shifts.findAllByActiveTrueOrderBySortOrderAscIdAsc()){ObjectNode row=rows.addObject();row.put("slug",shift.getSlug());row.put("title",shift.getTitle());row.put("description",shift.getDescription());row.put("difficulty",shift.getDifficulty());row.put("examScope",shift.getExamScope());row.put("ticketCount",shift.getTickets().size());row.put("waves",shift.getTickets().stream().mapToInt(ServiceClashTicket::getWave).max().orElse(1));}return root;} + @Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){ServiceClashShift shift=shift(slug);ObjectNode root=mapper.createObjectNode();root.put("slug",shift.getSlug());root.put("title",shift.getTitle());root.put("description",shift.getDescription());root.put("difficulty",shift.getDifficulty());root.put("examScope",shift.getExamScope());Map raw=new LinkedHashMap<>();ArrayNode tickets=root.putArray("tickets");int max=0;for(ServiceClashTicket ticket:shift.getTickets()){ObjectNode payload=parse(ticket.getPayload());if(payload==null)continue;raw.put(ticket.getId(),payload);ObjectNode safe=tickets.addObject();safe.put("ticketId",ticket.getId());safe.put("title",ticket.getTitle());safe.put("wave",ticket.getWave());safe.put("points",ticket.getPoints());safe.put("requester",payload.path("requester").asText());safe.put("text",payload.path("text").asText());ArrayNode options=safe.putArray("options");List shuffled=new ArrayList<>();payload.path("options").forEach(shuffled::add);Collections.shuffle(shuffled);for(JsonNode option:shuffled){ObjectNode row=options.addObject();row.put("id",option.path("id").asText());row.put("text",option.path("text").asText());}max+=ticket.getPoints();}root.put("maxScore",max);http.setAttribute(SESSION,new Run(shift.getSlug(),raw));return root;} + public JsonNode check(ServiceClashCheckRequest request,HttpSession http){if(request==null||request.getTicketId()==null||!Set.of("classify","priority","solve").contains(request.getStep()))throw new IllegalArgumentException("Die Prüfdaten sind unvollständig.");Run run=run(http);ObjectNode payload=run.payloads.get(request.getTicketId());if(payload==null)throw new IllegalArgumentException("Dieses Ticket gehört nicht zu deiner Schicht.");State state=run.states.computeIfAbsent(request.getTicketId(),id->new State());String step=request.getStep();if(state.completed(step))throw new IllegalArgumentException("Dieser Schritt ist bereits abgeschlossen.");if(!request.isReveal()&&(request.getValue()==null||request.getValue().isBlank()))throw new IllegalArgumentException("Wähle zuerst eine Antwort.");boolean correct=request.isReveal()?false:expected(payload,step).equals(request.getValue());state.attempts.merge(step,1,Integer::sum);if(request.isReveal()){state.revealed.add(step);state.setCompleted(step,true);} else if(correct){state.setCompleted(step,true);} else if(step.equals("solve")&&state.attempts.get(step)>=3){state.setCompleted(step,true);}ObjectNode result=mapper.createObjectNode();result.put("step",step);result.put("correct",correct);result.put("resolved",state.completed(step));result.put("attempts",state.attempts.get(step));result.put("canReveal",!state.completed(step));if(state.completed(step)&&(!correct||request.isReveal()))result.put("answer",expected(payload,step));if(state.ticketDone()){result.put("ticketDone",true);result.put("explanation",payload.path("explanation").asText());result.put("solution",payload.path("solution").asText());}return result;} + @Transactional public ServiceClashResult finish(String username,ServiceClashFinishRequest request,HttpSession http){if(request==null||request.getSlug()==null||request.getSlug().isBlank()||request.getDurationSeconds()<1||request.getDurationSeconds()>MAX_DURATION)throw new IllegalArgumentException("Unplausibler Abschluss.");Run run=run(http);if(!run.slug.equals(request.getSlug()))throw new IllegalArgumentException("Diese Schicht wurde nicht angefordert.");ServiceClashShift shift=shift(request.getSlug());int score=0,max=0,resolved=0,health=100;boolean flawless=true;for(ServiceClashTicket ticket:shift.getTickets()){max+=ticket.getPoints();State state=run.states.get(ticket.getId());if(state!=null&&state.ticketDone()){resolved++;double ratio=stepRatio(state,"classify",.20)+stepRatio(state,"priority",.30)+stepRatio(state,"solve",.50);score+=(int)Math.round(ticket.getPoints()*ratio);if(ratio<1)flawless=false;}else{health-=15;flawless=false;}}health=Math.max(0,health);int stars=stars(score,max);ScoreResult saved=scores.recordScore(username,GAME,shift.getSlug(),score,resolved,request.getDurationSeconds());if(resolved>0)achievements.awardSpecial(username,"Leitstand-Einsatz","Schließe eine ServiceClash-Schicht mit Punkten ab.");if(flawless&&resolved==shift.getTickets().size())achievements.awardSpecial(username,"Incident Commander","Löse eine komplette ServiceClash-Schicht ohne Fehler oder Aufdecken.");http.removeAttribute(SESSION);return new ServiceClashResult(score,max,stars,resolved,shift.getTickets().size(),health,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays(),flawless);} + private double stepRatio(State state,String step,double weight){if(!state.completed(step)||state.revealed.contains(step))return 0;int attempts=state.attempts.getOrDefault(step,1);return weight*(attempts==1?1:attempts==2?.6:.3);} private String expected(ObjectNode payload,String step){return switch(step){case "classify"->payload.path("category").asText();case "priority"->payload.path("priority").asText();default->payload.path("solution").asText();};}private ServiceClashShift shift(String slug){return shifts.findBySlug(slug).filter(ServiceClashShift::isActive).orElseThrow(()->new IllegalArgumentException("Schicht nicht gefunden."));}private ObjectNode parse(String raw){try{JsonNode node=mapper.readTree(raw);return node instanceof ObjectNode value?value:null;}catch(Exception e){return null;}}private int stars(int score,int max){if(score<=0||max<=0)return 0;double ratio=(double)score/max;return ratio>=.8?3:ratio>=.5?2:1;}private Run run(HttpSession http){Object value=http.getAttribute(SESSION);if(value instanceof Run run)return run;throw new IllegalArgumentException("Wähle zuerst eine Schicht aus.");} + static class Run {final String slug;final Map payloads;final Map states=new HashMap<>();Run(String slug,Map payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State {final Map attempts=new HashMap<>();final Set revealed=new HashSet<>(),done=new HashSet<>();boolean completed(String step){return done.contains(step);}void setCompleted(String step,boolean value){if(value)done.add(step);}boolean ticketDone(){return done.containsAll(Set.of("classify","priority","solve"));}} +} diff --git a/src/main/resources/db/migration/V15__serviceclash.sql b/src/main/resources/db/migration/V15__serviceclash.sql new file mode 100644 index 0000000..7294a9e --- /dev/null +++ b/src/main/resources/db/migration/V15__serviceclash.sql @@ -0,0 +1,22 @@ +CREATE TABLE service_clash_shifts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + description VARCHAR(1200), + difficulty VARCHAR(30) NOT NULL DEFAULT 'junior', + exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP2_FISI', + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE service_clash_tickets ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + shift_id BIGINT NOT NULL REFERENCES service_clash_shifts(id) ON DELETE CASCADE, + title VARCHAR(220) NOT NULL, + wave INT NOT NULL DEFAULT 1, + points INT NOT NULL DEFAULT 100, + sort_order INT NOT NULL DEFAULT 0, + payload VARCHAR(8000) NOT NULL +); + +CREATE INDEX idx_service_clash_tickets_shift ON service_clash_tickets(shift_id); diff --git a/src/main/resources/static/css/accessibility-placement.css b/src/main/resources/static/css/accessibility-placement.css index 945e432..d8b2bab 100644 --- a/src/main/resources/static/css/accessibility-placement.css +++ b/src/main/resources/static/css/accessibility-placement.css @@ -7,6 +7,7 @@ body{padding-left:0!important;padding-bottom:0!important} body.mf-a11y-skin-codeescape{--mf-a11y-accent:var(--esc-accent);--mf-a11y-glow:var(--esc-glow);--mf-a11y-ink:#1c1100} body.mf-a11y-skin-bughunter{--mf-a11y-accent:var(--bh-accent);--mf-a11y-glow:var(--bh-glow);--mf-a11y-ink:#210c12} body.mf-a11y-skin-networker{--mf-a11y-accent:var(--nw-accent);--mf-a11y-glow:var(--nw-glow);--mf-a11y-ink:#061321} +body.mf-a11y-skin-serviceclash{--mf-a11y-accent:var(--sc-accent);--mf-a11y-glow:var(--sc-glow);--mf-a11y-ink:#06171c} body[class*="mf-a11y-skin-"] .mf-a11y-trigger{border-color:color-mix(in srgb,var(--mf-a11y-accent) 65%,var(--border));background:linear-gradient(135deg,var(--mf-a11y-accent),color-mix(in srgb,var(--mf-a11y-accent) 72%,#fff));color:var(--mf-a11y-ink);box-shadow:0 0 16px var(--mf-a11y-glow)} body[class*="mf-a11y-skin-"] .mf-a11y-panel{border-color:color-mix(in srgb,var(--mf-a11y-accent) 56%,var(--border));box-shadow:0 12px 45px rgba(0,0,0,.55),0 0 22px var(--mf-a11y-glow)} body[class*="mf-a11y-skin-"] .mf-a11y-head{background:color-mix(in srgb,var(--mf-a11y-accent) 28%,#07121f)} diff --git a/src/main/resources/static/css/serviceclash.css b/src/main/resources/static/css/serviceclash.css new file mode 100644 index 0000000..2f58d07 --- /dev/null +++ b/src/main/resources/static/css/serviceclash.css @@ -0,0 +1 @@ +.sc-app{--sc-accent:#2bb6d7;--sc-glow:rgba(43,182,215,.28);--sc-danger:#ef5a62;min-height:100vh;background:radial-gradient(circle at 20% 0,#0e2e3c 0,transparent 37%),#071015;color:#edf8fb;font-family:var(--font-mono,monospace)}.sc-top{min-height:88px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 2.4rem;border-bottom:1px solid rgba(43,182,215,.42);background:#0c1011}.sc-nav{display:flex;align-items:center;gap:.8rem}.sc-nav a{color:#b9c9ce;text-decoration:none}.sc-nav-button{border:1px solid var(--sc-accent);border-radius:5px;padding:.5rem .75rem;font-size:.78rem;letter-spacing:.08em}.sc-brand{text-align:center;text-transform:uppercase}.sc-brand b{display:block;color:var(--sc-accent);font-size:1.55rem;letter-spacing:.17em;text-shadow:0 0 16px var(--sc-glow)}.sc-brand span{font-size:.7rem;letter-spacing:.2em;color:#c0ad81}.sc-top .mf-a11y-host{justify-self:end}.sc-view{max-width:1240px;margin:auto;padding:3rem 2rem 5rem}.sc-loading{color:#c0ad81;text-align:center;padding:7rem}.sc-eyebrow{color:var(--sc-accent);letter-spacing:.15em;font-size:.8rem;text-transform:uppercase}.sc-title{font-family:Georgia,serif;font-size:clamp(2.2rem,5vw,4.2rem);margin:.45rem 0 1rem}.sc-intro{max-width:760px;color:#b9c9ce;font-size:1.06rem;line-height:1.7}.sc-shifts{display:grid;grid-template-columns:repeat(3,1fr);gap:1.15rem;margin-top:2rem}.sc-card,.sc-panel{border:1px solid rgba(43,182,215,.36);background:linear-gradient(145deg,rgba(18,35,40,.94),rgba(8,16,19,.95));box-shadow:0 16px 34px rgba(0,0,0,.22);padding:1.4rem}.sc-card{position:relative;min-height:265px;display:flex;flex-direction:column}.sc-card:before{content:"";position:absolute;inset:9px;border:1px solid rgba(43,182,215,.12);pointer-events:none}.sc-card h2{font-family:Georgia,serif;font-size:1.8rem;margin:.6rem 0}.sc-card p{line-height:1.55;color:#b9c9ce;flex:1}.sc-meta{display:flex;gap:.55rem;flex-wrap:wrap}.sc-meta span{border:1px solid rgba(192,173,129,.34);padding:.28rem .48rem;color:#d6c49f;font-size:.72rem}.sc-btn{appearance:none;border:1px solid var(--sc-accent);background:transparent;color:#eafaff;padding:.75rem 1rem;margin-top:1.3rem;font:700 .78rem var(--font-mono,monospace);letter-spacing:.08em;cursor:pointer}.sc-btn:hover,.sc-btn.primary{background:var(--sc-accent);color:#061316;box-shadow:0 0 22px var(--sc-glow)}.sc-brief{max-width:760px;margin:2rem auto}.sc-brief h1{font-family:Georgia,serif;font-size:2.8rem}.sc-tip{border-left:3px solid var(--sc-accent);padding:.7rem 1rem;background:rgba(43,182,215,.09);line-height:1.6}.sc-command{display:grid;grid-template-columns:245px minmax(0,1fr);gap:1rem;align-items:start}.sc-queue{position:sticky;top:1rem}.sc-stat{margin:.2rem 0 .85rem;color:#b9c9ce;font-size:.83rem}.sc-meter{height:8px;background:#17262c;border-radius:99px;overflow:hidden}.sc-meter i{display:block;height:100%;background:var(--sc-accent);box-shadow:0 0 12px var(--sc-glow)}.sc-ticket-list{display:grid;gap:.5rem;margin-top:1.1rem}.sc-ticket-list button{width:100%;text-align:left;background:#0a1519;border:1px solid #24424c;color:#c9d8dc;padding:.65rem;cursor:pointer;font:inherit;font-size:.78rem}.sc-ticket-list button.active{border-color:var(--sc-accent);color:white}.sc-ticket-list button.done:after{content:" ✓";color:#87e2ab}.sc-ticket-head{display:flex;justify-content:space-between;gap:1rem;color:#c0ad81;font-size:.75rem;letter-spacing:.08em}.sc-ticket h1{font-family:Georgia,serif;font-size:2rem;margin:.6rem 0}.sc-ticket p{font-family:Georgia,serif;font-size:1.2rem;line-height:1.55;color:#dde9eb}.sc-steps{display:flex;gap:.5rem;margin:1.4rem 0}.sc-step{border:1px solid #31515b;color:#8aa3aa;padding:.35rem .55rem;font-size:.72rem}.sc-step.active{border-color:var(--sc-accent);color:var(--sc-accent)}.sc-step.done{border-color:#75ce94;color:#75ce94}.sc-choices{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.7rem}.sc-choice{border:1px solid #31515b;background:#0a1519;color:#eef9fb;text-align:left;padding:.9rem;cursor:pointer;font:inherit;line-height:1.35}.sc-choice:hover{border-color:var(--sc-accent)}.sc-feedback{margin-top:1rem;padding:1rem;border-left:3px solid var(--sc-accent);background:#102126;line-height:1.55}.sc-feedback.bad{border-color:var(--sc-danger)}.sc-reveal{color:#c0ad81;background:none;border:0;text-decoration:underline;cursor:pointer;margin-top:1rem;font:inherit}.sc-debrief{margin-top:1.4rem;border-top:1px solid #294a54;padding-top:1rem;color:#c9d8dc;line-height:1.6}.sc-result{max-width:700px;margin:2rem auto;text-align:center}.sc-result h1{font-family:Georgia,serif;font-size:3.3rem;margin:.6rem}.sc-score{font-size:3.8rem;color:var(--sc-accent);text-shadow:0 0 20px var(--sc-glow)}.sc-result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.8rem;margin:1.8rem 0}.sc-result-grid div{border:1px solid #31515b;padding:1rem}.sc-result-grid b{display:block;font-size:1.5rem;color:white}@media(max-width:780px){.sc-top{grid-template-columns:1fr;padding:1rem;gap:.75rem}.sc-brand{order:-1}.sc-top .mf-a11y-host{justify-self:start}.sc-shifts,.sc-command{grid-template-columns:1fr}.sc-queue{position:static}.sc-view{padding:2rem 1rem}.sc-result-grid{grid-template-columns:1fr}} diff --git a/src/main/resources/static/js/accessibility.js b/src/main/resources/static/js/accessibility.js index 0900efd..52cbb2b 100644 --- a/src/main/resources/static/js/accessibility.js +++ b/src/main/resources/static/js/accessibility.js @@ -2,7 +2,7 @@ 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 applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(!skin&&document.querySelector('.nw-app'))skin='networker';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);} +function applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(!skin&&document.querySelector('.sc-app'))skin='serviceclash';if(!skin&&document.querySelector('.nw-app'))skin='networker';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);} function button(id,icon,label,detail){return '';} 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=''; 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='

Barrierefreiheit

'+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')+'
'; diff --git a/src/main/resources/static/js/serviceclash.js b/src/main/resources/static/js/serviceclash.js new file mode 100644 index 0000000..827d830 --- /dev/null +++ b/src/main/resources/static/js/serviceclash.js @@ -0,0 +1,10 @@ +(()=>{'use strict';const view=document.getElementById('scView');let catalog=[],session=null,index=0,started=0,states={};const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const api=(url,opt={})=>fetch('/api/serviceclash'+url,{headers:{'Content-Type':'application/json'},...opt}).then(async r=>{const data=await r.json();if(!r.ok)throw new Error(data.message||'Serverfehler');return data});const post=(url,data)=>api(url,{method:'POST',body:JSON.stringify(data)});const seconds=()=>Math.max(1,Math.round((Date.now()-started)/1000));const current=()=>session.tickets[index]; +function renderCatalog(){view.innerHTML=`
Service Desk Simulation

Welche Schicht übernimmst du?

ServiceClash erklärt die Triage direkt im Spiel: Ordne jedes Ticket zuerst als Störung oder Anfrage ein, schätze seine Dringlichkeit ein und wähle dann die beste erste Maßnahme. Du darfst Fehler machen – bei Bedarf zeigt dir „Aufdecken“ die Begründung.

${catalog.map(s=>`
${esc(s.difficulty)} · ${esc(s.examScope)}

${esc(s.title)}

${esc(s.description)}

${s.ticketCount} Tickets${s.waves} Wellen
`).join('')}
`;view.querySelectorAll('[data-shift]').forEach(b=>b.onclick=()=>brief(b.dataset.shift));} +async function brief(slug){try{session=await api('/session/'+encodeURIComponent(slug));index=0;states={};view.innerHTML=`
${esc(session.difficulty)} · ${session.tickets.length} Tickets

${esc(session.title)}

${esc(session.description)}

So spielst du: Eine Störung bedeutet, dass etwas bereits nicht funktioniert. Eine Anfrage wünscht eine geplante Leistung. Priorität hoch wählst du bei größerer Auswirkung, Sicherheitsrisiko oder Zeitkritik. Die Punkte berücksichtigen alle drei Entscheidungen.

`;document.getElementById('scStart').onclick=()=>{started=Date.now();renderRun()}}catch(error){fail(error)}} +function ticketState(ticket){return states[ticket.ticketId]||{step:'classify',done:[]}}function renderRun(){const ticket=current(),state=ticketState(ticket),done=Object.values(states).filter(s=>s.done?.length===3).length;const wave=Math.max(...session.tickets.map(t=>t.wave));if(!ticket){finish();return}const choices=choiceMarkup(ticket,state);view.innerHTML=`
WELLE ${ticket.wave}/${wave} · ${esc(ticket.requester)}${ticket.points} PTS

${esc(ticket.title)}

${esc(ticket.text)}

1 ART2 PRIORITÄT3 MASSNAHME
${choices}${state.feedback?``:''}${state.done.length===3?`
Debrief: ${esc(state.explanation||'Ticket dokumentiert.')}
`:''}
`;view.querySelectorAll('[data-ticket]').forEach(b=>b.onclick=()=>{const target=Number(b.dataset.ticket);if(target<=index||ticketState(session.tickets[target]).done?.length===3){index=target;renderRun()}});view.querySelectorAll('[data-value]').forEach(b=>b.onclick=()=>check(state.step,b.dataset.value));const reveal=document.getElementById('scReveal');if(reveal)reveal.onclick=()=>check(state.step,'',true);const next=document.getElementById('scNext');if(next)next.onclick=()=>{index++;renderRun()}} +function choiceMarkup(ticket,state){if(state.done.length===3)return '';if(state.step==='classify')return `
${revealButton()}`;if(state.step==='priority')return `
${revealButton()}`;return `
${ticket.options.map(o=>``).join('')}
${revealButton()}`} +const revealButton=()=>``; +async function check(step,value,reveal=false){const ticket=current();try{const result=await post('/check',{ticketId:ticket.ticketId,step,value,reveal});const state=ticketState(ticket);states[ticket.ticketId]=state;if(result.resolved){state.done=[...state.done,step];state.step=step==='classify'?'priority':step==='priority'?'solve':'done'}state.bad=!result.correct;state.feedback=result.correct?'Richtig eingeordnet.':`${reveal?'Aufgedeckt':'Noch nicht passend.'} ${result.answer?`Richtig wäre: ${esc(label(result.answer,ticket))}.`:''}`;if(result.ticketDone){state.explanation=result.explanation;state.feedback='Ticket abgeschlossen. Prüfe kurz das Debrief und entscheide dann selbst, wann du weitergehst.'}renderRun()}catch(error){fail(error)}} +function label(value,ticket){if(value==='incident')return'Störung';if(value==='request')return'Anfrage';if(value==='high')return'hoch';if(value==='medium')return'mittel';if(value==='low')return'niedrig';return ticket.options.find(o=>o.id===value)?.text||value} +async function finish(){try{const result=await post('/finish',{slug:session.slug,durationSeconds:seconds()});view.innerHTML=`
Schicht abgeschlossen

${result.flawless?'Leitstand souverän geführt':'Auswertung bereit'}

${result.score} / ${result.maxScore} PTS

${'★'.repeat(result.stars)}${'☆'.repeat(3-result.stars)}

${result.resolved}/${result.total} Tickets gelöst
${result.health}% Service-Gesundheit
${result.highscore} Highscore

${result.newHighscore?'Neuer Highscore gespeichert. ':''}${result.flawless?'Fehlerfreie Schicht – stark.':'Wiederhole eine Schicht: Tickets und Reihenfolge bleiben nachvollziehbar, deine Entscheidungen werden neu bewertet.'}

`;document.getElementById('scAgain').onclick=load}catch(error){fail(error)}} +function fail(error){view.innerHTML=`

Leitstand nicht erreichbar

${esc(error.message||'Unbekannter Fehler')}

`}async function load(){try{catalog=(await api('/catalog')).shifts||[];renderCatalog()}catch(error){fail(error)}}load();})(); diff --git a/src/main/resources/static/js/theme.js b/src/main/resources/static/js/theme.js index f0ebf66..b3c5063 100644 --- a/src/main/resources/static/js/theme.js +++ b/src/main/resources/static/js/theme.js @@ -32,13 +32,13 @@ document.head.appendChild(accessibilityCss); var accessibilityPlacementCss = document.createElement('link'); accessibilityPlacementCss.rel = 'stylesheet'; - accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=7'; + accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=8'; 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=6'; + accessibilityScript.src = '/js/accessibility.js?v=7'; accessibilityScript.defer = true; accessibilityScript.setAttribute('data-accessibility', ''); document.head.appendChild(accessibilityScript); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index cebf941..f363c98 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -137,7 +137,7 @@ ['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'], ['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!','/serviceclash'], ['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/bughunter'], ['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/networker'], ['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux'] diff --git a/src/main/resources/templates/serviceclash.html b/src/main/resources/templates/serviceclash.html new file mode 100644 index 0000000..016e8a4 --- /dev/null +++ b/src/main/resources/templates/serviceclash.html @@ -0,0 +1 @@ +ServiceClash · Mindflux
ServiceClashLEITSTAND · TRIAGE

LEITSTAND WIRD HOCHGEFAHREN …