Add ServiceClash game
mindflux-ci / build (push) Successful in 22s

This commit is contained in:
AliceDarkest
2026-07-13 23:06:14 +02:00
parent b8f830dce0
commit 3f32fcd6c1
22 changed files with 233 additions and 22 deletions
+15
View File
@@ -6,6 +6,21 @@ Versionierung [semantisch](https://semver.org/lang/de/).
## [Unreleased] – 2026-07-13 · BugHunter, NetWorker & echter Lernfortschritt ## [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 ### BugHunter
- **Vollständiges Fallakten-Spiel ausgeliefert:** serverautoritatives Drei-Phasen-Debugging - **Vollständiges Fallakten-Spiel ausgeliefert:** serverautoritatives Drei-Phasen-Debugging
(Orten → Klassifizieren → Fixen), Score, Combo, Sterne, Highscore, Admin-Pflege und (Orten → Klassifizieren → Fixen), Score, Combo, Sterne, Highscore, Admin-Pflege und
+45 -16
View File
@@ -2,14 +2,14 @@
## Kontext ## 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." 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 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: **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 > ◐ **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; > `theme.css` angelegt. SystemEscape, BugHunter und NetWorker haben eigene Spiel-Layouts;
> BugHunter nutzt Fallakten-/Aktenschrank-Optik, NetWorker ein sichtbares Blaupausenraster. > 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: 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`): 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). 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.
@@ -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. **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). - 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 (`V15__serviceclash.sql`):** **Datenmodell (`V15__serviceclash.sql`, umgesetzt):**
- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active) - `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty, exam_scope,
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index sort_order, active)
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt). - `service_clash_tickets` (id, shift_id FK CASCADE, title, wave, points, sort_order,
payload VARCHAR(8000)) + FK-Index
- Ticket-Payload enthält Antragsteller, Sachverhalt, Optionen und die ausschließlich
serverseitig ausgewerteten Felder `category`, `priority`, `solution`, `explanation`.
**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild. **Backend (umgesetzt):** Entities `ServiceClashShift`/`ServiceClashTicket` und
`ServiceClashShiftRepository`; `ServiceClashService` baut den Katalog und eine lösungsfreie
Session, prüft `classify|priority|solve` ausschließlich gegen den serverseitig gehaltenen
Payload und berechnet beim Abschluss Score, Sterne und Highscore mit
`recordScore(user, "serviceclash", slug, score, gelöste, sekunden)`.
**API (`/api/serviceclash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`. **API (`/api/serviceclash`, umgesetzt):** `GET /catalog`, `GET /session/{slug}` (lösungsfreie
Ticket-Instanzen mit Welle und Auswahlmöglichkeiten), `POST /check` sowie `POST /finish` →
`{score, maxScore, stars, resolved, total, health, highscore, newHighscore, plays}`.
**Frontend:** `templates/service-clash.html`, `static/js/serviceclash.js` (State-Machine `SHIFT_SELECT → BRIEFING → RUNNING ⇄ WAVE_BREAK → DEBRIEF`; **ein** `setInterval(250 ms)`-Tick: fällige Tickets spawnen, SLA-Ringe aktualisieren), `static/css/service-clash.css` Präfix `sc-`. Layout: Topbar (Schicht-Uhr, Wellen-Pips, Gesundheits-Meter, „Gelöst 4/8"), links die Ticket-Queue (Karten mit SLA-Ring, pulsierender roter Rand bei Überfälligkeit), Mitte das aktive Ticket mit 3-Stufen-Flow, unten ein Event-Log („14:02 Ticket ‚ERP down' eingegangen"). SLA-Ringe: `var(--accent)`, unter 25 % `var(--danger)`. **Frontend (umgesetzt):** `templates/serviceclash.html`, `static/js/serviceclash.js` und
`static/css/serviceclash.css` (Präfix `sc-`). Die Zustände Katalog → Briefing → Leitstand →
Debrief zeigen links Queue und Wellenfortschritt, in der Mitte den dreistufigen Ticket-Flow
und am Ende eine klare Score-Auswertung. Eine Echtzeit-SLA-Uhr ist als späterer Ausbau notiert.
**Schwierigkeitskurve:** Schicht 1 „Erster Arbeitstag" (8 Tickets, großzügige SLAs, Prio-Stufe deaktiviert), Schicht 2 „Normalbetrieb" (12 Tickets, Prio aktiv, erster Phishing-Köder), Schicht 3 „Patch Day Chaos" (16 Tickets, knappe SLAs, Major Incident mit Folgetickets in Welle 3). **Replay-Wert:** Ticket-Pool größer als `spawnCount` → jede Runde ein anderer Mix (Seed). **Schwierigkeitskurve (umgesetzt):** „Frühschicht: Stabiler Start“ vermittelt Routinefälle,
„Tagesschicht: Prioritäten unter Druck“ ergänzt Phishing und Monitoring, „Bereitschaft:
Kritische Lage“ behandelt Ransomware, Changes, Backups, Zertifikate und DNS. Jede Schicht
enthält acht Tickets in drei Wellen; ein Reset startet die Wertung neu.
**Content & Seeder:** `ServiceClashSeeder` liest `resources/service-clash/content.json`. Startumfang: **3 Schichten × 18–20 Ticket-Vorlagen ≈ 55 Tickets.** Vier Beispiel-Tickets sind voll ausgearbeitet (Drucker-Queue/Incident-mittel, Azubi-Onboarding/Service-Request-niedrig, ERP-Totalausfall/Major-Incident-hoch mit Sofort-Eskalation, Phishing-Mail mit verdächtiger Absenderdomain). **Content & Seeder (umgesetzt):** `ServiceClashSeeder` versorgt beim ersten Start drei
Schichten mit jeweils acht kuratierten Tickets (**24 insgesamt**). Enthalten sind u. a.
Drucker-Queue, Azubi-Onboarding, ERP-Totalausfall, Phishing, Change-Management,
Backup-/Zertifikats- und DNS-Störungen.
**Admin:** `AdminServiceClashController` (`/admin/service-clash`), Schicht-Liste + Detailseite (Schicht-Formular inkl. Settings-JSON, Ticket-Tabelle, Payload-JSON-Textarea mit Schema-Hinweis; Speichern validiert `correctIndex` gegen `options`). **Admin:** Die Admin-Pflege bleibt ein Ausbau; der produktive Startkatalog wird aktuell über
den idempotenten Seeder versorgt.
**Aufwand:** MVP ≈ 7,5 PT (Service inkl. Seed-Plan + Re-Simulation 2 · Frontend 3 · Content 1 · Admin 1 · Rest 0,5+0,5). Ausbau (+3 PT): Major-Incident-Folgeketten, Combo-Serien, HMAC-Seed, Endlosmodus „Überstunden". **Aufwand:** MVP ≈ 7,5 PT (Service inkl. Seed-Plan + Re-Simulation 2 · Frontend 3 · Content 1 · Admin 1 · Rest 0,5+0,5). Ausbau (+3 PT): Major-Incident-Folgeketten, Combo-Serien, HMAC-Seed, Endlosmodus „Überstunden".
@@ -292,7 +319,9 @@ maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
dynamische Wertung und Anti-Cheat-Negativtests umgesetzt. 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. 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. 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. **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.
@@ -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/**", "/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(); return http.build();
@@ -0,0 +1,51 @@
package com.example.mindflux.config;
import com.example.mindflux.model.*;
import com.example.mindflux.repository.ServiceClashShiftRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Component
public class ServiceClashSeeder implements CommandLineRunner {
private final ServiceClashShiftRepository shifts; private final ObjectMapper mapper;
public ServiceClashSeeder(ServiceClashShiftRepository shifts,ObjectMapper mapper){this.shifts=shifts;this.mapper=mapper;}
@Override @Transactional public void run(String... args) {
if(shifts.count()>0)return;
seed("fruehschicht","Frühschicht: Stabiler Start","Lerne das Triage-Prinzip: erst Art, dann Dringlichkeit, dann die passende erste Maßnahme.","Junior",0,List.of(
t("Druckerwarteschlange brennt","Buchhaltung","Drei Druckaufträge hängen seit dem Neustart fest; niemand kann Rechnungen drucken.","incident","medium","queue",1,100,"Störung: Die Warteschlange zurücksetzen und einen Testdruck starten.","queue","Druckwarteschlange prüfen und bereinigen","restart","Drucker ohne Prüfung neu starten","buy","Sofort ein neues Gerät bestellen"),
t("Azubi braucht Zugang","Ausbildung","Die neue Auszubildende startet heute und braucht Zugriff auf das Lernportal.","request","low","access",1,100,"Anfrage: Berechtigung nach Freigabe über den Standardprozess einrichten.","access","Zugang nach Freigabe einrichten","security","Sicherheitsincident ausrufen","ignore","Auf nächste Woche verschieben"),
t("WLAN im Besprechungsraum weg","Vertrieb","Seit 20 Minuten ist das Firmen-WLAN im Raum Elbe nicht sichtbar.","incident","medium","diagnose",1,110,"Störung: AP-Status und Stromversorgung prüfen, dann gezielt eskalieren.","diagnose","Access Point und Uplink prüfen","password","Alle WLAN-Passwörter zurücksetzen","replace","Das gesamte WLAN ersetzen"),
t("Neue Verteilerliste","Personal","Für das Onboarding wird eine Mailgruppe für zehn neue Kolleg:innen benötigt.","request","low","group",1,100,"Anfrage: Gruppe anhand der genehmigten Liste anlegen und Besitzer benennen.","group","Verteilerliste nach Vorlage anlegen","broadcast","An alle Mitarbeitenden mailen","escalate","Sofort an den CISO eskalieren"),
t("Zeiterfassung meldet Fehler","Produktion","Terminals zeigen beim Buchen nur noch „Dienst nicht erreichbar“.","incident","high","escalate",2,130,"Hohe Auswirkung: Incident dokumentieren, Dienst prüfen und an den Bereitschaftsdienst eskalieren.","escalate","Incident eröffnen und Bereitschaft informieren","wait","Bis morgen abwarten","delete","Terminals aus dem Verzeichnis löschen"),
t("USB-Tastatur defekt","Empfang","Eine einzelne Tastatur reagiert nicht mehr; Ersatz liegt im Schrank.","request","low","swap",2,90,"Standard-Request: Ersatzgerät ausgeben und Inventar nachführen.","swap","Ersatz ausgeben und Inventar buchen","outage","Großstörung ausrufen","format","Den Empfangs-PC formatieren"),
t("Dateiserver langsam","Konstruktion","Große CAD-Dateien öffnen seit einer Stunde extrem langsam, kleinere Dateien funktionieren.","incident","medium","monitor",2,120,"Störung: Auslastung und Netzwerkpfad messen, Befund in das Incident aufnehmen.","monitor","Last und Netzwerkpfad prüfen","reboot","Ohne Befund alle Server neu starten","close","Ticket als erledigt schließen"),
t("Meetingraum-Display buchen","Projektbüro","Für morgen soll das Display im Raum Nord für eine Präsentation reserviert werden.","request","low","booking",3,90,"Anfrage: Ressource im Buchungssystem reservieren und bestätigen.","booking","Display im Buchungssystem reservieren","incident","Display als Störung melden","handover","An die Nachtwache delegieren")
));
seed("tagesschicht","Tagesschicht: Prioritäten unter Druck","Mehr parallele Tickets: Schütze zuerst Menschen, Daten und geschäftskritische Dienste.","Fortgeschritten",1,List.of(
t("ERP nicht erreichbar","Finanzen","Alle Standorte melden beim ERP nur noch Zeitüberschreitungen. Rechnungsfreigaben stehen.","incident","high","major",1,150,"Major Incident: Lagebild eröffnen, Eigentümer informieren und Wiederherstellung koordinieren.","major","Major Incident eröffnen und eskalieren","restart","Einzelne Clients neu starten","close","Ohne Prüfung schließen"),
t("Neue Software beantragt","Marketing","Ein Team möchte eine bereits freigegebene Bildbearbeitung auf fünf Notebooks installieren.","request","medium","deploy",1,110,"Anfrage: Freigegebenes Paket über die Softwareverteilung bereitstellen.","deploy","Paket über Softwareverteilung ausrollen","admin","Allen lokale Adminrechte geben","ignore","Anfrage ignorieren"),
t("Verdächtige Phishing-Mail","Support","Mehrere Mitarbeitende erhielten eine Rechnung von einer ähnlich aussehenden Absenderdomain.","incident","high","contain",1,150,"Security-Incident: Nachricht sichern, Empfänger warnen und den Meldeprozess starten.","contain","Mail sichern, warnen und Security informieren","reply","Auf die Mail antworten","delete","Nur den eigenen Posteingang leeren"),
t("VPN für Dienstreise","Vertrieb","Eine Kollegin reist nächste Woche und benötigt den Standard-VPN-Zugang.","request","medium","vpn",1,110,"Anfrage: VPN nach Richtlinie und Freigabe aktivieren, MFA erklären.","vpn","VPN mit MFA nach Richtlinie einrichten","share","Kolleg:innen-Zugang teilen","exception","MFA dauerhaft umgehen"),
t("Monitoring meldet Platte voll","Monitoring","Das Monitoring meldet 94 % Belegung auf dem Log-Volume eines Anwendungsservers.","incident","high","capacity",2,140,"Hohe Priorität: Wachstum prüfen, sichere Bereinigung oder Erweiterung planen und Dienst beobachten.","capacity","Belegung prüfen und Kapazität sichern","rmall","Alle Logs blind löschen","silence","Alarm dauerhaft stummschalten"),
t("Namensschild-Drucker","Office Management","Für eine Veranstaltung wird ein temporärer Druckerzugang für den Empfang benötigt.","request","low","temporary",2,90,"Anfrage: zeitlich begrenzte Berechtigung dokumentiert einrichten.","temporary","Temporären Zugang dokumentiert einrichten","global","Drucker global freigeben","incident","Als Sicherheitsincident melden"),
t("CRM zeigt falsche Kundendaten","Vertrieb","Einige Kundensätze wirken veraltet, während Änderungen anderer Teams sichtbar sind.","incident","medium","investigate",2,120,"Störung: Reproduzierbarkeit, betroffene Datenquelle und letzte Synchronisation prüfen.","investigate","Datenquelle und Synchronisation prüfen","overwrite","Daten ohne Backup überschreiben","wait","Ticket ohne Rückmeldung parken"),
t("Projektablage anlegen","PMO","Ein neues Projekt braucht eine Teamablage mit den Rollen Lesen, Bearbeiten und Leitung.","request","medium","roles",3,110,"Anfrage: Ablage nach Berechtigungsvorlage erstellen und Rollen testen.","roles","Ablage mit Rollenmodell erstellen","public","Ablage öffentlich freigeben","owner","Alle zu Eigentümer:innen machen")
));
seed("bereitschaft","Bereitschaft: Kritische Lage","Für die Abschluss-Schicht kombinierst du Security, Changes und Wiederanlauf unter klarer Kommunikation.","Profi",2,List.of(
t("Ransomware-Verdacht","Monitoring","Ein Fileserver erzeugt ungewöhnlich viele umbenannte Dateien; EDR meldet verdächtige Prozesse.","incident","high","isolate",1,160,"Security-Incident: System isolieren, Beweise sichern und Incident-Response alarmieren.","isolate","System isolieren und Incident Response alarmieren","reboot","Server sofort neu starten","delete","Betroffene Dateien ohne Sicherung löschen"),
t("Firewall-Change vor Wartung","Netzwerk","Für ein genehmigtes Wartungsfenster muss eine dokumentierte Regel temporär geöffnet werden.","request","high","change",1,140,"Change: Genehmigung, Zeitfenster, Rückfallplan und Protokoll vor Umsetzung prüfen.","change","Change mit Rückfallplan kontrolliert umsetzen","permanent","Regel dauerhaft und ohne Prüfung öffnen","delegate","Anfrage ohne Dokumentation weitergeben"),
t("Backup-Job fehlgeschlagen","Backup","Der nächtliche Backup-Job für zwei Datenbanken endete mit Fehler; die Produktion läuft noch.","incident","high","restore",1,150,"Hohe Priorität: Fehlerursache und letzte erfolgreiche Sicherung prüfen, Wiederanlauf planen.","restore","Backup-Kette prüfen und Wiederanlauf planen","ignore","Bis zum nächsten Lauf warten","purge","Alte Sicherungen sofort löschen"),
t("Notfall-Handy ausgeben","Bereitschaft","Eine neu eingeteilte Person braucht das Bereitschaftshandy inklusive Rufumleitung.","request","medium","handover",1,110,"Anfrage: Gerät, Rufumleitung und Übergabe nachvollziehbar dokumentieren.","handover","Übergabe und Rufumleitung dokumentieren","share","PIN unverschlüsselt in den Chat posten","skip","Gerät ohne Einweisung übergeben"),
t("Zertifikat läuft heute ab","Webteam","Das TLS-Zertifikat des Kundenportals läuft laut Monitoring um 23:59 Uhr ab.","incident","high","renew",2,150,"Dringend: Zertifikatskette und Erneuerung prüfen, Rollback und Monitoring vorbereiten.","renew","Zertifikat erneuern und Ablauf testen","disable","HTTPS abschalten","wait","Erst nach Ablauf reagieren"),
t("Neue Adminrolle","Entwicklung","Eine Entwicklerin beantragt für einen Testzeitraum erhöhte Rechte auf einem Staging-System.","request","high","least",2,130,"Anfrage: zeitlich begrenzte Least-Privilege-Rolle mit Genehmigung einrichten.","least","Befristete Minimalrechte nach Freigabe geben","root","Dauerhafte Root-Rechte vergeben","shared","Gemeinsames Admin-Konto nutzen"),
t("DNS-Ausfall in Außenstelle","Filiale Ost","Clients können interne Namen nicht auflösen, direkte IP-Verbindungen funktionieren noch.","incident","high","dns",2,150,"Störung: DNS-Pfad und Resolver prüfen, Auswirkung kommunizieren und Eskalation steuern.","dns","Resolver und DNS-Pfad prüfen","format","Alle Clients neu installieren","close","Ticket schließen, weil IPs gehen"),
t("Wartungsfenster verschieben","Change Management","Ein freigegebener Patch kollidiert mit einem wichtigen Kunden-Workshop und soll verschoben werden.","request","medium","reschedule",3,110,"Change-Anfrage: Auswirkungen bewerten, Beteiligte informieren und neues Fenster dokumentieren.","reschedule","Change dokumentiert neu terminieren","patch","Patch trotz Konflikt ausrollen","cancel","Alle offenen Changes löschen")
));
}
private void seed(String slug,String title,String description,String difficulty,int order,List<ServiceClashTicket> tickets){ServiceClashShift shift=new ServiceClashShift(slug,title,description,difficulty,"AP2_FISI",order);tickets.forEach(shift::addTicket);shifts.save(shift);}
private ServiceClashTicket t(String title,String requester,String text,String category,String priority,String solution,int wave,int points,String explanation,String... options){try{ObjectNode p=mapper.createObjectNode();p.put("requester",requester);p.put("text",text);p.put("category",category);p.put("priority",priority);p.put("solution",solution);p.put("explanation",explanation);var array=p.putArray("options");for(int i=0;i<options.length;i+=2){ObjectNode option=array.addObject();option.put("id",options[i]);option.put("text",options[i+1]);}return new ServiceClashTicket(title,wave,points,wave*10+options.length,mapper.writeValueAsString(p));}catch(Exception e){throw new IllegalStateException(e);}}
}
@@ -0,0 +1,3 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.*; import com.example.mindflux.service.ServiceClashService; import com.fasterxml.jackson.databind.JsonNode; import jakarta.servlet.http.HttpSession; import java.security.Principal; import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/serviceclash") public class ServiceClashApiController {private final ServiceClashService service;public ServiceClashApiController(ServiceClashService service){this.service=service;}@GetMapping("/catalog") public JsonNode catalog(){return service.catalog();}@GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug,HttpSession http){return service.session(slug,http);}@PostMapping("/check") public JsonNode check(@RequestBody ServiceClashCheckRequest request,HttpSession http){return service.check(request,http);}@PostMapping("/finish") public ServiceClashResult finish(@RequestBody ServiceClashFinishRequest request,Principal principal,HttpSession http){return service.finish(principal.getName(),request,http);}}
@@ -0,0 +1,3 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping;
@Controller public class ServiceClashController { @GetMapping("/serviceclash") public String page(){return "serviceclash";} }
@@ -0,0 +1,2 @@
package com.example.mindflux.dto;
public class ServiceClashCheckRequest { private Long ticketId; private String step; private String value; private boolean reveal; public Long getTicketId(){return ticketId;} public void setTicketId(Long value){ticketId=value;} public String getStep(){return step;} public void setStep(String value){step=value;} public String getValue(){return value;} public void setValue(String value){this.value=value;} public boolean isReveal(){return reveal;} public void setReveal(boolean value){reveal=value;} }
@@ -0,0 +1,2 @@
package com.example.mindflux.dto;
public class ServiceClashFinishRequest { private String slug; private int durationSeconds; public String getSlug(){return slug;} public void setSlug(String value){slug=value;} public int getDurationSeconds(){return durationSeconds;} public void setDurationSeconds(int value){durationSeconds=value;} }
@@ -0,0 +1,2 @@
package com.example.mindflux.dto;
public record ServiceClashResult(int score,int maxScore,int stars,int resolved,int total,int health,int highscore,boolean newHighscore,long plays,boolean flawless) { }
@@ -0,0 +1,21 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.*;
@Entity @Table(name = "service_clash_shifts")
public class ServiceClashShift {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, unique = true) private String slug;
@Column(nullable = false) private String title;
private String description;
private String difficulty;
@Column(name = "exam_scope") private String examScope;
@Column(name = "sort_order") private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "shift", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("wave ASC, sortOrder ASC, id ASC") private List<ServiceClashTicket> tickets = new ArrayList<>();
public ServiceClashShift() { }
public ServiceClashShift(String slug, String title, String description, String difficulty, String examScope, int sortOrder) { this.slug=slug;this.title=title;this.description=description;this.difficulty=difficulty;this.examScope=examScope;this.sortOrder=sortOrder; }
public void addTicket(ServiceClashTicket ticket) { ticket.setShift(this); tickets.add(ticket); }
public Long getId(){return id;} public void setId(Long value){id=value;} public String getSlug(){return slug;} public String getTitle(){return title;} public String getDescription(){return description;} public String getDifficulty(){return difficulty;} public String getExamScope(){return examScope;} public int getSortOrder(){return sortOrder;} public boolean isActive(){return active;} public List<ServiceClashTicket> getTickets(){return tickets;}
}
@@ -0,0 +1,17 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
@Entity @Table(name = "service_clash_tickets")
public class ServiceClashTicket {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "shift_id", nullable = false) private ServiceClashShift shift;
@Column(nullable = false) private String title;
private int wave;
private int points;
@Column(name = "sort_order") private int sortOrder;
@Column(length = 8000, nullable = false) private String payload;
public ServiceClashTicket() { }
public ServiceClashTicket(String title,int wave,int points,int sortOrder,String payload){this.title=title;this.wave=wave;this.points=points;this.sortOrder=sortOrder;this.payload=payload;}
public Long getId(){return id;} public void setId(Long value){id=value;} public void setShift(ServiceClashShift value){shift=value;} public ServiceClashShift getShift(){return shift;} public String getTitle(){return title;} public int getWave(){return wave;} public int getPoints(){return points;} public String getPayload(){return payload;}
}
@@ -0,0 +1,5 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.ServiceClashShift;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.*;
public interface ServiceClashShiftRepository extends JpaRepository<ServiceClashShift,Long> { Optional<ServiceClashShift> findBySlug(String slug); List<ServiceClashShift> findAllByActiveTrueOrderBySortOrderAscIdAsc(); }
@@ -31,6 +31,8 @@ public class AchievementServiceImpl implements AchievementService {
CATALOG.put("Syntax-Spur", "Schließe eine SyntaxSaga-Runde mit Punkten ab."); CATALOG.put("Syntax-Spur", "Schließe eine SyntaxSaga-Runde mit Punkten ab.");
CATALOG.put("Fluchtprotokoll", "Schließe eine SystemEscape-Runde mit Punkten ab."); CATALOG.put("Fluchtprotokoll", "Schließe eine SystemEscape-Runde mit Punkten ab.");
CATALOG.put("Erste Fallakte", "Schließe eine BugHunter-Akte 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."); 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( String gameAchievement = Map.of(
"mindmatch", "Gedächtnis-Impuls", "techtacular", "Quiz-Kapitän", "devlab", "Werkbank aktiv", "mindmatch", "Gedächtnis-Impuls", "techtacular", "Quiz-Kapitän", "devlab", "Werkbank aktiv",
"syntax-saga", "Syntax-Spur", "codeescape", "Fluchtprotokoll", "bughunter", "Erste Fallakte", "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); if (gameAchievement != null) award(existing, username, gameAchievement, score > 0);
} }
@@ -0,0 +1,24 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.*;
import com.example.mindflux.model.*;
import com.example.mindflux.repository.ServiceClashShiftRepository;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
public class ServiceClashService {
private static final String GAME="serviceclash", SESSION="serviceclash.run"; private static final int MAX_DURATION=14_400;
private final ServiceClashShiftRepository shifts; private final GameScoreService scores; private final AchievementService achievements; private final ObjectMapper mapper;
public ServiceClashService(ServiceClashShiftRepository shifts,GameScoreService scores,AchievementService achievements,ObjectMapper mapper){this.shifts=shifts;this.scores=scores;this.achievements=achievements;this.mapper=mapper;}
@Transactional(readOnly=true) public JsonNode catalog(){ObjectNode root=mapper.createObjectNode();ArrayNode rows=root.putArray("shifts");for(ServiceClashShift shift:shifts.findAllByActiveTrueOrderBySortOrderAscIdAsc()){ObjectNode row=rows.addObject();row.put("slug",shift.getSlug());row.put("title",shift.getTitle());row.put("description",shift.getDescription());row.put("difficulty",shift.getDifficulty());row.put("examScope",shift.getExamScope());row.put("ticketCount",shift.getTickets().size());row.put("waves",shift.getTickets().stream().mapToInt(ServiceClashTicket::getWave).max().orElse(1));}return root;}
@Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){ServiceClashShift shift=shift(slug);ObjectNode root=mapper.createObjectNode();root.put("slug",shift.getSlug());root.put("title",shift.getTitle());root.put("description",shift.getDescription());root.put("difficulty",shift.getDifficulty());root.put("examScope",shift.getExamScope());Map<Long,ObjectNode> raw=new LinkedHashMap<>();ArrayNode tickets=root.putArray("tickets");int max=0;for(ServiceClashTicket ticket:shift.getTickets()){ObjectNode payload=parse(ticket.getPayload());if(payload==null)continue;raw.put(ticket.getId(),payload);ObjectNode safe=tickets.addObject();safe.put("ticketId",ticket.getId());safe.put("title",ticket.getTitle());safe.put("wave",ticket.getWave());safe.put("points",ticket.getPoints());safe.put("requester",payload.path("requester").asText());safe.put("text",payload.path("text").asText());ArrayNode options=safe.putArray("options");List<JsonNode> shuffled=new ArrayList<>();payload.path("options").forEach(shuffled::add);Collections.shuffle(shuffled);for(JsonNode option:shuffled){ObjectNode row=options.addObject();row.put("id",option.path("id").asText());row.put("text",option.path("text").asText());}max+=ticket.getPoints();}root.put("maxScore",max);http.setAttribute(SESSION,new Run(shift.getSlug(),raw));return root;}
public JsonNode check(ServiceClashCheckRequest request,HttpSession http){if(request==null||request.getTicketId()==null||!Set.of("classify","priority","solve").contains(request.getStep()))throw new IllegalArgumentException("Die Prüfdaten sind unvollständig.");Run run=run(http);ObjectNode payload=run.payloads.get(request.getTicketId());if(payload==null)throw new IllegalArgumentException("Dieses Ticket gehört nicht zu deiner Schicht.");State state=run.states.computeIfAbsent(request.getTicketId(),id->new State());String step=request.getStep();if(state.completed(step))throw new IllegalArgumentException("Dieser Schritt ist bereits abgeschlossen.");if(!request.isReveal()&&(request.getValue()==null||request.getValue().isBlank()))throw new IllegalArgumentException("Wähle zuerst eine Antwort.");boolean correct=request.isReveal()?false:expected(payload,step).equals(request.getValue());state.attempts.merge(step,1,Integer::sum);if(request.isReveal()){state.revealed.add(step);state.setCompleted(step,true);} else if(correct){state.setCompleted(step,true);} else if(step.equals("solve")&&state.attempts.get(step)>=3){state.setCompleted(step,true);}ObjectNode result=mapper.createObjectNode();result.put("step",step);result.put("correct",correct);result.put("resolved",state.completed(step));result.put("attempts",state.attempts.get(step));result.put("canReveal",!state.completed(step));if(state.completed(step)&&(!correct||request.isReveal()))result.put("answer",expected(payload,step));if(state.ticketDone()){result.put("ticketDone",true);result.put("explanation",payload.path("explanation").asText());result.put("solution",payload.path("solution").asText());}return result;}
@Transactional public ServiceClashResult finish(String username,ServiceClashFinishRequest request,HttpSession http){if(request==null||request.getSlug()==null||request.getSlug().isBlank()||request.getDurationSeconds()<1||request.getDurationSeconds()>MAX_DURATION)throw new IllegalArgumentException("Unplausibler Abschluss.");Run run=run(http);if(!run.slug.equals(request.getSlug()))throw new IllegalArgumentException("Diese Schicht wurde nicht angefordert.");ServiceClashShift shift=shift(request.getSlug());int score=0,max=0,resolved=0,health=100;boolean flawless=true;for(ServiceClashTicket ticket:shift.getTickets()){max+=ticket.getPoints();State state=run.states.get(ticket.getId());if(state!=null&&state.ticketDone()){resolved++;double ratio=stepRatio(state,"classify",.20)+stepRatio(state,"priority",.30)+stepRatio(state,"solve",.50);score+=(int)Math.round(ticket.getPoints()*ratio);if(ratio<1)flawless=false;}else{health-=15;flawless=false;}}health=Math.max(0,health);int stars=stars(score,max);ScoreResult saved=scores.recordScore(username,GAME,shift.getSlug(),score,resolved,request.getDurationSeconds());if(resolved>0)achievements.awardSpecial(username,"Leitstand-Einsatz","Schließe eine ServiceClash-Schicht mit Punkten ab.");if(flawless&&resolved==shift.getTickets().size())achievements.awardSpecial(username,"Incident Commander","Löse eine komplette ServiceClash-Schicht ohne Fehler oder Aufdecken.");http.removeAttribute(SESSION);return new ServiceClashResult(score,max,stars,resolved,shift.getTickets().size(),health,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays(),flawless);}
private double stepRatio(State state,String step,double weight){if(!state.completed(step)||state.revealed.contains(step))return 0;int attempts=state.attempts.getOrDefault(step,1);return weight*(attempts==1?1:attempts==2?.6:.3);} private String expected(ObjectNode payload,String step){return switch(step){case "classify"->payload.path("category").asText();case "priority"->payload.path("priority").asText();default->payload.path("solution").asText();};}private ServiceClashShift shift(String slug){return shifts.findBySlug(slug).filter(ServiceClashShift::isActive).orElseThrow(()->new IllegalArgumentException("Schicht nicht gefunden."));}private ObjectNode parse(String raw){try{JsonNode node=mapper.readTree(raw);return node instanceof ObjectNode value?value:null;}catch(Exception e){return null;}}private int stars(int score,int max){if(score<=0||max<=0)return 0;double ratio=(double)score/max;return ratio>=.8?3:ratio>=.5?2:1;}private Run run(HttpSession http){Object value=http.getAttribute(SESSION);if(value instanceof Run run)return run;throw new IllegalArgumentException("Wähle zuerst eine Schicht aus.");}
static class Run {final String slug;final Map<Long,ObjectNode> payloads;final Map<Long,State> states=new HashMap<>();Run(String slug,Map<Long,ObjectNode> payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State {final Map<String,Integer> attempts=new HashMap<>();final Set<String> revealed=new HashSet<>(),done=new HashSet<>();boolean completed(String step){return done.contains(step);}void setCompleted(String step,boolean value){if(value)done.add(step);}boolean ticketDone(){return done.containsAll(Set.of("classify","priority","solve"));}}
}
@@ -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);
@@ -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-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-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-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-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-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)} body[class*="mf-a11y-skin-"] .mf-a11y-head{background:color-mix(in srgb,var(--mf-a11y-accent) 28%,#07121f)}
File diff suppressed because one or more lines are too long
@@ -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 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){}} 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(); 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 '<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 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>'; 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>'; 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>';
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=`<div class="sc-eyebrow">Service Desk Simulation</div><h1 class="sc-title">Welche Schicht übernimmst du?</h1><p class="sc-intro">ServiceClash erklärt die Triage direkt im Spiel: Ordne jedes Ticket zuerst als <b>Störung</b> oder <b>Anfrage</b> 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.</p><div class="sc-shifts">${catalog.map(s=>`<article class="sc-card"><div class="sc-eyebrow">${esc(s.difficulty)} · ${esc(s.examScope)}</div><h2>${esc(s.title)}</h2><p>${esc(s.description)}</p><div class="sc-meta"><span>${s.ticketCount} Tickets</span><span>${s.waves} Wellen</span></div><button class="sc-btn primary" data-shift="${esc(s.slug)}">SCHICHT BRIEFEN</button></article>`).join('')}</div>`;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=`<article class="sc-panel sc-brief"><div class="sc-eyebrow">${esc(session.difficulty)} · ${session.tickets.length} Tickets</div><h1>${esc(session.title)}</h1><p class="sc-intro">${esc(session.description)}</p><p class="sc-tip"><b>So spielst du:</b> Eine <b>Störung</b> bedeutet, dass etwas bereits nicht funktioniert. Eine <b>Anfrage</b> wünscht eine geplante Leistung. Priorität <b>hoch</b> wählst du bei größerer Auswirkung, Sicherheitsrisiko oder Zeitkritik. Die Punkte berücksichtigen alle drei Entscheidungen.</p><button id="scStart" class="sc-btn primary">LEITSTAND ÖFFNEN →</button></article>`;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=`<div class="sc-command"><aside class="sc-panel sc-queue"><div class="sc-eyebrow">${esc(session.title)}</div><div class="sc-stat">Gelöst ${done}/${session.tickets.length}</div><div class="sc-meter"><i style="width:${done/session.tickets.length*100}%"></i></div><div class="sc-ticket-list">${session.tickets.map((t,i)=>`<button class="${i===index?'active':''} ${ticketState(t).done?.length===3?'done':''}" data-ticket="${i}">W${t.wave} · ${esc(t.title)}</button>`).join('')}</div></aside><article class="sc-panel sc-ticket"><div class="sc-ticket-head"><span>WELLE ${ticket.wave}/${wave} · ${esc(ticket.requester)}</span><span>${ticket.points} PTS</span></div><h1>${esc(ticket.title)}</h1><p>${esc(ticket.text)}</p><div class="sc-steps"><span class="sc-step ${state.step==='classify'?'active':''} ${state.done.includes('classify')?'done':''}">1 ART</span><span class="sc-step ${state.step==='priority'?'active':''} ${state.done.includes('priority')?'done':''}">2 PRIORITÄT</span><span class="sc-step ${state.step==='solve'?'active':''} ${state.done.includes('solve')?'done':''}">3 MASSNAHME</span></div>${choices}${state.feedback?`<div class="sc-feedback ${state.bad?'bad':''}">${state.feedback}</div>`:''}${state.done.length===3?`<div class="sc-debrief"><b>Debrief:</b> ${esc(state.explanation||'Ticket dokumentiert.')}<br><button id="scNext" class="sc-btn primary">${index+1===session.tickets.length?'SCHICHT AUSWERTEN':'NÄCHSTES TICKET →'}</button></div>`:''}</article></div>`;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 `<div class="sc-choices"><button class="sc-choice" data-value="incident"><b>Störung</b><br><small>Etwas funktioniert bereits nicht.</small></button><button class="sc-choice" data-value="request"><b>Anfrage</b><br><small>Eine Leistung soll geplant erbracht werden.</small></button></div>${revealButton()}`;if(state.step==='priority')return `<div class="sc-choices"><button class="sc-choice" data-value="high"><b>Hoch</b><br><small>Sicherheits-, Geschäfts- oder Zeitrisiko.</small></button><button class="sc-choice" data-value="medium"><b>Mittel</b><br><small>Spürbar, aber mit begrenzter Auswirkung.</small></button><button class="sc-choice" data-value="low"><b>Niedrig</b><br><small>Planbar, lokal oder ohne akute Auswirkung.</small></button></div>${revealButton()}`;return `<div class="sc-choices">${ticket.options.map(o=>`<button class="sc-choice" data-value="${esc(o.id)}">${esc(o.text)}</button>`).join('')}</div>${revealButton()}`}
const revealButton=()=>`<button id="scReveal" class="sc-reveal">Antwort aufdecken (keine Punkte für diesen Schritt)</button>`;
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: <b>${esc(label(result.answer,ticket))}</b>.`:''}`;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=`<article class="sc-panel sc-result"><div class="sc-eyebrow">Schicht abgeschlossen</div><h1>${result.flawless?'Leitstand souverän geführt':'Auswertung bereit'}</h1><div class="sc-score">${result.score} <small>/ ${result.maxScore} PTS</small></div><p>${'★'.repeat(result.stars)}${'☆'.repeat(3-result.stars)}</p><div class="sc-result-grid"><div><b>${result.resolved}/${result.total}</b> Tickets gelöst</div><div><b>${result.health}%</b> Service-Gesundheit</div><div><b>${result.highscore}</b> Highscore</div></div><p>${result.newHighscore?'Neuer Highscore gespeichert. ':''}${result.flawless?'Fehlerfreie Schicht – stark.':'Wiederhole eine Schicht: Tickets und Reihenfolge bleiben nachvollziehbar, deine Entscheidungen werden neu bewertet.'}</p><button id="scAgain" class="sc-btn primary">SCHICHTEN ANSEHEN</button></article>`;document.getElementById('scAgain').onclick=load}catch(error){fail(error)}}
function fail(error){view.innerHTML=`<article class="sc-panel sc-brief"><h1>Leitstand nicht erreichbar</h1><p>${esc(error.message||'Unbekannter Fehler')}</p><button class="sc-btn primary" onclick="location.reload()">NEU LADEN</button></article>`}async function load(){try{catalog=(await api('/catalog')).shifts||[];renderCatalog()}catch(error){fail(error)}}load();})();
+2 -2
View File
@@ -32,13 +32,13 @@
document.head.appendChild(accessibilityCss); document.head.appendChild(accessibilityCss);
var accessibilityPlacementCss = document.createElement('link'); var accessibilityPlacementCss = document.createElement('link');
accessibilityPlacementCss.rel = 'stylesheet'; accessibilityPlacementCss.rel = 'stylesheet';
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=7'; accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=8';
accessibilityPlacementCss.setAttribute('data-accessibility', ''); accessibilityPlacementCss.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityPlacementCss); document.head.appendChild(accessibilityPlacementCss);
} }
if (!document.querySelector('script[data-accessibility]')) { if (!document.querySelector('script[data-accessibility]')) {
var accessibilityScript = document.createElement('script'); var accessibilityScript = document.createElement('script');
accessibilityScript.src = '/js/accessibility.js?v=6'; accessibilityScript.src = '/js/accessibility.js?v=7';
accessibilityScript.defer = true; accessibilityScript.defer = true;
accessibilityScript.setAttribute('data-accessibility', ''); accessibilityScript.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityScript); document.head.appendChild(accessibilityScript);
+1 -1
View File
@@ -137,7 +137,7 @@
['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'],
['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/codeescape'], ['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'], ['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'], ['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'] ['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
@@ -0,0 +1 @@
<!doctype html><html lang="de" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ServiceClash · Mindflux</title><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/serviceclash.css?v=1"><script src="/js/theme.js"></script></head><body><main class="sc-app"><header class="sc-top"><div class="sc-nav"><a href="/dashboard">← Dashboard</a><a href="/serviceclash" class="sc-nav-button">SCHICHTEN</a></div><div class="sc-brand"><b>ServiceClash</b><span>LEITSTAND · TRIAGE</span></div><span class="mf-a11y-host"></span></header><section id="scView" class="sc-view"><p class="sc-loading">LEITSTAND WIRD HOCHGEFAHREN …</p></section></main><script src="/js/serviceclash.js?v=1"></script></body></html>