Balancing: sonnensystem (Erst-Treffer-Bewertung, Loesung nicht aufdecken, Level-Badge) + energiemanager (echter Blackout an Netzfrequenz, Schwierigkeitsrampe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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** — `<FilesMatch>` 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. `<FilesMatch "^\.env">Require all denied</FilesMatch>` 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).
|
||||
@@ -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'},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1006,6 +1023,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Blackout-Overlay (Verlierzustand) ==================== -->
|
||||
<div class="em-set-summary-overlay em-blackout-overlay" id="blackoutOverlay" hidden>
|
||||
<div class="em-set-summary-card em-blackout-card">
|
||||
<div id="blackoutBody"></div>
|
||||
<div class="em-set-summary-actions em-center">
|
||||
<button class="em-btn-primary em-blackout-btn" type="button" onclick="blackoutReplan()">🔧 Plan überarbeiten & neu starten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Set-Abschluss-Overlay ==================== -->
|
||||
<div class="em-set-summary-overlay" id="setSummaryOverlay" hidden>
|
||||
<div class="em-set-summary-card">
|
||||
@@ -1365,9 +1392,9 @@
|
||||
var pendingTankTop = null;
|
||||
|
||||
// === Set-Verlauf: Tag-für-Tag-Ergebnisse innerhalb des aktuellen Sets ===
|
||||
var setRun = { setIdx: 0, startedAt: 0, days: [] }; // { setIdx, startedAt:ms, days:[{dayId, dayTitle, balanced, gap, surplus, blocks}] }
|
||||
var setRun = { setIdx: 0, startedAt: 0, days: [], blackouts: 0 }; // { setIdx, startedAt:ms, days:[…], blackouts:n }
|
||||
function resetSetRun(idx) {
|
||||
setRun = { setIdx: idx, startedAt: Date.now(), days: [] };
|
||||
setRun = { setIdx: idx, startedAt: Date.now(), days: [], blackouts: 0 };
|
||||
}
|
||||
|
||||
// === Achievements (cross-Set, persistiert in localStorage) ===
|
||||
@@ -1474,6 +1501,22 @@
|
||||
|
||||
var BLOCK_DURATION_MS = 2000; // Ablauf pro Block in der Animation
|
||||
|
||||
// ---------- Netzfrequenz-Modell mit echtem Blackout ----------
|
||||
// Die Netzfrequenz ist kein Deko-Zeiger mehr: Ungleichgewicht schiebt sie weg
|
||||
// von 50 Hz, die Regelreserve zieht sie zurück. Reicht die Reserve nicht →
|
||||
// Blackout, der Tag muss neu geplant werden.
|
||||
// freqError(neu) = freqError(alt) · retain + balance · FREQ_K
|
||||
// effektive Frequenz = 50 + freqError
|
||||
var FREQ_K = 0.10; // GWh-Balance → Hz (−5 GWh ≈ −0,5 Hz, wie im Tacho)
|
||||
var FREQ_LOW = 49.5; // darunter: Blackout (physikalische Grenze, siehe Intro)
|
||||
var FREQ_HIGH = 50.5; // darüber: Blackout durch Überfrequenz
|
||||
// Schwierigkeitsrampe: früh viel Regelreserve (kleiner retain → schnelle Erholung),
|
||||
// spät wenig Reserve (großer retain → Fehler summieren sich schneller zum Blackout).
|
||||
function emRetainForSet(setIdx) {
|
||||
var table = [0.45, 0.50, 0.55, 0.60, 0.65, 0.70];
|
||||
return table[Math.min(Math.max(setIdx, 0), table.length - 1)];
|
||||
}
|
||||
|
||||
// ---------- Stufen-Bezeichner ----------
|
||||
var STAGE_LABELS = {
|
||||
'-2': '⬆⬆ 2 Pumpen',
|
||||
@@ -1498,6 +1541,8 @@
|
||||
tankBot: TANK_TOTAL - DAY.tankTopInit,
|
||||
timer: null,
|
||||
results: [],
|
||||
freqError: 0, // Abweichung von 50 Hz (akkumuliert mit Regelreserve)
|
||||
blackout: false, // dieser Tag-Lauf endete im Blackout
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -1666,6 +1711,8 @@
|
||||
state.tankBot = TANK_TOTAL - DAY.tankTopInit;
|
||||
state.results = [];
|
||||
state.currentBlock = 0;
|
||||
state.freqError = 0;
|
||||
state.blackout = false;
|
||||
gmStartSampling();
|
||||
|
||||
// Plan-UI sperren
|
||||
@@ -1700,13 +1747,21 @@
|
||||
var r = runBlock(state.plan[i], b.demand, b.solar, state.tankTop, state.tankBot);
|
||||
state.results.push(r);
|
||||
|
||||
// Netzfrequenz akkumulieren: Ungleichgewicht schiebt weg von 50 Hz,
|
||||
// die Regelreserve (retain, je Set) zieht zurück. Reicht sie nicht → Blackout.
|
||||
var retain = emRetainForSet(currentSetIdx);
|
||||
state.freqError = state.freqError * retain + r.balance * FREQ_K;
|
||||
var effFreq = 50 + state.freqError;
|
||||
var blackoutNow = (effFreq < FREQ_LOW || effFreq > FREQ_HIGH);
|
||||
|
||||
// Pipe-Flow + Wasserstand-Update (CSS-Transition tut den Rest)
|
||||
updatePipeFlow(state.plan[i]);
|
||||
state.tankTop = r.tankTopAfter;
|
||||
state.tankBot = r.tankBotAfter;
|
||||
updateWaterViz();
|
||||
// Netz-Monitor: Block-Wechsel melden (löst Transient aus + setzt neue Soll-Balance)
|
||||
gmSetCurrentBlock(i, r.balance);
|
||||
// Netz-Monitor: Zeiger zeigt die AKKUMULIERTE Frequenz (nicht nur diesen Block),
|
||||
// damit man das Wegdriften bis zum Blackout kommen sieht.
|
||||
gmSetCurrentBlock(i, state.freqError / FREQ_K);
|
||||
|
||||
// Block-Feedback einblenden (kurz vor Block-Ende)
|
||||
setTimeout(function () {
|
||||
@@ -1737,9 +1792,86 @@
|
||||
|
||||
state.currentBlock += 1;
|
||||
renderProgressPill();
|
||||
state.timer = setTimeout(stepNext, BLOCK_DURATION_MS);
|
||||
// Bei Blackout den Block noch fertig „spielen" (Zeiger fällt in den roten
|
||||
// Bereich, Feedback erscheint), dann das Blackout-Overlay öffnen.
|
||||
if (blackoutNow) {
|
||||
state.blackout = true;
|
||||
state.timer = setTimeout(function () { triggerBlackout(i, effFreq, r); }, BLOCK_DURATION_MS);
|
||||
} else {
|
||||
state.timer = setTimeout(stepNext, BLOCK_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Blackout: echter Verlierzustand ----------
|
||||
function triggerBlackout(blockIdx, effFreq, r) {
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
state.running = false;
|
||||
state.finished = false; // Tag ist NICHT abgeschlossen — muss neu geplant werden
|
||||
state.blackout = true;
|
||||
gmStopSampling();
|
||||
setNeedle(effFreq); setReadout(Math.max(49.4, Math.min(50.6, effFreq)));
|
||||
setStatusPill('⛔ BLACKOUT', 'bad');
|
||||
updatePipeFlow(0);
|
||||
playSound('day-bad');
|
||||
if (setRun.setIdx !== currentSetIdx) resetSetRun(currentSetIdx);
|
||||
setRun.blackouts = (setRun.blackouts || 0) + 1;
|
||||
|
||||
var tooLow = effFreq < FREQ_LOW;
|
||||
var freqTxt = effFreq.toFixed(2).replace('.', ',');
|
||||
var cause = tooLow
|
||||
? ('Die Netzfrequenz ist auf <b>' + freqTxt + ' Hz</b> gefallen — es wurde <b>zu wenig</b> Strom erzeugt. '
|
||||
+ 'Die Regelreserve war erschöpft, dann brach das Netz zusammen.')
|
||||
: ('Die Netzfrequenz ist auf <b>' + freqTxt + ' Hz</b> gestiegen — es wurde <b>zu viel</b> Strom erzeugt. '
|
||||
+ 'Das Netz konnte den Überschuss nicht abführen und brach zusammen.');
|
||||
var tip = tooLow
|
||||
? 'Tipp: Wo der Bedarf höher ist als Solar, musst du <b>turbinieren</b> (⬇). Große Lücken brauchen <b>2 Turbinen</b>.'
|
||||
: 'Tipp: Wo Solar höher ist als der Bedarf, musst du <b>pumpen</b> (⬆). Großen Überschuss fängst du mit <b>2 Pumpen</b>.';
|
||||
|
||||
var body = document.getElementById('blackoutBody');
|
||||
if (body) {
|
||||
body.innerHTML =
|
||||
'<div class="em-blackout-head"><div class="em-blackout-icon">🔌</div>'
|
||||
+ '<div><h2>Blackout!</h2><div class="em-blackout-sub">Block ' + (blockIdx + 1)
|
||||
+ ' von ' + DAY.blocks.length + ' · ' + DAY.title + '</div></div></div>'
|
||||
+ '<p class="em-blackout-cause">' + cause + '</p>'
|
||||
+ '<p class="em-blackout-tip">' + tip + '</p>'
|
||||
+ '<p class="em-blackout-note">Dein Plan bleibt erhalten — überarbeite die kritischen Blöcke und starte den Tag neu.</p>';
|
||||
}
|
||||
var ov = document.getElementById('blackoutOverlay');
|
||||
if (ov) ov.hidden = false;
|
||||
}
|
||||
|
||||
// Blackout schließen → gleicher Tag, Plan bleibt erhalten, neu startbar.
|
||||
function blackoutReplan() {
|
||||
var ov = document.getElementById('blackoutOverlay');
|
||||
if (ov) ov.hidden = true;
|
||||
// Lauf-Zustand zurücksetzen, PLAN behalten.
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
state.running = false;
|
||||
state.finished = false;
|
||||
state.blackout = false;
|
||||
state.currentBlock = 0;
|
||||
state.freqError = 0;
|
||||
state.results = [];
|
||||
state.tankTop = DAY.tankTopInit;
|
||||
state.tankBot = TANK_TOTAL - DAY.tankTopInit;
|
||||
// Block-Kacheln + Feedback zurücksetzen, Slider wieder freigeben
|
||||
document.querySelectorAll('.em-block').forEach(function (el) { el.classList.remove('running','done'); });
|
||||
document.querySelectorAll('.em-block-feedback').forEach(function (fb) { fb.hidden = true; fb.className = 'em-block-feedback'; });
|
||||
document.querySelectorAll('.em-stage-slider').forEach(function (s) { s.disabled = false; });
|
||||
updateWaterViz();
|
||||
updatePipeFlow(0);
|
||||
gmReset();
|
||||
$('btnRunDay').disabled = false;
|
||||
$('btnRunDay').textContent = 'Tag neu starten ▶';
|
||||
$('blockRunning').hidden = true;
|
||||
$('resolutionSection').hidden = true;
|
||||
renderProgressPill();
|
||||
var grid = document.getElementById('blocksGrid');
|
||||
if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
window.blackoutReplan = blackoutReplan;
|
||||
|
||||
function showBilanz() {
|
||||
state.running = false;
|
||||
state.finished = true;
|
||||
@@ -1870,6 +2002,11 @@
|
||||
: quote >= 0.87 ? 4
|
||||
: quote >= 0.66 ? 3
|
||||
: quote >= 0.50 ? 2 : 1;
|
||||
// Blackout-Deckelung: wer das Netz zusammenbrechen ließ, kann kein Top-Ergebnis
|
||||
// holen. 1 Blackout → max 3★, mehrere → max 2★.
|
||||
var blackouts = setRun.blackouts || 0;
|
||||
if (blackouts >= 2) stars = Math.min(stars, 2);
|
||||
else if (blackouts === 1) stars = Math.min(stars, 3);
|
||||
|
||||
var summary = {
|
||||
setId: setMeta.id,
|
||||
@@ -1881,6 +2018,7 @@
|
||||
blocksBalanced: blocksBalanced,
|
||||
totalGap: Math.round(totalGap * 10) / 10,
|
||||
totalSurplus: Math.round(totalSurplus * 10) / 10,
|
||||
blackouts: blackouts,
|
||||
stars: stars,
|
||||
perDay: perDay
|
||||
};
|
||||
@@ -1932,6 +2070,12 @@
|
||||
}).join('')
|
||||
+ '</ul></div>';
|
||||
}
|
||||
var blackoutHtml = (s.blackouts > 0)
|
||||
? '<div style="margin-top:10px;padding:8px 12px;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;'
|
||||
+ 'font-size:0.85rem;color:#b91c1c;text-align:center">🔌 <b>'
|
||||
+ s.blackouts + (s.blackouts === 1 ? ' Blackout' : ' Blackouts')
|
||||
+ '</b> in diesem Set — deshalb kein Top-Ergebnis. Halte das Netz stabil für mehr Sterne.</div>'
|
||||
: '';
|
||||
host.innerHTML =
|
||||
'<div class="em-set-summary-head">'
|
||||
+ '<div style="font-size:2rem">'+(s.setIcon||'⚡')+'</div>'
|
||||
@@ -1939,6 +2083,7 @@
|
||||
+ '<div class="em-set-summary-meta">'+s.daysPlayed+' Tage · '+s.blocksBalanced+'/'+s.blocksTotal+' Blöcke ausgeglichen</div></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="em-set-summary-stars">'+starsHtml+'</div>'
|
||||
+ blackoutHtml
|
||||
+ '<div class="em-set-summary-stats">'
|
||||
+ '<div><b>'+s.totalGap+'</b> GWh<span>Σ Lücke</span></div>'
|
||||
+ '<div><b>'+s.totalSurplus+'</b> GWh<span>Σ Überschuss</span></div>'
|
||||
@@ -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,
|
||||
|
||||
+109
-39
@@ -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 @@
|
||||
<div id="obs-card">
|
||||
<button id="obs-close" type="button">✕</button>
|
||||
<div id="obs-step-intro">
|
||||
<div id="obs-progress">Aufgabe <span id="obs-num">1</span> / <span id="obs-total">20</span></div>
|
||||
<div id="obs-progress">Aufgabe <span id="obs-num">1</span> / <span id="obs-total">20</span><span id="obs-level-badge" class="obs-badge"></span></div>
|
||||
<div id="obs-blocks"></div>
|
||||
<h2 id="obs-title">Titel</h2>
|
||||
<p id="obs-intro">Hinführung</p>
|
||||
@@ -996,6 +1007,7 @@
|
||||
<div id="obs-step-answer" class="obs-step-hidden">
|
||||
<h2 id="obs-question">Frage</h2>
|
||||
<div id="obs-options"></div>
|
||||
<div id="obs-answer-hint"></div>
|
||||
</div>
|
||||
|
||||
<div id="obs-step-explain" class="obs-step-hidden">
|
||||
@@ -1013,7 +1025,8 @@
|
||||
|
||||
<div id="obs-step-done" class="obs-step-hidden">
|
||||
<h2>🎉 Geschafft!</h2>
|
||||
<p>Du hast <span id="obs-score">0</span> von <span id="obs-max">0</span> Punkten erreicht.</p>
|
||||
<p>Auf Anhieb richtig: <span id="obs-score">0</span> von <span id="obs-max">0</span> Aufgaben.</p>
|
||||
<p id="obs-firsttry-note" class="obs-small" style="color:#475569;margin-top:-6px"></p>
|
||||
<div id="snEndStars" class="sn-end-stars"></div>
|
||||
<div id="snEndAch" class="sn-end-ach" hidden></div>
|
||||
<div class="obs-actions">
|
||||
@@ -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) + '<span class="empty">' + '★'.repeat(5-stars) + '</span>';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user