Add TechTacular fifty-fifty joker
mindflux-ci / build (push) Successful in 34s

This commit is contained in:
AliceDarkest
2026-07-19 20:01:06 +02:00
parent 860b1ffba9
commit c5009d1718
11 changed files with 446 additions and 14 deletions
+6
View File
@@ -6,6 +6,12 @@ Versionierung [semantisch](https://semver.org/lang/de/).
## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand ## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand
### TechTacular
- **50:50-Joker:** Pro Runde kann im Frage-Overlay ein Joker zwei garantiert falsche
Antworten ausblenden und deaktivieren. Die Lösung bleibt vollständig serverseitig;
ein verwendeter Joker halbiert die Punkte der betreffenden Frage nur, wenn der
serverseitige Rundenzustand den Einsatz bestätigt.
### Konto & Sicherheit ### Konto & Sicherheit
- **E-Mail-Bestätigung:** Neue Accounts erhalten einen einmaligen, 24 Stunden - **E-Mail-Bestätigung:** Neue Accounts erhalten einen einmaligen, 24 Stunden
gültigen Bestätigungslink und werden erst nach erfolgreicher Verifikation zum gültigen Bestätigungslink und werden erst nach erfolgreicher Verifikation zum
+171
View File
@@ -0,0 +1,171 @@
# Mindflux / SysnaptiK Neural — Gesamt-Audit & Umsetzungsplan
## Kontext
Vollständiges Audit der Plattform (Spring Boot 3.4.4, Java 21, Thymeleaf, PostgreSQL, Flyway) mit Fokus auf: Light Mode, Barrierefreiheits-Widget, Header-Icons, Spielanleitungen/Hints, AP2-Inhalte in FinalProtocol, Umlaute, DSGVO und Best Practices. Ziel: ein Light Mode, der wie ein „Geschwister" des Dark Mode wirkt (gleiche Farbidentität, aber für hell entworfen statt invertiert), konsistente UI, Hilfen in jedem Spiel, prüfungstauglicher AP2-Umfang für **beide** Fachrichtungen, saubere Umlaute, DSGVO-Konformität.
**Entscheidungen:** ServiceClash/BugHunter/Networker bekommen **echte Light-Varianten** (nicht dauerhaft dunkel). AP2: **5 Themen je Fachrichtung** à 20 Fragen. Impressum bleibt **strukturierter Platzhalter** (Daten werden vor Launch geliefert).
**Audit-Kurzantworten:**
- **A11y-Widget:** Gut (9 Funktionen, ARIA, Escape, Fokus-Rückgabe), aber nicht perfekt: kein Focus-Trap, falsche `aria-pressed`-Semantik am 4-stufigen Font-Button, toter Legacy-CSS-Code, spielfremde Regeln in `accessibility-placement.css`.
- **Spiele ohne Hilfe:** MindMatch + TechTacular haben **weder Anleitung noch Hints**. SyntaxSaga, DevLab, CodeEscape haben Hints, aber keine Anleitung. BugHunter nur Auflösen-Button. Networker ist Vorbild (Guide-Panel + Lexikon).
- **AP2:** Nur je 1 Thema / 6 Fragen pro Richtung (vs. ~742 Fragen AP1/Wirtschaft). Der Ordner `systemintegration/` ist AP1-Stoff („Systeme & Netzwerke"), nicht AP2.
- **Umlaute:** `static/finalflux/questions/usv.json` und `devlab/content.json` systematisch ohne Umlaute; Einzelfälle in `zahlensysteme.html:78`, `techtacular/content.json:4250`; Mojibake (`für`) in `style.css`-Kommentaren (Z. 19/29/220).
- **DSGVO:** Chart.js von jsDelivr-CDN (`progress.html:9`) = einziges externes Asset, widerspricht eigener Datenschutzerklärung. Impressum = Platzhalter. NeuralNet-Messaging fehlt in der Datenschutzerklärung. Kein Datenexport (Art. 20). Footer mit Rechtslinks fehlt auf allen Spielseiten + Error/Auth-Seiten. Demo-User `NeuralTest` wird per Flyway (V17) in Prod eingespielt. Positiv: kein Tracking, keine Cookies außer Session, Secrets sauber, Löschkonzept (Art. 17) vorhanden.
- **Best Practices:** `@Valid`/Bean-Validation nirgends genutzt (obwohl Starter + Handler verdrahtet), keine CSP/HSTS, Spring Boot 3.4.4 veraltet, Avatar-Logik im Controller statt Service, Footer 26× dupliziert statt Fragment, `DummyController`/`hello.txt`-Reste. Positiv: kein IDOR, saubere Schichten sonst, 51 Tests, Flyway, Brute-Force-Schutz.
Basis-Pfad: `RES` = `src\main\resources`
---
## AP 0 — Quick Wins (zuerst, alles klein)
1. **Chart.js lokal vendoren**: nach `RES\static\js\vendor\chart.umd.min.js` laden, `RES\templates\progress.html:9` auf lokalen Pfad umstellen. Schließt das einzige DSGVO-Datenleck.
2. **Scaffolding löschen**: `controller/DummyController.java`, `templates/dummy.html`, `RES\static\hello.txt`.
3. **Umlaute fixen**:
- `RES\static\finalflux\questions\usv.json` — kompletter Durchgang (fuer→für, ueberbrueckt→überbrückt, heisst→heißt, …).
- `RES\devlab\content.json` — ~30 Stellen (hints/instructions/successText). **Achtung:** DevLab wird per `DevLabSeeder` in die DB geseedet (existenzgeprüft = idempotent) → prüfen, ob Fixes im JSON bestehende DB-Zeilen erreichen; falls nicht: Seeder auf Upsert umstellen **oder** Flyway-Migration `V21__fix_devlab_umlauts.sql`.
- `RES\static\finalflux\ap1\zahlensysteme.html:78` (`fuer`), `RES\techtacular\content.json:4250` (`zusaetzlich`, gleiche Seeder-Prüfung), `RES\static\css\style.css` Z. 19/29/220 (Mojibake-Kommentare).
- QA-Grep danach: `fuer|ueber|heisst|koennen|moechte|zurueck|loeschen|zusaetzlich` über resources → nur URLs/Slugs/IDs dürfen übrig bleiben.
4. **FinalProtocol-Titel**: `RES\templates\finalflux.html:5` „FinalProtocol AP1" → „FinalProtocol", Hero (Z. 75–83) auf AP1 **und** AP2 umformulieren.
---
## AP 1 — Light Mode (Kern des Redesigns)
Architektur bleibt: Tokens via `data-theme` auf `<html>`, zwei Ebenen (`theme.css` Amber-Palette, `platform-neural.css` Grün-Override für Plattform-Shell). Alle Werte unten sind arithmetisch auf WCAG AA geprüft (≥4,5:1 Text, ≥3:1 UI).
### 1a. `RES\static\css\theme.css` — Light-Block (Z. 33–54) nachschärfen
Kein Token-Split (`--accent-text`), stattdessen Light-Akzente abdunkeln, bis ein Token Text + Grafik + weiße Ink trägt:
| Token | Alt | Neu | Kontrast |
|---|---|---|---|
| `--accent` | #d65116 | **#c2410c** | 5,18 (auch weiße Ink darauf: 5,18) |
| `--accent-2` | #e4801d | **#b45309** | 5,02 (heilt `.login-box h2`) |
| `--muted` | #8a7d6c | **#6b5f4c** | 6,24 / 5,79 auf #fbf6ec |
| `--secondary` (+`--success`) | #1f86b8 | **#146b96** | 5,88 / 5,46 |
| `--secondary-2` | #2d9cce | **#0f5a80** | passt |
| NEU `--warn` | — | hell **#8a6a06** / dunkel **#edcb4c** | 5,07 |
Spiel-Tokens im selben Block, nur die Durchfaller: `--esc-accent`→#a85408, `--sc-accent`→#0e7c86, `--dl-accent`→#9c4d0f, `--ss-secondary`→#8a5710, `--ff-accent`→#8c5e10. Zugehörige `*-glow`-rgba nachziehen.
### 1b. `RES\static\css\platform-neural.css` — Light-Kartenakzente (der Hauptbug)
Z. 41–49 leaken Neon-Akzente auf fast-weiße Karten. Neuer Block `html[data-theme="light"].sn-platform body .mf-card-<name>` mit farbton-treu abgedunkelten Akzenten (alle ≥4,65:1 auf Kartengrund):
mindmatch **#a62c74**, techtacular **#67700a**, devlab **#167a52**, syntaxsaga **#6742d6**, codeescape **#8a6a06**, serviceclash **#0e7c86**, bughunter **#c22736**, networker **#1668a8**, finalprotocol **#b5490f** — je mit `--glow-s: rgba(<hex>,.22)` und hellerem `--accent-2` für Grafik-Deko. Dazu Light-Variante des finalflux-Shell-Overrides (Z. 57–60).
### 1c. Hardcodes
`RES\templates\dashboard.html:19–20`: `#edcb4c` → `var(--warn)` + `color-mix(in srgb, var(--warn) 48%, transparent)`.
### 1d. Echte Light-Varianten für die 3 hardgecodeten Spiele
Vorgehen je Spiel: alle Roh-Hexwerte in scoped Custom Properties überführen (`.sc-body{--sc-*}` usw. auf `<body>`-Klasse, damit A11y-Skin-Variablen sie sehen), Dark-Defaults = heutige Werte, Light-Block `[data-theme="light"] .xx-body{…}` mit handentworfener Palette gleicher Farbidentität:
- **ServiceClash** (`serviceclash.css`, heute bg #071015 / accent #2bb6d7): Light = kühles NOC-Hell: bg **#eef6f8**, surface #ffffff, surface-2 #e2eff2, text **#10333b**, muted #52707a (4,6:1), accent **#0e7c86**, border #cfe3e8. Zusätzlich: `RES\templates\serviceclash.html` bekommt den fehlenden `.mf-theme`-Toggle in den Header (`.sc-nav`).
- **Networker** (`networker.css`, dunkelblauer Gradient + fälschlich `var(--text)`): Light = helles Netz-Blau: bg **#eef4fb**, surface #ffffff, text **#16324a**, muted #54708c, accent **#1668a8**, border #d3e2f0. Alle `var(--text)`/`var(--muted)`-Nutzungen auf `--nw-text`/`--nw-muted` umstellen.
- **BugHunter** (`bughunter.css`, Holz-Braun #231b18/#3a2d28): Light = Papier/Archiv-Look: bg **#f7f1e8**, surface #fffaf2, Schubladen helles Holz #e8d9c4/#d9c5a8, text **#33261d**, muted #6f5f4e, accent **#c22736**, border #e2d3bd. Die BugHunter/Networker-Regeln aus `accessibility-placement.css:21–23` in die jeweiligen Spiel-CSS verschieben (ohne `!important`).
**Check:** jeder Spiel-Header, dessen Spiel jetzt theme-fähig ist, braucht den `.mf-theme`-Toggle (theme.js verdrahtet ihn automatisch); mindestens serviceclash.html fehlt er.
---
## AP 2 — Header-Icons vereinheitlichen
Eine Spezifikation für alle: **42×42 px, border-radius 10px, Glyphe 20×20, Outline-Stil** (Surface-Fill, 1px Border, Akzent nur über `color`/`border-color`):
1. `theme.css:112` `.mf-iconbtn` → 42×42; `:114` svg → 20×20.
2. `theme.css:120–121` `.mf-theme` → 42×42, radius 10px (Ellipse weg), svg 20×20; Akzentfarbe/Glow bleibt als Erkennungsmerkmal.
3. `accessibility-placement.css:2–3` Trigger → 42×42, svg 20×20; Z. 16: gefüllten Gradient entfernen → `background:var(--surface); color:var(--mf-a11y-accent); border-color:color-mix(…)`.
4. `accessibility.css:26` Legacy-56px-Kreis-Trigger löschen, saubere Basisregel im `.mf-iconbtn`-Look (Fokus-Outline behalten).
5. `fluxtimer.css:3` `!important` entfernen; `.ft-trigger-icon` 20×20, `stroke-width:1.5`.
6. Sichtprüfung: `.nn-unread`-Badge-Position bei 42px, `.mf-search`-Höhe angleichen. `STYLEGUIDE.md` aktualisieren.
---
## AP 3 — Barrierefreiheits-Widget perfektionieren
In `RES\static\js\accessibility.js` / `accessibility.css`:
1. **Focus-Trap** im Panel (Tab/Shift+Tab zyklisch über Close + 9 Options-Buttons), `aria-modal="true"`, Klick-außerhalb schließt. Escape + Fokus-Rückgabe existieren schon.
2. **Font-Button**: boolean `aria-pressed` ersetzen durch dynamisches `aria-label` („Textgröße ändern – aktuell 115 %") + `.is-active`-Klasse ab >100 %; `aria-pressed` vom Reset-Button entfernen (Aktion, kein Toggle).
3. **Toten Dock-CSS löschen** (`accessibility.css:25` body-padding-72px, `.mf-a11y-dock`, Fixed-Trigger) und die dadurch obsoleten Resets in `accessibility-placement.css` einkürzen.
4. **Hochkontrast bleibt bewusst dunkel** auch im Light Mode (maximaler Kontrast, deterministisch) — im Button-Untertitel kenntlich machen („immer dunkel") + CSS-Kommentar.
5. Cache-Buster bumpen (theme.js: accessibility.css v6, placement v10, accessibility.js v9, fluxtimer.css v5; theme.css v4 in allen Templates).
---
## AP 4 — Spielanleitungen & Hints
### 4a. Gemeinsames Guide-Modul „So funktioniert's"
Neu: `RES\static\js\game-guide.js` + `RES\static\css\game-guide.css`, nach Vorbild `networker-guide.js` (IIFE, deutsches Inline-Registry als Template-Literals, kein Fetch). Injektion über `theme.js` nur auf Game-Routen (Liste existiert dort in `isGameRoute`, Z. 15) — null Template-Edits, wie FluxTimer.
- „?"-Button (`aria-label="Spielanleitung öffnen"`) in die jeweilige Header-Tools-Leiste; Erstbesuch öffnet Overlay automatisch (localStorage `mf-guide-seen:<slug>`), danach per „?" reopenbar; Escape/Backdrop schließt.
- API `window.MindfluxGuide = {open, close, registerHintProvider({label, remaining, onHint})}` für einheitliche Tipp-Buttons.
- Rollout: MindMatch, TechTacular, SyntaxSaga, DevLab-Game, CodeEscape, BugHunter, ServiceClash (kondensiert, Intro-Screen bleibt). **Networker bleibt unangetastet** (Inline-Guide ist stärker).
### 4b. MindMatch-Hint „Paar-Blitz"
Button „Tipp (3)" in `.mm-bar-group` (`mindmatch.html` ~Z. 35): deckt ein ungematchtes Paar ~1 s auf (wenn `firstCard` liegt: dessen Partner), kostet **+1 Zug** → Score/Sterne degradieren über die bestehende Formel, **null Backend-Änderungen** (Server rechnet aus `moves`). Hooks in `mindmatch.js`: State Z. 27–35, `newGame` Z. 241–265 (Reset auf 3), `finish`/`cancelGame` verstecken. CSS `.mm-card.hinted`-Glow.
### 4c. TechTacular „50:50-Joker" ✅ umgesetzt (2026-07-19)
`GET /api/techtacular/hint?questionId=` liefert ausschließlich bis zu zwei falsche Antwort-IDs; die korrekte ID verlässt den Service nie. Der Joker ist serverseitig pro Runde auf einen Einsatz begrenzt, markiert die konkrete Frage in der Session und wird beim Laden eines neuen Boards bzw. nach dem Abschluss zurückgesetzt. Im Antwort-Overlay werden die entfernten Optionen deaktiviert und durchgestrichen. `hintUsed` ist Teil der Answer-/Finish-DTOs; die Halbierung wird jedoch nur gewertet, wenn die Session den Joker-Einsatz für genau diese Frage bestätigt. Ein Endpoint-Test sichert JSON-Form und Weiterleitung ab.
### 4d. Optional (Phase 2): BugHunter progressive Hinweise (2 Stufen vor „Auflösen", nach CodeEscape-Vorbild).
---
## AP 5 — FinalProtocol: AP2-Ausbau (5 Themen je Richtung, je 20 Fragen)
Skelett exakt von `RES\static\finalflux\ap2-fisi\hochverfuegbarkeit.html` übernehmen (Lernfokus mit 3 Zielen, 2–4 Inhaltssektionen mit `key-list` + Merksätzen, Prüfungsmodus-Block verbatim, `finalflux-test.js` mit `data-questions-src`). JSON-Schema wie bestehende Dateien: `{category, hint, text, answers[3], correct (rotierend), explanation (nennt auch die Distraktor-Fallen)}`, 4–6 Kategorien pro Datei, „du"-Ansprache, **echte Umlaute (UTF-8 ohne BOM)**.
**AP2 Systemintegration** (`RES\static\finalflux\ap2-fisi\` + `questions\`):
| Seite | Inhalt |
|---|---|
| netzwerkplanung | Subnetting/VLSM, IPv6, WLAN-Planung, Routing |
| it-sicherheit | Firewall/DMZ, VPN (IPsec/TLS), sym./asym. Verschlüsselung, Zertifikate, CIA, 2FA |
| virtualisierung | Typ-1/2-Hypervisor, Container vs. VM, Snapshots, IaaS/PaaS/SaaS |
| monitoring-backup | SNMP/Schwellwerte, 3-2-1-Regel, Generationenprinzip, Wiederherstellungs-Szenarien, Serverdimensionierung |
| skripting | Shell/PowerShell-Konstrukte, Pseudocode, Cron/Aufgabenplanung, Exit-Codes |
**AP2 Anwendungsentwicklung** (`ap2-fiae\`): **uml** (Klassen-/Sequenz-/Aktivitätsdiagramm, Multiplizitäten), **sql** (Joins, GROUP BY/HAVING, DDL/DML, Normalisierung), **oop** (Kapselung, Vererbung vs. Komposition, Polymorphie, Interfaces), **algorithmen** (Pseudocode/Struktogramm, Sortieren/Suchen, O-Notation, Rekursion), **testverfahren** (Blackbox/Whitebox, Äquivalenzklassen/Grenzwerte, Testebenen; + Kategorie REST/JSON/XML).
→ je Richtung ~106 Fragen inkl. Bestand. Navigation: `RES\templates\finalflux.html` Z. 62–70 um je 5 Links erweitern; optional „AP2-Kompass"-Panel neben dem AP1-Kompass. `systemintegration/`-Sektion bleibt als AP1 „Systeme & Netzwerke" unverändert.
---
## AP 6 — DSGVO
1. **Footer-Fragment rollout**: `fragments/layout.html:87–94` (`th:fragment="footer"`) existiert, wird 0× genutzt → alle ~26 Inline-Footer durch `th:replace="~{fragments/layout :: footer}"` ersetzen **und** auf die 7 Spielseiten + `error.html` + `forgot-password.html`/`reset-password.html` bringen (Spielseiten ggf. schlanke CSS-Anpassung, damit der Look nicht bricht). Rechtslinks damit von jeder Seite erreichbar.
2. **Datenschutzerklärung vervollständigen** (`templates/legal/datenschutz.html`): NeuralNet-Messaging als Verarbeitungstätigkeit (Inhalte, Sender/Empfänger, Zeitstempel, Lesebestätigungen, Löschung via Kaskade), E-Mail-Token (SHA-256, 24 h TTL) + SMTP-Versand über `mail.sysnaptik.de` als Empfänger, konkrete Speicherfristen (Konto, Logs, Login-Versuche, Nachrichten), Drittland-Aussage nach CDN-Entfernung wieder korrekt.
3. **Impressum strukturieren** (`templates/impressum.html`): saubere Struktur mit klar markierten Pflichtfeldern (Name, Anschrift, E-Mail, Vertretung) als auffällige Platzhalter — werden vor Launch ausgefüllt.
4. **Support/Feedback-Links** (`fragments/layout.html:88–89`, `href="#"`): auf `mailto:`-Platzhalter konsistent zum Impressum umstellen oder bis dahin entfernen (Betroffenenrechte brauchen einen Kanal).
5. **Datenexport Art. 20**: `GET /profile/export` in `ProfileController` → Service sammelt User-Stammdaten, Scores, Achievements, Fortschritt, NeuralNet-Daten → JSON-Download. Test ergänzen.
6. **Demo-User `NeuralTest` entschärfen**: neue Migration `V22__remove_neuraltest_demo_user.sql` (Löschen, Kaskaden räumen auf); falls für Dev nötig, als profile-gebundenen Runner (`@Profile("dev")`) neu anlegen.
---
## AP 7 — Best Practices
1. **Bean Validation aktivieren**: `@Valid` + `@NotBlank/@Size/@Email` auf Request-DTOs (`ScoreSubmission`, BugHunter/Techtacular-Requests); die rohen `Map<String,String>`-Bodies in `NeuralNetApiController` durch typisierte DTOs ersetzen. Handler (`ApiExceptionHandler:45–51`) ist schon verdrahtet.
2. **Security-Header** (`SecurityConfig.java`): `.headers()`-Block mit CSP (nach Chart.js-Vendoring machbar; pragmatisch starten mit `default-src 'self'` + `style-src 'self' 'unsafe-inline'` wegen Inline-Styles in Templates), HSTS (nur Prod), Referrer-Policy.
3. **Spring Boot bumpen**: 3.4.4 → aktuellstes 3.4.x-Patch (`pom.xml:11`), Testlauf.
4. **Avatar-Logik aus `ProfileController` (Z. 115–172) in einen `AvatarService`** extrahieren (Bild-Dekodierung/Skalierung/Datei-I/O, Pfad-Logik heute 3× dupliziert).
5. **CSRF** (niedrige Prio): Blanket-Exemption für `/api/**` (SecurityConfig:42–46) durch Cookie-basierten CSRF-Token für JSON ersetzen.
6. **Tests**: Web-Layer-Tests für neue Endpoints (Techtacular-Hint, Profil-Export).
---
## Reihenfolge
1. AP 0 Quick Wins → 2. AP 1 Light Mode (Tokens → Kartenakzente → 3 Spiele) → 3. AP 2 Icons → 4. AP 3 Widget → 5. AP 4 Guides/Hints → 6. AP 5 AP2-Content (parallelisierbar zu 2–5) → 7. AP 6 DSGVO → 8. AP 7 Best Practices → 9. Gesamtverifikation.
## Verifikation
- **Build/Run**: `mvn spring-boot:run` in `mindflux2` (oder `docker-compose up`); `mvn test` muss grün bleiben.
- **Light Mode visuell**: Screenshots hell **und** dunkel: Dashboard (Kartengrid + `.mf-play`-Hover), Login, Settings, Progress, Soziales und **alle 9 Spiele** (insb. ServiceClash/Networker/BugHunter in beiden Themes); Hochkontrast- und Farbenblind-Modus je einmal gegenprüfen.
- **Icons**: Topbar Dashboard + ein Spiel-Header, beide Themes; Badge-Positionen.
- **Widget manuell**: Tab-Zyklus im Panel, Escape, Fokus-Rückgabe, Screenreader-Label des Font-Buttons.
- **Guides**: `localStorage` `mf-guide-seen:*` löschen → jedes Spiel: Auto-Open einmalig, „?" reopenbar, `/networker` unverändert. MindMatch: Tipp zählt Zug hoch, Server-Score stimmt. TechTacular: Joker eliminiert Falsche, halbe Punkte bei `hintUsed`, `/hint` leakt nie die richtige ID.
- **AP2**: alle 10 neuen Links erreichbar, je ein kompletter Quiz-Durchlauf (antworten, überspringen, neu starten, Kategorien-Summary), JSON-Validierung (`python -m json.tool`), `0 <= correct < answers.length`.
- **Umlaut-QA**: Grep-Muster (s. AP 0) ohne Treffer in sichtbaren Texten; Browser-Darstellung stichprobenartig.
- **DSGVO**: Netzwerk-Tab auf `/progress` → keine externen Requests; Footer-Links auf jeder Seite; Export-Download enthält alle Datenkategorien; `NeuralTest` nach Migration weg.
## Offene Punkte (vor Launch)
- Impressumsdaten (Name, Anschrift, E-Mail) liefern → Impressum + §1 Datenschutzerklärung.
- Support-/Feedback-Zieladresse bestätigen.
- Gesamtumfang ist groß (v. a. AP 5 Content-Arbeit) — bei Bedarf über mehrere Sitzungen umsetzen; die Arbeitspakete sind unabhängig genug.
@@ -7,12 +7,16 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import jakarta.servlet.http.HttpSession;
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */ /** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
@RestController @RestController
@RequestMapping("/api/techtacular") @RequestMapping("/api/techtacular")
public class TechtacularApiController { public class TechtacularApiController {
private static final String JOKER_USED = "techtacular.fiftyFiftyUsed";
private static final String HINTED_QUESTION_IDS = "techtacular.hintedQuestionIds";
private final TechtacularService techtacularService; private final TechtacularService techtacularService;
public TechtacularApiController(TechtacularService techtacularService) { public TechtacularApiController(TechtacularService techtacularService) {
@@ -21,7 +25,10 @@ public class TechtacularApiController {
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */ /** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
@GetMapping("/board") @GetMapping("/board")
public TechtacularBoardView board(@RequestParam(required = false) String exclude) { public TechtacularBoardView board(@RequestParam(required = false) String exclude, HttpSession session) {
// Ein neues Board beginnt immer eine neue Runde mit genau einem Joker.
session.removeAttribute(JOKER_USED);
session.removeAttribute(HINTED_QUESTION_IDS);
return techtacularService.buildBoard(parseIds(exclude)); return techtacularService.buildBoard(parseIds(exclude));
} }
@@ -40,12 +47,41 @@ public class TechtacularApiController {
} }
@PostMapping("/answer") @PostMapping("/answer")
public AnswerResult answer(@RequestBody AnswerRequest request) { public AnswerResult answer(@RequestBody AnswerRequest request, HttpSession session) {
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId()); // Das Client-Flag dient der UI und dem Finish-Payload. Ob die Punkte halbiert
// werden, entscheidet jedoch allein der serverseitige Rundenzustand.
boolean validHint = hintedQuestionIds(session).contains(request.getQuestionId());
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId(), validHint);
}
/** Liefert zwei eliminierbare, garantiert falsche Antworten ohne die Loesung offenzulegen. */
@GetMapping("/hint")
public FiftyFiftyHint hint(@RequestParam Long questionId, HttpSession session) {
if (Boolean.TRUE.equals(session.getAttribute(JOKER_USED))) {
return new FiftyFiftyHint(java.util.List.of());
}
FiftyFiftyHint hint = techtacularService.fiftyFifty(questionId);
if (!hint.removeAnswerIds().isEmpty()) {
session.setAttribute(JOKER_USED, true);
Set<Long> hinted = new HashSet<>();
hinted.add(questionId);
session.setAttribute(HINTED_QUESTION_IDS, hinted);
}
return hint;
} }
@PostMapping("/finish") @PostMapping("/finish")
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) { public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal, HttpSession session) {
return techtacularService.finish(principal.getName(), request); TechtacularResult result = techtacularService.finish(principal.getName(), request, hintedQuestionIds(session));
session.removeAttribute(JOKER_USED);
session.removeAttribute(HINTED_QUESTION_IDS);
return result;
}
@SuppressWarnings("unchecked")
private Set<Long> hintedQuestionIds(HttpSession session) {
Object value = session.getAttribute(HINTED_QUESTION_IDS);
return value instanceof Set<?> ? (Set<Long>) value : Set.of();
} }
} }
@@ -4,10 +4,15 @@ package com.example.mindflux.dto;
public class AnswerRequest { public class AnswerRequest {
private Long questionId; private Long questionId;
private Long answerId; private Long answerId;
private boolean hintUsed;
public Long getQuestionId() { return questionId; } public Long getQuestionId() { return questionId; }
public void setQuestionId(Long questionId) { this.questionId = questionId; } public void setQuestionId(Long questionId) { this.questionId = questionId; }
public Long getAnswerId() { return answerId; } public Long getAnswerId() { return answerId; }
public void setAnswerId(Long answerId) { this.answerId = answerId; } public void setAnswerId(Long answerId) { this.answerId = answerId; }
/** Der Client zeigt damit an, dass fuer diese Antwort der 50:50-Joker aktiv war. */
public boolean isHintUsed() { return hintUsed; }
public void setHintUsed(boolean hintUsed) { this.hintUsed = hintUsed; }
} }
@@ -0,0 +1,7 @@
package com.example.mindflux.dto;
import java.util.List;
/** Antwort des 50:50-Jokers. Die korrekte Antwort-ID wird niemals uebertragen. */
public record FiftyFiftyHint(List<Long> removeAnswerIds) {
}
@@ -85,6 +85,12 @@ public class TechtacularService {
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */ /** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId) { public AnswerResult checkAnswer(Long questionId, Long answerId) {
return checkAnswer(questionId, answerId, false);
}
/** Bewertet eine Antwort und reduziert nur bei einem gueltig eingesetzten Joker die Punkte. */
@Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId, boolean hintUsed) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null); TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) { if (question == null) {
return new AnswerResult(false, null, "", 0); return new AnswerResult(false, null, "", 0);
@@ -92,12 +98,36 @@ public class TechtacularService {
TechtacularAnswer correct = question.correctAnswer(); TechtacularAnswer correct = question.correctAnswer();
Long correctId = correct != null ? correct.getId() : null; Long correctId = correct != null ? correct.getId() : null;
boolean isCorrect = correctId != null && correctId.equals(answerId); boolean isCorrect = correctId != null && correctId.equals(answerId);
return new AnswerResult(isCorrect, correctId, question.getExplanation(), isCorrect ? question.getPoints() : 0); int points = isCorrect ? question.getPoints() : 0;
if (hintUsed) {
points /= 2;
}
return new AnswerResult(isCorrect, correctId, question.getExplanation(), points);
}
/**
* Waehlt bis zu zwei falsche Antworten aus. Die korrekte Antwort wird dabei weder
* entfernt noch als separate Information an das Frontend uebermittelt.
*/
@Transactional(readOnly = true)
public FiftyFiftyHint fiftyFifty(Long questionId) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) {
return new FiftyFiftyHint(List.of());
}
List<Long> removableIds = question.getAnswers().stream()
.filter(answer -> !answer.isCorrect())
.map(TechtacularAnswer::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(ArrayList::new));
Collections.shuffle(removableIds, random);
return new FiftyFiftyHint(removableIds.stream().limit(2).toList());
} }
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */ /** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
@Transactional @Transactional
public TechtacularResult finish(String username, FinishRequest request) { public TechtacularResult finish(String username, FinishRequest request, Set<Long> hintedQuestionIds) {
int score = 0; int score = 0;
int maxScore = 0; int maxScore = 0;
int correctCount = 0; int correctCount = 0;
@@ -116,7 +146,11 @@ public class TechtacularService {
maxScore += question.getPoints(); maxScore += question.getPoints();
TechtacularAnswer correct = question.correctAnswer(); TechtacularAnswer correct = question.correctAnswer();
if (correct != null && correct.getId().equals(answer.getAnswerId())) { if (correct != null && correct.getId().equals(answer.getAnswerId())) {
score += question.getPoints(); // hintUsed reist mit dem DTO fuer eine nachvollziehbare Client-UI;
// fuer die Wertung ist ausschliesslich die Session-Information massgeblich.
boolean jokerUsedForQuestion = hintedQuestionIds != null
&& hintedQuestionIds.contains(question.getId());
score += jokerUsedForQuestion ? question.getPoints() / 2 : question.getPoints();
correctCount++; correctCount++;
} }
} }
@@ -128,6 +162,12 @@ public class TechtacularService {
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays()); saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
} }
/** Kompatibilitaetsvariante fuer Aufrufer ohne rundenbezogenen Joker-Zustand. */
@Transactional
public TechtacularResult finish(String username, FinishRequest request) {
return finish(username, request, Set.of());
}
// ---------- Admin ---------- // ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */ /** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
@@ -225,6 +225,17 @@ body.tt-body {
.tt-q-answers { display: grid; gap: 0.6rem; } .tt-q-answers { display: grid; gap: 0.6rem; }
.tt-joker-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.8rem;
margin: 0 0 0.9rem;
}
.tt-joker { padding: 0.42rem 0.75rem; font-size: 0.82rem; }
.tt-joker:disabled { cursor: default; opacity: 0.62; }
.tt-joker-note { color: var(--muted); font-size: 0.78rem; }
.tt-answer { .tt-answer {
text-align: left; text-align: left;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@@ -240,6 +251,13 @@ body.tt-body {
.tt-answer:disabled { cursor: default; } .tt-answer:disabled { cursor: default; }
.tt-answer.correct { border-color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); } .tt-answer.correct { border-color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); }
.tt-answer.wrong { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } .tt-answer.wrong { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.tt-answer.eliminated {
border-color: var(--border);
background: color-mix(in srgb, var(--surface-2) 65%, transparent);
color: var(--muted);
opacity: 0.62;
text-decoration: line-through;
}
.tt-q-feedback { margin-top: 0.9rem; font-weight: 700; min-height: 1.2rem; } .tt-q-feedback { margin-top: 0.9rem; font-weight: 700; min-height: 1.2rem; }
.tt-q-feedback.correct { color: var(--success); } .tt-q-feedback.correct { color: var(--success); }
+44 -4
View File
@@ -21,6 +21,8 @@
const qAnswers = document.getElementById('tt-q-answers'); const qAnswers = document.getElementById('tt-q-answers');
const qFeedback = document.getElementById('tt-q-feedback'); const qFeedback = document.getElementById('tt-q-feedback');
const qNext = document.getElementById('tt-q-next'); const qNext = document.getElementById('tt-q-next');
const jokerBtn = document.getElementById('tt-fifty-fifty');
const jokerNote = document.getElementById('tt-joker-note');
const resultEl = document.getElementById('tt-result'); const resultEl = document.getElementById('tt-result');
const resultScore = document.getElementById('tt-result-score'); const resultScore = document.getElementById('tt-result-score');
@@ -38,7 +40,8 @@
let timerId = null; let timerId = null;
let running = false; let running = false;
let current = null; // gerade offene Kachel let current = null; // gerade offene Kachel
const submitted = []; // {questionId, answerId} für /finish let jokerUsed = false; // genau ein Joker pro Runde
const submitted = []; // {questionId, answerId, hintUsed} für /finish
function formatTime(s) { function formatTime(s) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
@@ -100,6 +103,10 @@
qFeedback.textContent = ''; qFeedback.textContent = '';
qFeedback.className = 'tt-q-feedback'; qFeedback.className = 'tt-q-feedback';
qNext.hidden = true; qNext.hidden = true;
jokerBtn.disabled = jokerUsed;
jokerNote.textContent = jokerUsed
? 'Der 50:50-Joker wurde in dieser Runde bereits eingesetzt.'
: 'Entfernt zwei falsche Antworten. Richtige Antwort: halbe Punkte.';
qAnswers.innerHTML = ''; qAnswers.innerHTML = '';
state.data.answers.forEach((answer) => { state.data.answers.forEach((answer) => {
@@ -121,13 +128,13 @@
const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer')); const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer'));
buttons.forEach((b) => { b.disabled = true; }); buttons.forEach((b) => { b.disabled = true; });
submitted.push({ questionId: state.data.questionId, answerId }); submitted.push({ questionId: state.data.questionId, answerId, hintUsed: Boolean(state.hintUsed) });
try { try {
const res = await fetch('/api/techtacular/answer', { const res = await fetch('/api/techtacular/answer', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ questionId: state.data.questionId, answerId }) body: JSON.stringify({ questionId: state.data.questionId, answerId, hintUsed: Boolean(state.hintUsed) })
}); });
const data = await res.json(); const data = await res.json();
@@ -157,6 +164,38 @@
qNext.hidden = false; qNext.hidden = false;
} }
async function useFiftyFifty() {
if (!current || current.answered || jokerUsed) return;
jokerBtn.disabled = true;
jokerNote.textContent = 'Joker wird eingesetzt ...';
try {
const res = await fetch(`/api/techtacular/hint?questionId=${encodeURIComponent(current.data.questionId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const removedIds = Array.isArray(data.removeAnswerIds) ? data.removeAnswerIds : [];
if (removedIds.length === 0) {
jokerNote.textContent = 'Der Joker ist fuer diese Frage nicht verfuegbar.';
return;
}
const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer'));
current.data.answers.forEach((answer, index) => {
if (removedIds.includes(answer.id)) {
buttons[index].disabled = true;
buttons[index].classList.add('eliminated');
}
});
current.hintUsed = true;
jokerUsed = true;
jokerNote.textContent = 'Zwei falsche Antworten entfernt. Richtige Antwort: halbe Punkte.';
} catch (err) {
jokerBtn.disabled = false;
jokerNote.textContent = 'Joker konnte nicht geladen werden.';
console.error(err);
}
}
function closeQuestion() { function closeQuestion() {
if (!current) return; if (!current) return;
const state = current; const state = current;
@@ -225,7 +264,7 @@
async function newGame() { async function newGame() {
stopTimer(); stopTimer();
seconds = 0; score = 0; answeredCount = 0; current = null; seconds = 0; score = 0; answeredCount = 0; current = null; jokerUsed = false;
submitted.length = 0; submitted.length = 0;
timeEl.textContent = '0:00'; timeEl.textContent = '0:00';
resultEl.hidden = true; resultEl.hidden = true;
@@ -261,6 +300,7 @@
finishBtn.addEventListener('click', finishGame); finishBtn.addEventListener('click', finishGame);
cancelBtn.addEventListener('click', cancelGame); cancelBtn.addEventListener('click', cancelGame);
qNext.addEventListener('click', closeQuestion); qNext.addEventListener('click', closeQuestion);
jokerBtn.addEventListener('click', useFiftyFifty);
loadStats(); loadStats();
newGame(); newGame();
@@ -6,7 +6,7 @@
<title>TechTacular – SysnaptiK Neural</title> <title>TechTacular – SysnaptiK Neural</title>
<link rel="stylesheet" href="/css/theme.css?v=3"> <link rel="stylesheet" href="/css/theme.css?v=3">
<script src="/js/theme.js?v=4"></script> <script src="/js/theme.js?v=4"></script>
<link rel="stylesheet" href="/css/techtacular.css?v=4"> <link rel="stylesheet" href="/css/techtacular.css?v=5">
</head> </head>
<body class="tt-body"> <body class="tt-body">
@@ -50,6 +50,10 @@
</div> </div>
<p class="tt-q-instruction">Antwort – finde die passende Frage:</p> <p class="tt-q-instruction">Antwort – finde die passende Frage:</p>
<h2 id="tt-q-text" class="tt-q-text"></h2> <h2 id="tt-q-text" class="tt-q-text"></h2>
<div class="tt-joker-row">
<button type="button" id="tt-fifty-fifty" class="tt-btn tt-btn-secondary tt-joker">50:50-Joker</button>
<span id="tt-joker-note" class="tt-joker-note">Entfernt zwei falsche Antworten. Richtige Antwort: halbe Punkte.</span>
</div>
<div id="tt-q-answers" class="tt-q-answers"></div> <div id="tt-q-answers" class="tt-q-answers"></div>
<p id="tt-q-feedback" class="tt-q-feedback"></p> <p id="tt-q-feedback" class="tt-q-feedback"></p>
<div class="tt-q-actions"> <div class="tt-q-actions">
@@ -72,6 +76,6 @@
</div> </div>
</div> </div>
<script src="/js/techtacular.js?v=3" defer></script> <script src="/js/techtacular.js?v=4" defer></script>
</body> </body>
</html> </html>
@@ -0,0 +1,43 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.FiftyFiftyHint;
import com.example.mindflux.service.TechtacularService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Stellt sicher, dass der Joker-Endpoint nur die eliminierbaren IDs ausliefert. */
class TechtacularApiControllerTest {
private TechtacularService techtacularService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
techtacularService = mock(TechtacularService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new TechtacularApiController(techtacularService)).build();
}
@Test
void hintEndpointReturnsOnlyTheRemovalIds() throws Exception {
when(techtacularService.fiftyFifty(42L)).thenReturn(new FiftyFiftyHint(List.of(7L, 11L)));
mockMvc.perform(get("/api/techtacular/hint").param("questionId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.removeAnswerIds[0]").value(7))
.andExpect(jsonPath("$.removeAnswerIds[1]").value(11))
.andExpect(jsonPath("$.correctAnswerId").doesNotExist());
verify(techtacularService).fiftyFifty(42L);
}
}
@@ -0,0 +1,62 @@
package com.example.mindflux.service;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.example.mindflux.repository.TechtacularQuestionRepository;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TechtacularServiceTest {
@Test
void fiftyFiftyRemovesOnlyIncorrectAnswers() {
TechtacularQuestionRepository questions = mock(TechtacularQuestionRepository.class);
TechtacularQuestion question = new TechtacularQuestion("Hinweis", "", 400);
question.setId(42L);
TechtacularAnswer correct = answer(1L, true);
question.addAnswer(correct);
question.addAnswer(answer(2L, false));
question.addAnswer(answer(3L, false));
question.addAnswer(answer(4L, false));
when(questions.findById(42L)).thenReturn(Optional.of(question));
TechtacularService service = new TechtacularService(
mock(TechtacularCategoryRepository.class), questions, mock(GameScoreService.class));
var hint = service.fiftyFifty(42L);
assertEquals(2, hint.removeAnswerIds().size());
assertFalse(hint.removeAnswerIds().contains(correct.getId()));
assertTrue(Set.of(2L, 3L, 4L).containsAll(hint.removeAnswerIds()));
}
@Test
void correctAnswerWithJokerIsWorthHalfThePoints() {
TechtacularQuestionRepository questions = mock(TechtacularQuestionRepository.class);
TechtacularQuestion question = new TechtacularQuestion("Hinweis", "", 500);
question.setId(42L);
TechtacularAnswer correct = answer(1L, true);
question.addAnswer(correct);
when(questions.findById(42L)).thenReturn(Optional.of(question));
TechtacularService service = new TechtacularService(
mock(TechtacularCategoryRepository.class), questions, mock(GameScoreService.class));
assertEquals(250, service.checkAnswer(42L, 1L, true).getPoints());
}
private static TechtacularAnswer answer(Long id, boolean correct) {
TechtacularAnswer answer = new TechtacularAnswer("Antwort", correct, 0);
answer.setId(id);
return answer;
}
}