From c5009d17189b36d1903f3190c65e89239d0ed843 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Sun, 19 Jul 2026 20:01:06 +0200 Subject: [PATCH] Add TechTacular fifty-fifty joker --- CHANGELOG.md | 6 + mindfluxrefine.md | 171 ++++++++++++++++++ .../controller/TechtacularApiController.java | 46 ++++- .../example/mindflux/dto/AnswerRequest.java | 5 + .../example/mindflux/dto/FiftyFiftyHint.java | 7 + .../mindflux/service/TechtacularService.java | 46 ++++- src/main/resources/static/css/techtacular.css | 18 ++ src/main/resources/static/js/techtacular.js | 48 ++++- src/main/resources/templates/techtacular.html | 8 +- .../TechtacularApiControllerTest.java | 43 +++++ .../service/TechtacularServiceTest.java | 62 +++++++ 11 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 mindfluxrefine.md create mode 100644 src/main/java/com/example/mindflux/dto/FiftyFiftyHint.java create mode 100644 src/test/java/com/example/mindflux/controller/TechtacularApiControllerTest.java create mode 100644 src/test/java/com/example/mindflux/service/TechtacularServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c39cc..92eff2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Versionierung [semantisch](https://semver.org/lang/de/). ## [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 - **E-Mail-Bestätigung:** Neue Accounts erhalten einen einmaligen, 24 Stunden gültigen Bestätigungslink und werden erst nach erfolgreicher Verifikation zum diff --git a/mindfluxrefine.md b/mindfluxrefine.md new file mode 100644 index 0000000..0a7a0fa --- /dev/null +++ b/mindfluxrefine.md @@ -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 ``, 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-` 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(,.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 ``-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:`), 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`-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. diff --git a/src/main/java/com/example/mindflux/controller/TechtacularApiController.java b/src/main/java/com/example/mindflux/controller/TechtacularApiController.java index 89d5bd0..0c81107 100644 --- a/src/main/java/com/example/mindflux/controller/TechtacularApiController.java +++ b/src/main/java/com/example/mindflux/controller/TechtacularApiController.java @@ -7,12 +7,16 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.util.HashSet; import java.util.Set; +import jakarta.servlet.http.HttpSession; /** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */ @RestController @RequestMapping("/api/techtacular") public class TechtacularApiController { + private static final String JOKER_USED = "techtacular.fiftyFiftyUsed"; + private static final String HINTED_QUESTION_IDS = "techtacular.hintedQuestionIds"; + private final 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. */ @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)); } @@ -40,12 +47,41 @@ public class TechtacularApiController { } @PostMapping("/answer") - public AnswerResult answer(@RequestBody AnswerRequest request) { - return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId()); + public AnswerResult answer(@RequestBody AnswerRequest request, HttpSession session) { + // 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 hinted = new HashSet<>(); + hinted.add(questionId); + session.setAttribute(HINTED_QUESTION_IDS, hinted); + } + return hint; } @PostMapping("/finish") - public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) { - return techtacularService.finish(principal.getName(), request); + public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal, HttpSession session) { + TechtacularResult result = techtacularService.finish(principal.getName(), request, hintedQuestionIds(session)); + session.removeAttribute(JOKER_USED); + session.removeAttribute(HINTED_QUESTION_IDS); + return result; + } + + @SuppressWarnings("unchecked") + private Set hintedQuestionIds(HttpSession session) { + Object value = session.getAttribute(HINTED_QUESTION_IDS); + return value instanceof Set ? (Set) value : Set.of(); } } diff --git a/src/main/java/com/example/mindflux/dto/AnswerRequest.java b/src/main/java/com/example/mindflux/dto/AnswerRequest.java index c0440dc..ea6a714 100644 --- a/src/main/java/com/example/mindflux/dto/AnswerRequest.java +++ b/src/main/java/com/example/mindflux/dto/AnswerRequest.java @@ -4,10 +4,15 @@ package com.example.mindflux.dto; public class AnswerRequest { private Long questionId; private Long answerId; + private boolean hintUsed; public Long getQuestionId() { return questionId; } public void setQuestionId(Long questionId) { this.questionId = questionId; } public Long getAnswerId() { return 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; } } diff --git a/src/main/java/com/example/mindflux/dto/FiftyFiftyHint.java b/src/main/java/com/example/mindflux/dto/FiftyFiftyHint.java new file mode 100644 index 0000000..c9fcfb9 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/FiftyFiftyHint.java @@ -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 removeAnswerIds) { +} diff --git a/src/main/java/com/example/mindflux/service/TechtacularService.java b/src/main/java/com/example/mindflux/service/TechtacularService.java index 190d397..34d79e1 100644 --- a/src/main/java/com/example/mindflux/service/TechtacularService.java +++ b/src/main/java/com/example/mindflux/service/TechtacularService.java @@ -85,6 +85,12 @@ public class TechtacularService { /** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */ @Transactional(readOnly = true) 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); if (question == null) { return new AnswerResult(false, null, "", 0); @@ -92,12 +98,36 @@ public class TechtacularService { TechtacularAnswer correct = question.correctAnswer(); Long correctId = correct != null ? correct.getId() : null; 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 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. */ @Transactional - public TechtacularResult finish(String username, FinishRequest request) { + public TechtacularResult finish(String username, FinishRequest request, Set hintedQuestionIds) { int score = 0; int maxScore = 0; int correctCount = 0; @@ -116,7 +146,11 @@ public class TechtacularService { maxScore += question.getPoints(); TechtacularAnswer correct = question.correctAnswer(); 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++; } } @@ -128,6 +162,12 @@ public class TechtacularService { 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 ---------- /** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */ diff --git a/src/main/resources/static/css/techtacular.css b/src/main/resources/static/css/techtacular.css index bbf1c91..634667b 100644 --- a/src/main/resources/static/css/techtacular.css +++ b/src/main/resources/static/css/techtacular.css @@ -225,6 +225,17 @@ body.tt-body { .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 { text-align: left; padding: 0.75rem 1rem; @@ -240,6 +251,13 @@ body.tt-body { .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.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.correct { color: var(--success); } diff --git a/src/main/resources/static/js/techtacular.js b/src/main/resources/static/js/techtacular.js index 13d2aa5..20f3837 100644 --- a/src/main/resources/static/js/techtacular.js +++ b/src/main/resources/static/js/techtacular.js @@ -21,6 +21,8 @@ const qAnswers = document.getElementById('tt-q-answers'); const qFeedback = document.getElementById('tt-q-feedback'); 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 resultScore = document.getElementById('tt-result-score'); @@ -38,7 +40,8 @@ let timerId = null; let running = false; 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) { return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; @@ -100,6 +103,10 @@ qFeedback.textContent = ''; qFeedback.className = 'tt-q-feedback'; 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 = ''; state.data.answers.forEach((answer) => { @@ -121,13 +128,13 @@ const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer')); 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 { const res = await fetch('/api/techtacular/answer', { method: 'POST', 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(); @@ -157,6 +164,38 @@ 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() { if (!current) return; const state = current; @@ -225,7 +264,7 @@ async function newGame() { stopTimer(); - seconds = 0; score = 0; answeredCount = 0; current = null; + seconds = 0; score = 0; answeredCount = 0; current = null; jokerUsed = false; submitted.length = 0; timeEl.textContent = '0:00'; resultEl.hidden = true; @@ -261,6 +300,7 @@ finishBtn.addEventListener('click', finishGame); cancelBtn.addEventListener('click', cancelGame); qNext.addEventListener('click', closeQuestion); + jokerBtn.addEventListener('click', useFiftyFifty); loadStats(); newGame(); diff --git a/src/main/resources/templates/techtacular.html b/src/main/resources/templates/techtacular.html index d0911c5..37591d1 100644 --- a/src/main/resources/templates/techtacular.html +++ b/src/main/resources/templates/techtacular.html @@ -6,7 +6,7 @@ TechTacular – SysnaptiK Neural - + @@ -50,6 +50,10 @@

Antwort – finde die passende Frage:

+
+ + Entfernt zwei falsche Antworten. Richtige Antwort: halbe Punkte. +

@@ -72,6 +76,6 @@
- + diff --git a/src/test/java/com/example/mindflux/controller/TechtacularApiControllerTest.java b/src/test/java/com/example/mindflux/controller/TechtacularApiControllerTest.java new file mode 100644 index 0000000..d886d8b --- /dev/null +++ b/src/test/java/com/example/mindflux/controller/TechtacularApiControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/example/mindflux/service/TechtacularServiceTest.java b/src/test/java/com/example/mindflux/service/TechtacularServiceTest.java new file mode 100644 index 0000000..7ee3c04 --- /dev/null +++ b/src/test/java/com/example/mindflux/service/TechtacularServiceTest.java @@ -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; + } +}