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
@@ -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=>({'&':'&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);
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);
+1 -1
View File
@@ -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>