@@ -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
|
||||
|
||||
+45
-16
@@ -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 = "<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-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)}
|
||||
|
||||
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 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 '<button type="button" class="mf-a11y-option" data-a11y="'+id+'"><strong aria-hidden="true">'+icon+'</strong><span>'+label+'</span>'+(detail?'<small>'+detail+'</small>':'')+'</button>';}
|
||||
function init(){if(document.querySelector('.mf-a11y-trigger'))return;var trigger=document.createElement('button');trigger.type='button';trigger.className='mf-a11y-trigger';trigger.setAttribute('aria-label','Barrierefreiheit öffnen');trigger.setAttribute('aria-expanded','false');trigger.setAttribute('aria-controls','mf-a11y-panel');trigger.innerHTML='<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="18" cy="5" r="3" fill="currentColor"/><path d="M13 11l5-2 5 4m-7-2-2 7-6 1m8-4 6 2 3 7M13 18a7 7 0 1 0 6 7"/></svg>';
|
||||
var panel=document.createElement('section');panel.id='mf-a11y-panel';panel.className='mf-a11y-panel';panel.hidden=true;panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','false');panel.setAttribute('aria-labelledby','mf-a11y-title');panel.innerHTML='<div class="mf-a11y-head"><button type="button" class="mf-a11y-close" aria-label="Barrierefreiheit schließen">×</button><h2 id="mf-a11y-title">Barrierefreiheit</h2></div><div class="mf-a11y-grid">'+button('font','T⁺','Text vergrößern','100 %')+button('contrast','◐','Hochkontrast')+button('color','◉','Farbenblind-Modus')+button('mono','◒','Monochrom')+button('readable','Aa','Lesemodus (Dyslexie)','Verdana + Abstände')+button('links','🔗','Links unterstreichen')+button('motion','Ⅱ','Animationen stoppen')+button('cursor','➤','Großer Cursor')+button('reset','↺','Alles zurücksetzen')+'</div>';
|
||||
|
||||
@@ -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=`<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();})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user