From 8b1088a0700110c1169120188d926a593a69c24b Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 15 Aug 2026 21:59:32 +0200 Subject: [PATCH] Balancing: sonnensystem (Erst-Treffer-Bewertung, Loesung nicht aufdecken, Level-Badge) + energiemanager (echter Blackout an Netzfrequenz, Schwierigkeitsrampe) Co-Authored-By: Claude Opus 4.8 --- App/Don_t_Deploy/REVIEW-TODO-2026-08-14.md | 86 +++++++++++ App/assets/js/sim-metrics.js | 4 +- App/php/lib/Benchmark.php | 7 + App/sims/energiemanager/game.html | 168 ++++++++++++++++++++- App/sims/sonnensystem/game.html | 148 +++++++++++++----- 5 files changed, 365 insertions(+), 48 deletions(-) create mode 100644 App/Don_t_Deploy/REVIEW-TODO-2026-08-14.md diff --git a/App/Don_t_Deploy/REVIEW-TODO-2026-08-14.md b/App/Don_t_Deploy/REVIEW-TODO-2026-08-14.md new file mode 100644 index 0000000..84e066b --- /dev/null +++ b/App/Don_t_Deploy/REVIEW-TODO-2026-08-14.md @@ -0,0 +1,86 @@ +# GeoGraSim — Komplett-Review & ToDo (2026-08-14) + +## ✅ ERLEDIGT (Batch 1, 2026-08-14, deployed + Prod) +- **#2 IDOR progress.php** — student_id-Zweig verlangt jetzt Lehrer-Login + Klassenbesitz (403 sonst). +- **#3 licenses.php** — `?admin=1` + `generate` nur noch echter Admin (`$_SESSION['admin_id']`); Collision-Retry mit Cap gegen Endlosschleife. +- **#4 .env-Deny** — `` für `.env*/.sh/.sql/.bak` in .htaccess (Docroot aktualisiert); `.env` weiterhin 404 (verifiziert). +- **#6 Schüler-Ergebnisse** — sim-metrics.js + .ivm-CSS in schueler.html; `renderOwnRunCard`-Fallback zeigt Sterne + grafische Kacheln für die 9 bisher leeren Sims. +- **#13 klima-Budget** — SIM_METRICS `final_budget` als „Mio €" (statt „€"). +- **#7 Assessments-Aufblähung** — assessment.php skippt phase running/started; heli-30s-Ping entfernt; Altlast bereinigt (lokal 8678→276, Prod 1696→197). +- **#9/#16 Cockpit/Matrix-Performance** — akute Last schon durch #7 gekappt (Scan ~200 statt 8000 Zeilen); zusätzlich Indizes `assessments(class_id,submitted_at)` + `student_results(class_id,completed_at)` gesetzt (lokal+Prod). APCu nicht verfügbar → App-Cache verworfen; Benchmark-Summary-Tabelle = späteres, größeres Stück falls nötig. +- **#8 String-Identität → student_id-FK (Stufe 1+2)** — `student_sessions.student_id` (Spalte+Index+Backfill, lokal 35/35, Prod 90/91); `Session::createStudent` setzt es künftig; ALLE ~15 Name-Joins (Benchmark, live×5, results×5, modules, profile, progress×2) auf `student_id` umgestellt. Verlustfrei verifiziert (alt==neu, diff=0 auf Prod). Robust gegen Umbenennung. *Rest (optional):* 1 Waisen-Session „Susi1" (kein Schüler), assessments-eigene student_id-Spalte (nicht nötig). + +## Geklärt ohne Code-Änderung (Entscheidung Thomas, 2026-08-14) +- **#1 Secrets — Rotieren WEGGELASSEN.** `git push` geht nur an eigenen Gitea (`git.schroffenegger.at:2222`), nichts Öffentliches → Restrisiko akzeptiert. Keine Rotation, kein History-Rewrite. +- **#5 Klartext-Passwörter — WORKS AS DESIGNED.** `password_plaintext` = systemvergebener PIN (druckbar). Lehrperson-Anlage/Reset setzt PIN+Hash; Selbst-Registrierung setzt KEIN plaintext; es gibt keinen Schüler-Selbständerungs-Flow → kein geändertes Passwort liegt je im Klartext. Kein Bug. + - *Zukunfts-Hinweis:* falls je ein Schüler-„Passwort ändern"-Feature gebaut wird, muss es `password_plaintext = NULL` setzen. + +## ✅ ERLEDIGT (Batch 2, 2026-08-15 — Balancing sonnensystem + energiemanager) +- **#10 sonnensystem** — Bewertung war bedeutungslos (Lösung sofort aufgedeckt + Endlos-Retry → immer 5★). NEU: + - Falsche Antwort deckt die richtige NICHT mehr auf → nur diese Option wird gesperrt, man wählt weiter (`obsAnswered` umgebaut, `.obs-option.spent`). + - Sterne = **Erst-Treffer-Quote** (persistiert in `sonnensystem_obs_firsttry`), nicht mehr completed/total. Schwellen 0.85/0.68/0.50/0.30. + - `level`-Feld (1–4) jetzt **genutzt**: Schwierigkeits-Badge pro Aufgabe (Grundlagen/Aufbau/Vertiefung/Profi). + - Endscreen zeigt „Auf Anhieb richtig X/Y". Payload: `firstTryCount`/`firstTryQuote`. Benchmark.php `scoreSonnensystem` nutzt `firstTryQuote` (Fallback = alt). sim-metrics.js: „Auf Anhieb"-Kachel. +- **#11 energiemanager** — kein Fail → immer 5★. NEU: + - **Echter Blackout** an der Netzfrequenz: `freqError` akkumuliert (`·retain + balance·0.10`), Regelreserve zieht zurück; < 49,5 Hz oder > 50,5 Hz → Blackout-Overlay, Tag muss neu geplant werden (Plan bleibt erhalten). + - **Schwierigkeitsrampe** über `retain` je Set (0.45 Frühling → 0.70 Wochenende): früh viel Reserve/verzeihend, spät summieren sich Fehler. + - Frequenz-Tacho zeigt jetzt die **akkumulierte** Frequenz (Wegdriften sichtbar). Blackouts deckeln Sterne (1→max 3★, ≥2→max 2★), erscheinen in Set-Bilanz + `GGS_LIVE_STATE` (phase `blackout`, `blackouts`). + - Numerisch verifiziert: optimales Spiel bleibt 49,9–50,0 Hz (nie Blackout); grober Richtungsfehler (Pumpen in Abendspitze) → 47,9 Hz → Blackout. Beide game.html + Benchmark.php syntaxgeprüft. + - **Offen/Notiz:** „konkurrierende Stufen" ist über Tank-Zwang + Frequenz-Drift jetzt spürbar, aber echte Mehr-Strategien-Trade-offs bräuchten Config-Redesign (18 Tage neu tunen) → separat mit Thomas. Tank-Übertrag „Speicher von gestern" ist derzeit nur kosmetisch (runDayStepwise resettet auf tankTopInit) — bewusst NICHT angefasst (Solvability). Für Thomas notiert. + - **NOCH NICHT DEPLOYED** — wartet auf Thomas-Test/-OK. + +## → NÄCHSTER SCHRITT +Nach Thomas-OK: deployen. Danach: Feld-Schemata vereinheitlichen (#12) · FL-Lehrplan (#15) · weitere „weiche" Sims (#18 fluss/farmer/tourismustal/weltküche/eu-werkstatt) · P3-Aufräumen. + +--- + + +Read-only-Audit über 6 Dimensionen (Verdrahtung, Darstellungen, Skalierung/DB, Balancing, +Codequalität, Sicherheit). Nichts geändert. Priorisiert P0→P3. + +**Positiv vorab:** SQL durchgängig prepared (keine ausnutzbare Injection), Fremd-Inhalte +escaped (keine cross-user-XSS), DB-vs-JSON sauber getrennt (kein Laufzeitzustand in Dateien), +alle 13 aktiven Sims sind grundverdrahtet. Vorbild-Balance: tourismusregion + klima. + +## P0 — Sofort (Sicherheit / Datenintegrität) +1. **[Sec] Secrets aus git entfernen + rotieren.** MySQL-Root-PW im Klartext in `Don_t_Deploy/deploy.sh`, `deploy-2026-04-26.sh`, `bulk-reset-lernapps.php`; SMTP-PW-Fallback in `lib/Mailer.php:15`. Blockiert sicheres Gitea-Push. → rotieren, untracken, aus History (BFG/filter-repo). +2. **[Sec] IDOR in `progress.php:34`.** Zweig `if($studentId && $simId)` hat KEINEN Auth-Guard → fremder Fortschritt/Results ohne Login lesbar (ID enumerierbar). Kleiner, dringender Fix. +3. **[Sec] `licenses.php` Access Control.** `?admin=1` (Z.15) autorisiert jede Lehrperson → liest ALLE Lizenzcodes; `action=generate` (Z.173) ohne Admin → 1000 Codes generierbar. An `$_SESSION['admin_id']` binden. +4. **[Sec] `.env`-Deny ergänzen (Defense-in-Depth).** Verifiziert: `.env.production/.local` werden aktuell NICHT ausgeliefert (HTTP 404), aber es gibt keinen bewussten Schutz. `Require all denied` in `.htaccess`. +5. **[Sec] Schüler-Passwörter Klartext.** `students.password_plaintext` (auch im GET). Zugriff strikt auf Ticket-Druck scopen, aus normalem Schüler-GET raus (besser Reset-only). + +## P1 — Hoch (Korrektheit / Skalierung / Balancing-kritisch) +6. **[Darstellung] Schüler sehen bei 9/15 Sims keine Ergebnisse.** `renderOwnRunCard` (schueler.html) hat nur 6 Renderer + Fallback zeigt nur Datum/Dauer; `sim-metrics.js` ist in schueler.html NICHT eingebunden. → einbinden + Fallback nutzt `renderSimMetrics(simId,d)`. +7. **[Skalierung] `assessments`-Aufblähung.** `assessment.php:28` immer INSERT; Heli-Ping alle 30 s → 8678 Zeilen bei 31 Schülern, 96,7 % Wegwerf-Telemetrie (phase 'running'). → Zwischenpings NICHT in `assessments` (nur `live_sessions.state_json`) + Purge-Job. +8. **[Skalierung/Code] String-Identität → `student_id`-FK.** Alles per `class_id + display_name` verknüpft (kein FK, unindexiert; „Anna" 16×). Umbenennen/Doppelname zerlegt Historie; Join nicht indexierbar. FK auf `student_sessions` (+`assessments`), ~40 Name-Joins ersetzen. +9. **[Skalierung] Cache-lose Voll-Historien-Aggregation.** `live.php` (benchRows/lastLogin) + `results.php` Matrix scoren die GESAMTE Klassen-Historie pro Poll, alle 4 s. → Datums-/LIMIT-Grenzen + gecachte Benchmark-Aggregate. +10. **[Balancing] sonnensystem — Bewertung bedeutungslos.** Falsche Antwort deckt sofort die richtige auf + Endlos-Retry → immer 5★; `obsShowEnd` nur bei 100 %; `level` 1–4 ungenutzt. → Score aus Erst-Treffer; Lösung nicht sofort zeigen. +11. **[Balancing] energiemanager — immer 5★, kein Fail.** Eindeutiges Lookup (5er-Schritte) löst alle Sets. → konkurrierende Stufen + echter Blackout-Fail (Frequenz-Tacho) + Schwierigkeitsrampe. + +## P2 — Mittel (Konsistenz / Balancing / Skalierung) +12. **[Code/Darstellung] Drei Feld-Schemata konsolidieren** (SIM_METRICS / MODULE_HIGHLIGHTS / LIVE_PRIMARY_FIELDS) → eine Quelle. Sofort-Lücken: `fluggesellschaft` in SIM_METRICS+badges.js; `LIVE_PRIMARY_FIELDS` für klima-3d/farmer/logistik. +13. **[Darstellung] klima `final_budget`** in SIM_METRICS als „Mio €" (aktuell „€", 10⁶ falsch). +14. **[Darstellung] Generischer Live-Vergleich** zeigt „0 %"+rohe Phase für weltkueche/farmer/fluss/logistik/kofferdetektiv/tourismus* (mit #12 lösen). +15. **[Verdrahtung] Lehrplan Liechtenstein (FL)** für eu-werkstatt, busfahrt, logistik, tourismusregion ergänzen (D-A-CH+FL-Regel). +16. **[Skalierung] Indizes:** `assessments(class_id, submitted_at)`, `student_results(class_id, completed_at)`, Join-Seite `student_sessions`. +17. **[Code] class_modules/class_assignments** transaktional vereinheitlichen (eine autoritative Quelle; `create` und `set_mode` in einem Write). +18. **[Balancing] Echter Verlierzustand + Abschluss-Feedback** für die „weichen" Sims: fluss, farmer, tourismustal, weltküche, eu-werkstatt. +19. **[Balancing] heli** Navi-Drittel entschärfen (WP-Marker erst nach Klick), Level-Skalierung auf Start/Plan ausweiten. +20. **[Balancing] fluss** Level-Skalierung (Wetter/Zeit); **farmer** Klimahinweis VOR dem Pflanzen + wählbare Level; **busfahrt** L3-Ziel (35 k) nachrechnen/`fixedFee` aktivieren. +21. **[Sec] Härtung:** Login-Throttling/Lockout, `session_regenerate_id(true)` nach Login, Session-Cookie explizit (HttpOnly/SameSite/Secure), CSRF-Token oder SameSite erzwingen, foto-upload-PIN (`0815`) aus .env. +22. **[Verdrahtung] `weltkueche.page_url`** setzen (Info-Button/href kaputt); **kofferdetektiv** fehlt `module_info`-Eintrag (überall sonst verdrahtet). +23. **[Code] `$_SESSION['student_id']` wird nie gesetzt** → Leichte Sprache / per-Schüler-Land greifen für Cookie-Schüler nie (stiller Fallback). Verifizieren + fixen. + +## P3 — Aufräumen (Wartbarkeit / niedrig) +24. **[Code] Toter Code:** `renderLiveCockpit` + ~15 `_cockpit*`-Helfer, `toggleAssignmentPause`, `IV_PLABEL`/`ivParams`, altes `ACHIEVEMENTS` (schueler.html). +25. **[Code] ~40 MB `.bak`-Müll** (347 Dateien, v. a. heli/sounds), `_inbox/` (~300 md), DB-Dumps aus git; `.gitignore` (`*.bak*`). +26. **[Code] design-system.css** in teacher/schueler einbinden (715 bzw. 130 Hex hartkodiert); perspektivisch auf `--ggs-*`. +27. **[Code] Toter Vite-Build-Stack** (`vite.config.ts`, `App/src/`), verwaiste Sims (wal/landwirtschaft/vulkan/staustufen) klären/entfernen. +28. **[Darstellung] Matrix-Spaltenköpfe:** sichtbarer Kurzname statt nur Emoji (a11y/iPad). +29. **[Skalierung] `kofferdetektiv/outlines.json` (1,4 MB)** cachen/lazy laden. +30. **[Code/Infra] Migrations-Runner** (`schema_migrations`) + idempotentes Deploy (`--delete`-Schutz, Mirror-Whitelist entschärfen); `LIVE_SIM_SHORT` Geister-Module (stadt/regenwald/erdbeben) bereinigen. + +--- +Systemisches Muster Balancing: häufigster Mangel = **fehlender echter Verlierzustand** + „Lösung +aufgedeckt + Endlos-Retry → garantierte Bestnote". Systemisches Muster Code: gute Abstraktionen +existieren, werden aber nicht erzwungen (String-Join, 3 Schemata, design-system.css ungenutzt). diff --git a/App/assets/js/sim-metrics.js b/App/assets/js/sim-metrics.js index 08655ed..01a6033 100644 --- a/App/assets/js/sim-metrics.js +++ b/App/assets/js/sim-metrics.js @@ -47,8 +47,8 @@ {k:'crops_used',type:'num',l:'Sorten'} ], sonnensystem: [ - {k:'completed',type:'ratio',t:'total',l:'Aufgaben gelöst'}, - {k:'firstTry',type:'bool',l:'Erstversuch'} + {k:'firstTryCount',type:'ratio',t:'total',l:'Auf Anhieb'}, + {k:'completed',type:'ratio',t:'total',l:'Aufgaben gelöst'} ], 'eu-werkstatt': [ {k:'firstTryHits',type:'ratio',t:'stepsTotal',l:'Erste Wahl'}, diff --git a/App/php/lib/Benchmark.php b/App/php/lib/Benchmark.php index cbad53f..958fae8 100644 --- a/App/php/lib/Benchmark.php +++ b/App/php/lib/Benchmark.php @@ -252,6 +252,13 @@ class Benchmark */ private static function scoreSonnensystem(array $r): ?int { + // Neue Bewertung = Erst-Treffer-Quote (Meisterschaft auf Anhieb, 0–100). + // Nur „alle gelöst" ergibt nicht mehr automatisch 100 %. + $firstTryQuote = $r['firstTryQuote'] ?? null; + if (is_numeric($firstTryQuote)) { + return (int)round(max(0, min(100, (float)$firstTryQuote))); + } + // Fallback für Alt-Datensätze ohne Erst-Treffer-Feld. $completed = $r['completed'] ?? null; $total = $r['total'] ?? null; $firstTry = $r['firstTry'] ?? null; diff --git a/App/sims/energiemanager/game.html b/App/sims/energiemanager/game.html index fd9734e..aaf9df2 100644 --- a/App/sims/energiemanager/game.html +++ b/App/sims/energiemanager/game.html @@ -718,6 +718,23 @@ display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; flex-wrap: wrap; } + /* ---- Blackout-Overlay (Verlierzustand) ---- */ + .em-blackout-overlay { background: rgba(60,10,10,0.62); } + .em-blackout-card { border-top: 6px solid #dc2626; } + .em-blackout-head { display: flex; align-items: center; gap: 14px; margin-bottom: 10px; } + .em-blackout-icon { font-size: 2.4rem; animation: emBlackoutFlicker 1.1s ease-in-out infinite; } + @keyframes emBlackoutFlicker { 0%,100% { opacity: 1; } 45% { opacity: .35; } 55% { opacity: .9; } } + .em-blackout-head h2 { margin: 0; font-size: 1.4rem; color: #b91c1c; letter-spacing: .01em; } + .em-blackout-sub { font-size: 0.8rem; color: var(--ggs-text-muted); margin-top: 2px; } + .em-blackout-cause { font-size: 0.95rem; line-height: 1.5; color: var(--ggs-text); margin: 6px 0; } + .em-blackout-tip { + font-size: 0.9rem; line-height: 1.5; margin: 10px 0; + background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; padding: 10px 12px; + } + .em-blackout-note { font-size: 0.82rem; color: var(--ggs-text-muted); margin-top: 8px; } + .em-blackout-btn { background: #dc2626 !important; } + .em-blackout-btn:hover { background: #b91c1c !important; } + .em-set-summary-actions.em-center { justify-content: center; } @@ -1006,6 +1023,16 @@ + + + ' + '
'+starsHtml+'
' + + blackoutHtml + '
' + '
'+s.totalGap+' GWhΣ Lücke
' + '
'+s.totalSurplus+' GWhΣ Überschuss
' @@ -2447,6 +2592,10 @@ state.tankTop = startTop; state.tankBot = TANK_TOTAL - startTop; state.results = []; + state.freqError = 0; + state.blackout = false; + var boOv = document.getElementById('blackoutOverlay'); + if (boOv) boOv.hidden = true; drawGraph(); renderBlocks(); @@ -2527,7 +2676,8 @@ window.GGS_LIVE_STATE = function () { if (typeof state === 'undefined' || !state) return null; - var phase = state.finished ? 'done' + var phase = state.blackout ? 'blackout' + : state.finished ? 'done' : state.running ? 'running' : 'plan'; @@ -2547,7 +2697,10 @@ // Health-Ampel (cross-app Pflicht-Konvention) var health = 'good'; var label; - if (phase === 'running') { + if (phase === 'blackout') { + health = 'struggling'; + label = '🔌 Blackout · Tag ' + (currentDayIdx+1) + ' neu planen'; + } else if (phase === 'running') { health = 'good'; label = 'Tag läuft · Block ' + ((state.currentBlock||0)+1) + '/' + DAY.blocks.length; } else if (phase === 'done') { @@ -2587,6 +2740,7 @@ // Set-Sicht setDaysDone: daysCompleted, setBlocksBalanced: setBalancedSoFar, + blackouts: setRun.blackouts || 0, // Cross-app Konvention health: health, healthLabel: label, diff --git a/App/sims/sonnensystem/game.html b/App/sims/sonnensystem/game.html index 55d35bd..0da8118 100644 --- a/App/sims/sonnensystem/game.html +++ b/App/sims/sonnensystem/game.html @@ -572,6 +572,17 @@ .obs-option.correct { background: #dcfce7; border-color: #16a34a; } .obs-option.wrong { background: #fee2e2; border-color: #dc2626; } .obs-option.disabled { pointer-events: none; opacity: 0.6; } + /* Falsch gewählte Option bleibt gesperrt sichtbar (nicht ausgraut wie „disabled"), + die richtige wird NICHT verraten — man wählt einfach weiter. */ + .obs-option.spent { pointer-events: none; background: #fee2e2; border-color: #fca5a5; color: #991b1b; opacity: 0.7; } + #obs-answer-hint { margin-top: 10px; font-size: 13px; font-weight: 600; color: #dc2626; min-height: 18px; line-height: 1.4; } + /* Schwierigkeits-Badge (nutzt das level-Feld aus observations.json) */ + .obs-badge { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: .02em; + padding: 2px 9px; border-radius: 999px; margin-left: 8px; vertical-align: middle; white-space: nowrap; } + .obs-badge.l1 { background: #dcfce7; color: #15803d; } + .obs-badge.l2 { background: #dbeafe; color: #1d4ed8; } + .obs-badge.l3 { background: #fef3c7; color: #b45309; } + .obs-badge.l4 { background: #fce7f3; color: #be185d; } #obs-sim-hint-wrap { margin-top: 14px; @@ -972,7 +983,7 @@
-
Aufgabe 1 / 20
+
Aufgabe 1 / 20

Titel

Hinführung

@@ -996,6 +1007,7 @@

Frage

+
@@ -1013,7 +1025,8 @@

🎉 Geschafft!

-

Du hast 0 von 0 Punkten erreicht.

+

Auf Anhieb richtig: 0 von 0 Aufgaben.

+

@@ -3452,6 +3465,18 @@ function loadCompleted() { function saveCompleted(set) { try { localStorage.setItem(OBS_STORAGE_KEY, JSON.stringify([...set])); } catch (e) {} } +// „Auf Anhieb richtig" (Erst-Treffer) — persistiert wie completed, damit die +// Bewertung über mehrere Sitzungen hinweg gilt. Das ist die Basis der Sterne. +const OBS_FIRSTTRY_KEY = 'sonnensystem_obs_firsttry'; +function loadFirstTry() { + try { + const raw = localStorage.getItem(OBS_FIRSTTRY_KEY); + return new Set(raw ? JSON.parse(raw) : []); + } catch (e) { return new Set(); } +} +function saveFirstTry(set) { + try { localStorage.setItem(OBS_FIRSTTRY_KEY, JSON.stringify([...set])); } catch (e) {} +} const obsState = { tasks: [], index: 0, @@ -3461,6 +3486,7 @@ const obsState = { phaseTimer: null, stopAtCheck: null, completed: loadCompleted(), + firstTry: loadFirstTry(), baseUrl: (window.SONNENSYSTEM_BASE || './') + 'data/observations.json', }; @@ -3600,6 +3626,14 @@ function obsShowTask(idx) { document.getElementById('obs-total').textContent = obsState.tasks.length; document.getElementById('obs-title').textContent = t.title; document.getElementById('obs-intro').textContent = t.intro; + // Schwierigkeits-Badge (nutzt das level-Feld) + const lvBadge = document.getElementById('obs-level-badge'); + if (lvBadge) { + const lv = Math.min(4, Math.max(1, t.level || 1)); + const lvNames = { 1: 'Grundlagen', 2: 'Aufbau', 3: 'Vertiefung', 4: 'Profi' }; + lvBadge.textContent = '★'.repeat(lv) + ' ' + lvNames[lv]; + lvBadge.className = 'obs-badge l' + lv; + } obsRenderBlocks(); obsShowStep('intro'); // „Zurück zur letzten Übung" bei erster Aufgabe ausgrauen @@ -3745,37 +3779,55 @@ function obsAnswered(displayIdx) { const correctDisplay = (typeof t._shuffledCorrect === 'number') ? t._shuffledCorrect : t.correctIndex; const opts = document.querySelectorAll('.obs-option'); - opts.forEach((b, i) => { - b.classList.add('disabled'); - if (i === correctDisplay) b.classList.add('correct'); - else if (i === displayIdx) b.classList.add('wrong'); - }); - const wasFirstTry = !obsState._attempted || !obsState._attempted[t.id]; + const clicked = opts[displayIdx]; + if (clicked && clicked.classList.contains('spent')) return; // schon verbraucht obsState._attempted = obsState._attempted || {}; - obsState._attempted[t.id] = (obsState._attempted[t.id] || 0) + 1; - if (displayIdx === correctDisplay) { - const wasNew = !obsState.completed.has(t.id); - obsState.score += 1; - obsState.completed.add(t.id); - saveCompleted(obsState.completed); - // Achievement-Auswertung pro Antwort - if (window.snEvaluateAchievementsOnAnswer) { - var ctx = (t.startState || {}); - var newly = window.snEvaluateAchievementsOnAnswer( - t.id, wasFirstTry, obsState.completed.size, obsState.tasks.length, ctx - ); - obsState._newAchievements = (obsState._newAchievements || []).concat(newly); - } - // Submit pro gelöster Aufgabe (live ans Backend, ohne Sterne — Set-Submit kommt am Ende) - if (wasNew && window.snSubmitProgress) { - window.snSubmitProgress({ - stars: 0, - durationMs: 0, - data: { event:'task_solved', taskId:t.id, taskTitle:t.title, - completed:obsState.completed.size, total:obsState.tasks.length, - firstTry: wasFirstTry } - }); - } + const priorTries = obsState._attempted[t.id] || 0; + obsState._attempted[t.id] = priorTries + 1; + const hintEl = document.getElementById('obs-answer-hint'); + + if (displayIdx !== correctDisplay) { + // FALSCH — die richtige Lösung NICHT verraten. Nur diese Option sperren, + // Bearbeiter:in wählt weiter. Damit zählt die Bewertung echte Erst-Treffer. + if (clicked) { clicked.classList.remove('correct'); clicked.classList.add('spent'); } + const nudges = [ + 'Das war noch nicht richtig — denk an das, was du gerade beobachtet hast, und versuch es nochmal.', + 'Noch nicht ganz — überleg in Ruhe und wähle eine andere Antwort.', + 'Knapp daneben — probier es weiter.' + ]; + if (hintEl) hintEl.textContent = nudges[Math.min(priorTries, nudges.length - 1)]; + return; + } + + // RICHTIG + opts.forEach((b) => { b.classList.add('disabled'); }); + if (clicked) clicked.classList.add('correct'); + if (hintEl) hintEl.textContent = ''; + const wasFirstTry = priorTries === 0; // beim allerersten Versuch dieser Aufgabe richtig + const wasNew = !obsState.completed.has(t.id); + obsState.completed.add(t.id); + saveCompleted(obsState.completed); + if (wasFirstTry) { + obsState.firstTry.add(t.id); + saveFirstTry(obsState.firstTry); + } + // Achievement-Auswertung pro Antwort + if (window.snEvaluateAchievementsOnAnswer) { + var ctx = (t.startState || {}); + var newly = window.snEvaluateAchievementsOnAnswer( + t.id, wasFirstTry, obsState.completed.size, obsState.tasks.length, ctx + ); + obsState._newAchievements = (obsState._newAchievements || []).concat(newly); + } + // Submit pro gelöster Aufgabe (live ans Backend, ohne Sterne — Set-Submit kommt am Ende) + if (wasNew && window.snSubmitProgress) { + window.snSubmitProgress({ + stars: 0, + durationMs: 0, + data: { event:'task_solved', taskId:t.id, taskTitle:t.title, + completed:obsState.completed.size, total:obsState.tasks.length, + firstTry: wasFirstTry } + }); } setTimeout(() => obsShowExplain(), 700); } @@ -3792,13 +3844,22 @@ function obsShowExplain() { function obsShowEnd() { document.body.classList.remove('obs-watching'); - document.getElementById('obs-score').textContent = obsState.score; - document.getElementById('obs-max').textContent = obsState.tasks.length; - // Sterne aus completed/total - const total = obsState.tasks.length || 1; - const done = obsState.completed.size; - const quote = done / total; - const stars = quote >= 1.00 ? 5 : quote >= 0.85 ? 4 : quote >= 0.65 ? 3 : quote >= 0.4 ? 2 : 1; + const total = obsState.tasks.length || 1; + const done = obsState.completed.size; + // Nur Erst-Treffer der TATSÄCHLICH gelösten Aufgaben zählen (robust gegen Alt-Stände). + let firstTryN = 0; + obsState.tasks.forEach(t => { if (obsState.firstTry.has(t.id)) firstTryN++; }); + const quote = firstTryN / total; // Bewertung = Meisterschaft auf Anhieb + // Sterne aus der Erst-Treffer-Quote (nicht mehr aus completed → kein garantiertes 5★) + const stars = quote >= 0.85 ? 5 : quote >= 0.68 ? 4 : quote >= 0.50 ? 3 : quote >= 0.30 ? 2 : 1; + document.getElementById('obs-score').textContent = firstTryN; + document.getElementById('obs-max').textContent = total; + const ftNote = document.getElementById('obs-firsttry-note'); + if (ftNote) { + ftNote.textContent = (done >= total) + ? 'Alle ' + total + ' Aufgaben gelöst. Die Sterne zählen, wie viele du beim ersten Versuch getroffen hast.' + : (done + ' von ' + total + ' Aufgaben gelöst.'); + } const starsEl = document.getElementById('snEndStars'); if (starsEl) { starsEl.innerHTML = '★'.repeat(stars) + '' + '★'.repeat(5-stars) + ''; @@ -3826,7 +3887,10 @@ function obsShowEnd() { data: { event: 'set_complete', completed: done, total: total, + firstTryCount: firstTryN, + firstTryQuote: Math.round(quote*100), scoreQuote: Math.round(quote*100), + firstTry: (firstTryN >= total), // legacy: alle auf Anhieb stars: stars, attemptsByTask: obsState._attempted || {}, newAchievements: newly @@ -3900,6 +3964,9 @@ document.getElementById('obs-next').addEventListener('click', () => { document.getElementById('obs-restart').addEventListener('click', () => { obsState.completed.clear(); saveCompleted(obsState.completed); + obsState.firstTry.clear(); + saveFirstTry(obsState.firstTry); + obsState._attempted = {}; obsState.score = 0; obsShowTask(0); }); @@ -3907,6 +3974,9 @@ document.getElementById('obs-skip-restart').addEventListener('click', () => { if (confirm('Fortschritt zurücksetzen — alle Aufgaben wieder offen?')) { obsState.completed.clear(); saveCompleted(obsState.completed); + obsState.firstTry.clear(); + saveFirstTry(obsState.firstTry); + obsState._attempted = {}; obsState.score = 0; obsShowTask(0); }