Compare commits

..

125 Commits

Author SHA1 Message Date
Adminator 3dba841830 Lizenz-Feinschliff: CSV-Mailanhang, Lehrer-Listenspalten, Klassenliste-Haken
- Mailanhang bei grossen Chargen: zusaetzlich zur .txt jetzt auch .csv
  (gleiche reine Codeliste), Dateiname <ExtBestellnr|Datum>_geograsim_<intNr>.
- Lehrer-Lizenzlisten (zugewiesen + frei): vorne laufende Nr., Eingeloest am,
  Gueltig bis (Backend liefert redeemed_at + valid_until).
- Klassenliste Lizenz-Spalte: nur Haken (gruen) bzw. "fehlt" (rot); Tooltip
  zeigt Lizenznummer + gueltig bis. "Gueltig" via unlicensed-Liste (nicht
  storniert/abgelaufen). Bulk-Auswahl/Zaehler nutzen jetzt dieselbe Wahrheit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 11:33:44 +02:00
Adminator ff889865e1 Lizenz-Gate: Schueler ohne Lizenz gesperrt + Lehrer-Cockpit-Warnung
Teil 1 — Schueler-Lizenz-Gate (gilt fuer alle Klassen-Schueler ohne gueltige
Lizenz = zugewiesen, nicht storniert, nicht abgelaufen):
- Server-Gate zentral im Front-Controller (index.php + php/lib/license_gate.php):
  eingeloggte Klassen-Schueler ohne Lizenz sehen bei JEDER Sim-Route statt der
  Simulation eine Hinweis-Seite "Deine Lehrperson muss dir noch eine gueltige
  Lizenz zuweisen". Schnell-Ausstieg fuer Nicht-Schueler/Nicht-Sim (kein Cookie
  -> keine DB-Abfrage). Autodidakt ohne Klasse: nicht lizenzpflichtig.
- modules.php?student=1 liefert 'licensed'; Schueler-Cockpit (schueler.html)
  zeigt oben die Meldung und sperrt die Start-Buttons.

Teil 2 — Lehrer-Cockpit:
- Rote Warnleiste ganz oben, wenn Schueler:innen ohne gueltige Lizenz in der
  Klasse sind (+ Link "Jetzt zuweisen"). licenses?class_id=X liefert dazu die
  Liste 'unlicensed'.
- Lizenzen-Lasche: neue Sektion "Ohne gueltige Lizenz" mit Einzel- und
  Sammel-Zuweisung (auto_assign) — sonst fuehrt die Warnung ins Leere.

Kunden-Vereinfachung (Feedback): keine Auto-Erkennung/Dedup mehr (Ansprech-
partner koennen wechseln). Kunde = Freitext "Name/Verlag/Schule" + Mailadresse,
je Bestellung gespeichert. Editierbares Kundennummer-Feld + Autocomplete entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 16:15:16 +02:00
Adminator eea5688c76 Lizenz-Vertrieb: CSV-Download ohne Kopfzeile (nur Codes)
Auf Wunsch: txt und csv liefern beide reine Code-Zeilen (eine pro Zeile,
keine Metadaten-Kopfzeile) -> direkt per Copy-Paste einloesbar. Nur die
Datei-Endung/MIME unterscheidet sich. Dateiname unveraendert
<ExtBestellnr|Datum>_geograsim_<interneNr>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 15:36:52 +02:00
Adminator 50c72fa8d8 Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere Datei-Einloesung, Mail-Config
Umsetzung des Thomas-Feedbacks:
- Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der
  reinkopierten Liste); stornierte ausgeblendet.
- Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert =
  ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und
  qty (geliefert); Live-Vorschau im Formular.
- Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account).
  einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus.
- CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen
  (per Copy-Paste einloesbar). Dateiname: <ExtBestellnr|Datum>_geograsim_<intNr>.
- Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil).
- Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen-
  limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server
  validiert) — kein Upload, keine Ausfuehrung/Rendering.
- Mailsystem: zentrale Config in neuer app_settings (Provider server|aws,
  Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand.
  Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt
  kanonische geograsim.at (nicht v3/localhost).

Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 11:15:53 +02:00
Adminator 96a73e22b2 Lizenz-Vertrieb: Chargen-Vergabe, Storno, Verlaengerung, Mail, Einloeselink
Erweitert das Lizenzmodul um eine Vertriebs-/Vergabe-Schicht ueber dem
bestehenden Einloese-Flow (non-breaking, alle Alt-Codes + generate/redeem
/assign unveraendert).

Schema (Migration 2026-08-26-lizenz-vertrieb.sql, additiv):
- license_customers (Besteller: Kundennummer/Email/Name)
- license_batches (Bestellung/Charge: externe Bestellnummer, Typ, Menge,
  Gueltigkeit, Sammel-Einloesetoken, Storno)
- licenses += batch_id, issued_at, valid_from/until (pro Code -> Verlaengerung),
  canceled_at/reason; code auf VARCHAR(32) (laengere Codes)
- license_mails (Mail-Archiv, unabh. vom IMAP), license_redeem_attempts
  (Brute-Force-Schutz), license_events (Audit-Log)

Backend (php/api/licenses.php):
- issue_batch: N frische 100-Bit-Codes in Transaktion, sofort vergeben,
  an Kunde/Bestellung/Gueltigkeit gebunden; sticky Default Gueltig-bis
- Uebersicht (view=batches mit Aggregat ausgegeben/storniert/netto/eingeloest
  /abgelaufen), batch_detail, Kunden-Autocomplete, sales_default
- cancel_batch/cancel_license, extend_batch/extend_license
- send_batch_mail (Server-Treiber via Mailer, archiviert, Anhang bei >40)
- redeem_charge (Sammel-Einloeselink)
- Brute-Force-Bremse + Audit + Storno-Ablehnung im redeem/redeem_batch

UI (admin-licenses.html): Ausgabe-Formular (Bestaetigung bei Grossmengen),
Ergebnis mit Copy/Download(txt+csv)/Mail-Composer/Sammellink, Bestellungs-
Uebersicht mit Filtern + Detail-Modal (Storno/Verlaengern/Verlauf/Mails).

Einloese-Landingpage (einloesen.html + pages/einloesen.php): ?charge=Token
(alle auf einmal) und ?code=CODE, mit Lehrer-Login-Gate.

Lib: Database Transaktionen; Mailer sendWithAttachments + renderPlainBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 01:39:14 +02:00
Adminator 7691649f37 Farmer Anbaugebiete: SO-Afrika-Polygon + Blickwinkel-Hinweis (Klima vs. Ertrag)
- koeppen-zones.json: eigenes subtropisch_feucht-Polygon ueber SO-Afrika
  (KwaZulu-Natal/Lowveld, lon 25-33 / lat -34..-22). Vorher fiel die Region
  durch bis zum groben Breitengrad-Fallback (+ fehlerhafter Wueste-Streifen im
  Sueden). Jetzt intentional per Polygon klassifiziert.
- game.html: Ernte-Bericht zeigt bei knapper Eignung (S3) einen schuelerlichten
  "Blickwinkel"-Hinweis: die Sim bewertet die NATUERLICHE Klima-Eignung; mit
  Bewaesserung/Schutz ernten Profis auch dort viel (Banane z.B. in Suedafrika,
  Ertrags-Weltmeister Syrien). Klima-Eignung != Anbau-Effizienz.
- Eignungswerte unveraendert: subtropische Banane bleibt bewusst S3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 00:29:12 +02:00
Adminator bb4d3b4cb6 heli-uebersicht: Cockpit-Link -> Haus-Icon (unser Stil) auf /schueler; Zur-Simulation-Button entfernt
Cockpit-Pille (fuehrte auf /sim ins Leere) ersetzt durch schwebendes
Haus-Icon -> /schueler (Schueler-Module). Zur-Simulation-Button raus,
da die Seite via target=_blank oeffnet (sonst mehrere Fenster).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 23:51:34 +02:00
Adminator b109ebb099 Heli: vorhandene Phasen-Links auf App-Icon-Stil (Option 2, kein Umbau)
Home/Hilfe/Uebersichtskarte als schwebende Icon-Buttons (Home/?/Karten-Pin)
statt Text/Emoji; Positionen als Icon-Cluster angepasst. Kein Header-Umbau,
keine JS-Aenderung (IDs/Handler unveraendert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 22:57:18 +02:00
Adminator 93c742b381 Sonnensystem: Onboarding-Button auf ?-Icon (wie andere Sims), i weggelassen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 22:48:41 +02:00
Adminator ff91c489f7 Sonnensystem: Aufgaben + Onboarding-Info in den Header (eigene Icons)
- obs-open-btn (Aufgaben, Klemmbrett-Icon) + obs-help-btn (Onboarding,
  Info-i-Icon) in den Header vor die Musik verschoben; obs-launcher weg.
- Redundantes help-toggle (moonTilt-?) aus dem Header entfernt, Handler
  null-sicher (moonTilt weiter ueber Inline-? in der Mondbahn-Sektion).
- obs-Pill-CSS neutralisiert (Header-Stil greift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 22:39:37 +02:00
Adminator 085b0b6aa8 Sonnensystem-Header-Fixes: Pille-Breite, obs-launcher, eigene Icons
- Komponente: .ggs-header--float bekommt display:flex (war auf design-
  system.css angewiesen) -> behebt volle-Breite-Pille bei Sims ohne
  design-system (Sonnensystem).
- obs-launcher (Aufgaben/Info) unter den Header (top 64, links 268).
- Eigene SVG-Icons: Leichte-Sprache (Textseite), Lights (Gluehbirne),
  Theme (Sonne/Mond-Umschaltung) statt Emoji.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 21:43:30 +02:00
Adminator 637c058507 Sonnensystem: einheitliche Kopfzeile oben, #ui + Top-Elemente nach unten
- ggs-header (Overlay) oben: Titel-Pille + Toolbar-Buttons (Home/Reset/BGM/
  Hilfe iconisiert, Easy/Light/Theme als Sim-Buttons) rechts oben.
- Toolbar + Titel aus #ui entfernt; #ui nach unten (top 62).
- Datums-Banner (top 64) und Lupe (top 104, inkl. renderInspect-Offset)
  unter den Header geschoben.
- Komponente: cockpit-link/home, reset-defaults-toggle/reset, help-toggle/
  help, bgm-toggle/music erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 21:10:47 +02:00
Adminator 5b6c25bcd9 Sonnensystem: linke Sidebar schmaler (280->244), Datum-Feld + lange Labels gekuerzt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 20:48:04 +02:00
Adminator 77a3e2f16c Sonnensystem: Lupen-Beschriftung skaliert mit den Stufen (kleiner bei 50/25 %)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 20:43:27 +02:00
Adminator 3f29ad0533 Sonnensystem: Umbenennung + Lupe mit 3 Stufen (iPad)
- Titel Himmelsmechanik -> Sonnensystem.
- Lupe (#inspect-frame) in 3 Stufen 100/50/25 % ueber Eck-Griff unten
  rechts: Frame-Groesse (CSS) + Render-Groesse (lupeScale in renderInspect)
  synchron -> Inhalt schrumpft mit. Behebt die riesige Lupe am iPad.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 20:17:41 +02:00
Adminator 248a46f829 Tourismus-Icons: Reduzierte-Animation als Funkeln (durchgestrichen wenn
aktiv), Tal-Log-Glocke, Tal-Meldungsmodus Sprechblase/ABC

- Komponente: generisches Zwei-Zustands-Icon (.ic-state-on/.ic-state-off
  via .active) -> reduzierte Animation zeigt durchgestrichenes Funkeln.
- Region: Schildkroete -> Funkeln (mit Aus-Zustand).
- Tal: btnLog -> Glocken-SVG; _syncInfoModeBtn -> Sprechblase (Audio) /
  ABC (Lese-Modus) statt Emoji (Buch war fuers Glossar vergeben).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 19:50:57 +02:00
Adminator 2e9ad6a83f Tourismusregion: eigene SVG-Icons (Log/Anim/Kamera) + Tempo ganz links
- Komponente: Tempo-Steuerung an erste Aktions-Position (ganz links),
  Rest ans Ende.
- Region: 🔔/🐢/🎯 durch eigene SVG-Icons ersetzt (logBadge erhalten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 19:34:02 +02:00
Adminator 6009eabca1 ggs-header: Tourismustal + Tourismusregion (Batch 2)
- Komponente: btnHome/⌂-Erkennung (ganzer Header durchsucht), sim-eigener
  Musik-Toggle (btnMusic) -> Musik-Icon, Panel-Optik (blur/shadow/radius)
  gestrippt fuer Sims mit .panel-Leiste.
- Beide Tourismus-Sims: topbar -> ggs-header (Klassen additiv, alle IDs +
  ui.js-Bindings erhalten), Tempo-Popover ueber .ggs-speed, Musik bleibt
  eigenes Panel (btnMusic oeffnet es). ggs-header.css vor style.css fuer
  position:fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 17:46:46 +02:00
Adminator 41731254b1 ggs-quick-intro: alwaysShow-Option; Busfahrt zeigt Onboarding bei jedem Besuch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 17:19:14 +02:00
Adminator 1dffef8200 Busfahrt: nur EIN Onboarding (Welcome-Overlay abgeloest)
Ursache 2x: Busfahrt zeigte sein eigenes #welcome-overlay UND das
GgsQuickIntro-Onboarding. Jetzt ist GgsQuickIntro das einzige Start-Gate:
- ggs-quick-intro: onStart-Callback (feuert einmal beim Schliessen bzw.
  sofort wenn schon gesehen), isSeen exportiert.
- Busfahrt: welcome-overlay wird nicht mehr gezeigt; onStart -> startGame;
  startGame wartet per Retry auf boot() (kein Timing-Problem).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 17:09:28 +02:00
Adminator 496fc59c7b Busfahrt: Neustart-Button + Onboarding-Doppelung abgesichert
- ggs-quick-intro: open() entfernt vorab alle vorhandenen Overlays (nie 2x).
- Busfahrt: Neustart-Button (btn-reset, location.reload mit Rueckfrage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 16:54:34 +02:00
Adminator 8f89613eee Busfahrt: Auftrags-Card tiefer, EN-Button -> Onboarding Seite 2, Texte
- ggs-quick-intro.js: optionale zweite Seite (config.page2 {title, html})
  mit Weiter/Zurueck-Navigation. Rueckwaertskompatibel (ohne page2 = 1 Seite).
- Busfahrt: EN-Button entfernt; die "Warum Englisch?"-Erklaerung liegt jetzt
  auf Onboarding-Seite 2 + Satz zur mehrsprachigen Hintergrundmusik.
- Onboarding-Schritte ohne lange "-" ausformuliert.
- Auftrags-Card (.bf-order) 20px tiefer (top 84).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 16:32:32 +02:00
Adminator 6135dd2648 Busfahrt: Vollbreite-Header (Logo/Haus links, Rest rechts), Karten nach
unten, eigenes Kartennamen-Icon

- Header wieder Vollbreite: Pille+Home links, Steuerung rechts.
- Auftrags-Card (.bf-order) + Info-Card (.bf-info) top 8 -> 64 (unter den
  rechten Buttons, keine Ueberlappung).
- Beschriftung-ein/aus (black-map-toggle): eigenes Tag-SVG statt Emoji
  (an = Tag, aus = Tag durchgestrichen), auch in applyTileVariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 16:23:12 +02:00
Adminator aab9b35781 Busfahrt v2: Header kompakt-links, Budget/Auftrag nach links, Zoom unten
- Header kompakt links (nicht mehr Vollbreite) -> keine Ueberlappung mit
  der Auftrags-Karte oben rechts.
- Budget aus Header raus (widersprach dem Graph); Graph-Budget-Wert wird
  jetzt live in updateTopbar aktualisiert. updateTopbar null-sicher.
- Auftrag-Nr ins Stats-Panel (links bei den Graphen).
- Leaflet-Zoom nach unten links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 16:14:09 +02:00
Adminator 014eb418a6 Busfahrt: ggs-header eingebaut (Batch 2)
ggs-topbar -> ggs-header (Vollbreite, klick-durchlaessig), Titel ohne
Emoji, Logo-Fallback, KPIs (Budget/Auftrag/Nacht) bleiben als Pillen,
eigener Musik-Player in #ggs-music gewrappt (Popup), Sim-Buttons
(Beschriftung/EN-Info) + Ton/Hilfe/Home + Plattform-Glossar. Keine
Elemente entfernt (alle IDs/onclicks erhalten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:54:57 +02:00
Adminator e901f5addb Farmer: Markt + Erntejahr wieder untereinander (gestapelt), gleich breit/hoch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:47:37 +02:00
Adminator abc8bf5149 Farmer: Markt + Erntejahr gleich breit/hoch nebeneinander (nicht mehr Vollbreite)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:39:49 +02:00
Adminator 54772180a2 Farmer-Feinschliff: Markt+Erntejahr gleich breit, Buch -> Plattform-Glossar
- farmer-side-status: Markt-Feld und Erntejahr-Button beide volle Box-Breite.
- Kaputte interne Fachinfo (openInfoModal) raus; Komponente ergaenzt den
  Glossar-Button (/glossar?module=farmer, neuer Tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:36:01 +02:00
Adminator 4cbdb6bbb1 Farmer: Klick-Blocker gefixt (updateMoneyDisplay-Crash) + Konsolidierung
ROOT CAUSE: #money-display aus Header entfernt, aber updateMoneyDisplay()
las es beim Boot ungeschuetzt -> null.textContent wirft -> Boot bricht ab
-> map.on('click') wird nie gebunden -> Sim unbedienbar. Jetzt null-safe.

Konsolidierung (Thomas-Wunsch): Jahr/Markt/Erntejahr-starten in die
Anbau-Versuch-Box (farmer-side-status) statt separater Jahr-Leiste;
Jahr-Badge aus der Pille raus. Anbau-Box wieder hoeher (top 64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:31:33 +02:00
Adminator b835475ef7 Fix: schwebende Kopfzeile blockierte Karten-Klicks (Farmer unbedienbar)
Ursache: die transparente ggs-header ueber der Vollbild-Leaflet-Karte
fing alle Klicks ab -> Pflanzen unmoeglich. Fix zentral: Container
pointer-events:none, nur Bedienelemente pointer-events:auto (Karte
darunter bleibt bedienbar). Gilt fuer alle Karten/Canvas-Sims.

Farmer: Pille nach links (Vollbreite-Header, Steuerung rechts),
Leaflet-Zoom nach unten links verschoben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:20:33 +02:00
Adminator ec746f250f Header-Komponente: Bespoke-Fixes (Logo-Verzerrung, Icon-Groesse, Glossar-Icon)
- Logo-Kreis resettet Padding/Border/min-height (Sim-eigenes a-Styling
  vererbte sich -> verzerrter Logo-Kreis).
- .ic-Groesse (20px) auch fuer Buttons/Links ohne .ggs-btn-ghost-Klasse
  (Lautsprecher/Hilfe rendeten riesig).
- Sim-interne Glossar-Buttons (glossar-btn u.a.) bekommen das Buch-Icon
  statt Emoji.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:07:09 +02:00
Adminator 6c2990aa5e Farmer-Header v2 nach Thomas-Feedback + Default-Musikplayer
- Header nach oben RECHTS (raus aus dem Leaflet-Zoom oben links),
  Geld-Anzeige entfernt (steht in linker Sidebar), Emoji aus Titel,
  Jahr als Badge in die Pille (Konsistenz mit Batch 1), Inter-Font.
- Jahr-Leiste + Anbau-Versuch-Spalte nach unten geschoben (unter Header).
- Komponente: wiederverwendbarer Default-Musikplayer (data-ggs-music,
  Klima-Tracks als Plattform-Standard) + Musik-Widget-Styling fuer Sims
  ohne design-system.css.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 15:02:18 +02:00
Adminator 7b1bdb8d1b ggs-header Batch 2 Start: Farmer + Komponenten-Vorruestung
- Komponente: Logo-Fallback (Kreis-Logo wenn Sim keins hat), Overlay-Modus
  (data-ggs-overlay fuer Vollbild-Canvas), generische Icon-Button-Optik fuer
  Aktionen ohne .ggs-btn-ghost-Klasse, Ton-IDs erweitert (audio-toggle/btnSound).
- Farmer als erstes Bespoke-Muster: ggs-topbar -> ggs-header, schwebende Pille
  (Fallback-Logo + Titel) + Geld-KPI + Icon-Buttons; interne Fachinfo bleibt
  (data-ggs-no-glossar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 14:33:53 +02:00
Adminator c913522a77 Kofferdetektiv: Kopfzeile schwebt ueber Vollbild-3D (schwarzer Balken weg)
#stage jetzt inset:0 (Canvas bis ganz oben), Header position:absolute
transparent darueber. Obere HUD-Pillen + Minimap + Karten-Button unter
die Kopfzeile geschoben (top 14->64, btn-map 166->216).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 14:20:36 +02:00
Adminator 502f271f36 Einheitliche Sim-Kopfzeile (Entwurf B) als Komponente + Lizenz-Datenmodell
- ggs-header.css/.js: schwebende Pillen-Kopfzeile aus vorhandenem
  .ggs-header-Markup (Kreis-Logo/Titel/Level als eine Pille, eigene
  SVG-Icons, Tempo-Panel links + Zustandsbutton, Musik-Panel unten,
  30% transparente Untermenues, Glossar target=_blank, Lehrplan raus).
  Enhance-Ansatz: Spiel-Logik (IDs/Klassen, data-speed) bleibt unberuehrt.
- Eingebaut in Klima 2D/3D, Fluss, Logistik (Auto-Tempo), Energiemanager,
  Kofferdetektiv 3D.
- admin-licenses.html: Datenmodell-Sektion (licenses-Tabelle, Lebenszyklus
  Generiert->Eingeloest->Zugewiesen, Beziehungen, Jahr->Schuljahr->Code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 14:06:41 +02:00
Adminator 362aaba883 Feinschliff-Spalten + Kopfzeilen-Panels (Placement/Transparenz)
- teacher.html Aktuell-Spalten: Weltkueche '🏆 Punkte' (score), Fluss
  '👥 Einw.' + '🌾 Ernte' (population/food), Busfahrt '📍 Ø km' (avgTapKm).
  (Kofferdetektiv Items-% vertagt: Sim liefert live noch keine Item-Zahl.)
- admin-styleguide.html Kopfzeile: Untermenues 30% transparent (Blur);
  Tempo oeffnet links + bleibt offen, Musik klappt nach unten + schliesst
  nach Song-Auswahl; Toggle per Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 13:04:32 +02:00
Adminator 88e3c09e5c Per-Sim leistung_pct: alle 14 Sims senden das eine Leistungs-% (0-100)
Jede Sim liefert jetzt selbst leistung_pct in GGS_LIVE_STATE UND im Submit
(Fundament fuer das einheitliche %-System) — kein Rueckrechnen aus Sternen
mehr, keine 0-3-Sonderfaelle. Formeln je Sim wie vereinbart:
- klima/fluss/kofferdetektiv/heli: = vorhandener 0-100-score
- weltkueche: richtige/(richtige+falsche)*100 (Fehler in %)
- busfahrt/fluggesellschaft: topoPunkte/(Auftraege*3)*100
- farmer: gewichteter totalScore; energiemanager: ausgegl./gesamt -15/Blackout
- eu-werkstatt: firstTry/Schritte -5/Muelleimer; sonnensystem: Erstversuch-Quote
- tourismustal: Ziele+Zufriedenheit+Natur gewichtet; tourismusregion: gewichteter score
- logistik: Kontostand->% (sendet erstmals eine Kennzahl, auch an /api/assessment)
Teacher _leistungPct + live.php extractScore bevorzugen bereits diesen Wert.
Additive Aenderungen, bestehende Logik/Sterne unveraendert; alle 15 Dateien
syntaktisch geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 12:35:56 +02:00
Adminator 539f6b012e leistung_pct-Fundament (Teacher+Backend bevorzugen) + Tempo-Button-Zustaende
- teacher.html _leistungPct: von der Sim geliefertes s.leistung_pct hat
  Vorrang, sonst zentrale Ableitung (Fallback).
- live.php extractScore: leistung_pct (0-100) hat Vorrang vor score/Sterne.
- admin-styleguide.html: Tempo-Button zeigt IMMER den Stand (1x/2x/8x Auto/
  Pause); Toggle-Verhalten (Panel bleibt offen bis erneutem Klick) beschrieben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 12:31:23 +02:00
Adminator 7e1a7c5228 Kopfzeile: nur Entwurf B, eigene SVG-Icons, aufgeklappte Controls
- A und C entfernt, nur schwebende Variante B.
- Emojis raus -> eigene SVG-Icons im App-Stil (Home/Tempo/Musik/Ton/
  Neustart/Hilfe/Glossar) via <symbol>/<use>.
- Logo (bildLogo.png) + Titel + Level in EINER Pille, alle Elemente 38px
  hoch, gleiche Hoehe (Hoehen-Mismatch behoben).
- Tempo (Segmente Pause/1x/2x/4x) und Musikplayer (Song-Dropdown + Play +
  Lautstaerke) zusaetzlich in aufgeklappter Version dargestellt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 11:40:50 +02:00
Adminator ce7bff3184 Drilldown-Metriken: schwarze Balken -> farbig + Sterne raus (sim-metrics.js)
bar() faerbte ueber ivm-CSS-Klassen, die im Schueler-Drilldown fehlen ->
Balken erschienen schwarz. Jetzt INLINE gefaerbt (ivmColor = _pctColor-Skala).
starsBlock (Heli Start/Landung) zeigt jetzt %+Balken statt Sterne.
pct/pctInv/ratio/money/temp/bool ebenfalls inline gefaerbt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 11:34:25 +02:00
Adminator 89d4d8587e Teacher-Fixes (Heli-Flugplan, Modul-Label) + Kopfzeilen-Entwuerfe
- teacher.html: Modul-Detail-Spalte 'Sterne' -> 'Leistung' (zeigt %-Balken).
  Heli 'Aktuell': neue Spalte 'Flugplan' (kumuliert Wegpunkt/Treffer/Fehlkl.),
  die drei Detailspalten bleiben, 'Gesamt' entfaellt (Leistung deckt es ab).
- admin-styleguide.html: neue Sektion 'Kopfzeile' mit 3 Entwuerfen
  (A durchgehend / B schwebend / C schwebend+halbtransparent), je als
  iPad- und Full-HD-Mockup ueber simuliertem Sim-Inhalt. Glossar target=_blank,
  Neustart drin, Lehrplan raus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 11:28:03 +02:00
Adminator a4e57d7aa6 Cockpit-Triage + Sterne->%+Balken durchgaengig (teacher.html)
Cockpit (Uebersicht):
- Farben auf kanonische Palette (--ivg..--ivr = _pctColor-Hex); ivAmp-
  Schwellen auf 50/60/80/90.
- ivTriage neu: >=90 Fordern (kann alles) / 50-89 Laeuft gut /
  <50 & fleissig -> Begleiten / <50 sonst -> Foerdern. Bestaetigen entfaellt.
- Fleiss im Klassenvergleich (_fleissig = 0.5*Zeit + 0.5*Durchgaenge >= Schnitt),
  fuer HEUTE- und insgesamt-Triage. IV_META-Texte angepasst.

Sterne raus -> %+Balken (colorblind-freundlich):
- _pctBadge (Run-Karten) neu. Alle 7 Run-Karten + generische Sonnensystem-
  Karte auf %+Balken (Busfahrt/Flug rechnen /3). Heli Start/Landung/Gesamt-
  Spalten in Aktuell auf _pctBar. Spectator, Drilldown (ivStars,
  Top-Performers), fmtMetric, Heli-Detail: Balken bzw. %.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 10:17:05 +02:00
Adminator 3bc0f418be Bewertungs-Vorschau: EINE Skala-Darstellung + 4-stufige Triage
- Doppeldarstellung (Beispiel-Balken + Legende) zu EINER Tabelle
  zusammengefuehrt: Farbe + Bereich + Label + Beispiel-Balken je Zeile.
  Beispielwert = Bereichsschwelle (90/80/60/50), nicht willkuerlich.
- Triage jetzt 4 Felder an denselben Farbgrenzen:
  <50 Foerdern (Hilfe) / 50-79 Begleiten / 80-89 Laeuft gut /
  >=90 Fordern (herausfordern). Styleguide + Preview angeglichen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 02:51:50 +02:00
Adminator 0a6fa2172f Einheitliche Leistungs-Skala (%) + Farbcodes ueberall vereinheitlicht
- Schwellen NEU (Thomas): >=90 Dunkelgruen / >=80 Hellgruen / >=60 Gelb /
  >=50 Orange / <50 Rot. Sterne entfallen.
- teacher.html: _pctColor (kanonisch) — _tagTraffic + _liveBar + _liveBarColor
  delegieren jetzt alle daran (EINE Skala; 'niedrig-ist-gut' invertiert).
  Neu: _leistungPct(simId,state) = das eine 0-100-% je Sim (eigener Score
  oder abgeleitet), _pctBar (kompakt, schmal). Neue Spalte 'Leistung' in
  'Aktuell'. _liveMood aus _leistungPct (>=80 gut / >=50 mittel / <50) —
  deckungsgleich mit Triage Foerdern/Begleiten/Laeuft gut (50/80).
- admin-styleguide.html: neue Sektion 'Leistungs-Skala' (5 Stufen, %+Balken,
  Triage, CSS-Variablen).
- admin-lehrer-view.html: Schwellen/Triage/Preview auf neue Werte, Balken
  kompakt, Sterne-Hinweis raus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 02:37:33 +02:00
Adminator c79ef691ad Lehrer-View-Uebersicht: Vorschau einheitliches Leistungs-% (Sterne raus)
- Vorschau-Karte: %+Balken-Standardanzeige, 5-stufige Farbskala,
  Triage-Felder (Foerdern/Begleiten/Laeuft gut) aus %xFleiss mit
  Grenzen = Farbgrenzen (50/65), damit Farbe<->Feld immer stimmen.
- Neue Spalte 'Leistung' je Sim: Vorschlags-Formel auf die Testdaten
  gerechnet (PERF), als pctBar + Triage-Chip. Busfahrt/Flug/Sonnensystem
  Naeherung (Rohwerte fehlen in Testdaten).
- Bewertungs-Block je Sim um 'Vorschlag %' (PROPOSED_PCT) erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 02:25:35 +02:00
Adminator bf579310ed Lehrer-View-Uebersicht: Sterne- + Prozent-Formeln je Sim dokumentiert
- Globale Erklaer-Box: wie der Zeilen-% entsteht (live.php extractScore:
  Score sonst Sterne/5x100) + Uneinheitlichkeiten (0-3 vs 0-5 Sterne,
  rohe score-Werte, Weltkueche pro Gericht, Logistik sendet nichts) +
  Vorschlag 'ein System' (% = Wahrheit, Sterne abgeleitet, gleiche Schwellen).
- Pro Sim ein Bewertungs-Block mit exakter Sterne-Formel + Prozent-Herkunft
  (aus dem Sterne-Audit der Game-Files) inkl. Warnhinweisen.
- Tourismustal: Gaeste-Spalte auf gruen (Gesamtgaeste = Spielfaktor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 01:24:05 +02:00
Adminator 691387c4d8 Admin: Lehrer-View Parameter-Ueberblick Schuelerleistung
Neue Referenzseite /admin-lehrer-view.html: pro Sim (14, der Reihe nach)
die 'Ergebnisse/Aktuell'-Tabelle mit Testdaten, gerendert mit der ECHTEN
Logik (LIVE_PRIMARY_FIELDS + Formatter aus teacher.html gespiegelt).
- Unter jedem Spaltentitel der State-Key (Spiel->Backend-Verkabelung).
- Spalten GRUEN, die nach Abschluss relevant bleiben (Ergebnis/Fortschritt/
  Identitaet), GRAU = nur live; je Sim Begruendungs-Liste der Grau-Spalten.
Verlinkt aus admin-sitemap.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 19:37:26 +02:00
Adminator 62e903a5ec Kofferdetektiv iPad-Performance: sicheres Buendel (Aufloesung + AA + GPU)
Nur auf Touch-Geraeten (Desktop unveraendert):
- Render-Aufloesung auf dpr 1.3 gedeckelt (dprCap-Helper) statt bis 2 —
  dpr 2 = 4x Pixel war der teuerste Posten.
- antialias aus (MSAA teuer auf Mobile), Retina-Dichte kaschiert Kanten.
- powerPreference:'high-performance' fuer die staerkere GPU-Pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:13:35 +02:00
Adminator 11c48817de Kofferdetektiv iPad: zwei feste Joysticks (immer 50% sichtbar)
Statt schwebendem Links-Joystick + Rechts-Wisch jetzt zwei feste,
halbtransparente Joysticks:
- links = Umsehen (Auge 👁), ratenbasiertes Drehen/Neigen in update()
- rechts = Bewegen (Fuß 🦶)
Touch wird dem naeheren Joystick im Fangradius zugewiesen (assign/stickVec).
Interaktions-Button (🧳) nach unten-mittig verschoben (kollidierte sonst
mit dem rechten Bewegungs-Joystick). Joysticks nur auf Touch sichtbar
(body.kd-touch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:57:20 +02:00
Adminator 8ec005f889 EU-Werkstatt: Probierzuege (Karten rausziehen) erfassen + Sterne ehrlicher
Bisher konnte man Karten in Slots einsetzen und wieder herausziehen, bis
alle voll waren — erst dann wurde ausgewertet. Dieses Herumprobieren war
unsichtbar und senkte die Sterne nicht (5 Sterne trotz Trial-and-Error).

- game.html: reshufflesByStep/totalReshuffles; manuelles Herausziehen einer
  platzierten Karte zaehlt als Probierzug. Ein Schritt gilt nur ohne
  Probierzuege als 'sauber auf Anhieb' -> Sterne (firstTryHits/steps) und
  Treffsicher-Achievement spiegeln Trial-and-Error jetzt. Endbild zeigt
  '🔄 N Probierzuege'; Live-State + Submit tragen die Zahlen.
- teacher.html: needsHelp eu-werkstatt auch bei vielen Probierzuegen;
  '🔄 Probier'-Spalte in Aktuell; Run-Karte: Gesamt-Probierzuege +
  pro Schritt ('🔄 nach Probieren' statt 'auf Anhieb').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:33:44 +02:00
Adminator 7de71b0e83 Energiemanager: Blackouts fuer Lehrer sichtbar machen
- game.html: bei Blackout sofort action:'blackout' an /progress feuern
  (setId/Tag/Block/Frequenz/Ursache, score:0) — Fehlversuch bleibt auch
  bei Set-Abbruch sichtbar, kein player_progress/Sterne.
- progress.php: neuer action:'blackout' -> eigene assessments-Zeile,
  KEIN XP/plays/best_stars.
- live.php: Blackout-Flag (event:'blackout') durch die Tag-Summary reichen
  (blackout/blackoutCount pro Session).
- teacher.html: needsHelp energiemanager (>=2 Blackouts oder Tag <4/8);
  Blackout-Spalte + '🔌 neu planen'-Status in Aktuell; Tag-Balken als
  🔌 Blackout kennzeichnen; Run-Karte: eigenes Blackout-Kaertchen +
  '🔌 N Blackouts'-Badge im fertigen Set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:19:21 +02:00
Adminator b77f4ed912 Tag-Ansicht: student_results einbeziehen + Datum/Zoom-Verhalten
- live.php summary: fertige Laeufe aus student_results ergaenzt (dedupliziert gegen assessment/
  live) -> JEDER heutige Durchgang erscheint als Balken, egal ueber welchen Submit-Pfad.
- teacher.html: Ergebnisse-Tab/Menuewechsel setzt Datum auf HEUTE + Regler auf Auto zurueck;
  Datumswahl/Heute setzt Regler auf Auto; Auto-Zoom jetzt +/-1 h um die Aktivitaet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:33:32 +02:00
Adminator 868cdda276 Fluss-Endscreen: zwei Felder (Rueckmeldung -> Aktionen) + Badges nebeneinander
Feld 1 = Rueckmeldung (Sterne/Stats/Badges/Reflexion) mit "Weiter"-Button (erst nach
Reflexions-Antwort aktiv); Feld 2 = "Wie geht's weiter?" (Stufen/Erneut versuchen +
Zurueck zur Startseite). end-badges jetzt display:flex (nebeneinander statt gestapelt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:19:10 +02:00
Adminator 9a1ee0fae1 Tag-Zeitachse: gestauchte Skala - Nacht 00-06 und Abend 21-24 je 1 Block, Tag 06-21 stuendlich
Regler UND Zeitachse/Balken nutzen dieselbe Stunde-Einheit-Abbildung (_hourToUnit/_unitToHour,
17 gleich breite Bloecke). Nacht-/Abend-Bloecke dunkel ohne interne Linien; Tag stuendlich
beschriftet + Trennlinien. Auto-Default rundet auf Block-Grenzen. Marker an den Block-Grenzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 22:23:34 +02:00
Adminator 1e1c1238bd Stadt & Raumplanung auf 'geplant' (In Vorbereitung, nicht spielbar) in v3 + Prod-SQL notiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 21:51:58 +02:00
Adminator de104e915a Tag-Zeitstrahl: keine Trennlinien in den Nacht-Baendern (zusammengefasst)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 21:50:13 +02:00
Adminator 193c6dcc8f Tag-Zeitstrahl: 23 Stunden-Trennlinien + stuendliche Beschriftung + dunklere Nachtzeiten (00-01/20-24)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 21:48:33 +02:00
Adminator 74736a33aa Tag-Ansicht: Zeitfenster-Doppelschieberegler (00-24 Uhr, stuendlich) ueber der Tabelle
Default = Auto (erste bis letzte Aktivitaet, auf volle Stunden). Zwei Fader zum
Zusammenschieben (z.B. aktuelle Stunde). Balken werden aufs Fenster gekappt (overflow).
"Ganzer Zeitraum"-Reset zurueck auf Auto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 21:42:27 +02:00
Adminator f7bf0f0901 Fix: alle heutigen Leistungen zaehlen (Ben) + Live-Session started_at bei Modulwechsel (Clara)
- live.php: ON DUPLICATE KEY UPDATE-Reihenfolge korrigiert (started_at VOR module_id), sonst
  wird der Modulwechsel nie erkannt -> Balken behielt alten Startzeitpunkt (Clara EU-Werkstatt seit 14:30).
- results.php view=today: liefert todayScores (Pro-Schueler-Aggregat aus student_results, zuverlaessig).
- teacher.html HEUTE-Triage: fertige Laeufe aus student_results + laufende aus Summary zusammengefuehrt;
  Totalversagen heute (<40) -> Foerdern. Ben (klima 36) erscheint jetzt korrekt in Foerdern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 15:21:04 +02:00
Adminator cc83493055 Lehrer-Live: idle-pausierte gelten in Aktuell als pausiert (nicht aktiv) + Tag-App-Symbol immer sichtbar
- Aktuell: paused beruecksichtigt jetzt __idlePaused/__paused (heartbeatet trotz Pause weiter)
  -> kein Widerspruch mehr "navigiert UND pausiert" (Klara).
- Tag: App-Symbol als immer sichtbares Overlay am Balkenstart, auch bei winzigem Balken (darf ueberstehen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 15:11:20 +02:00
Adminator abbc0580c5 Heli M17: A10-Tauern-Zielpunkt auf Tauerntunnel-Nordportal (lag 7km noerdlich im Gebirge)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 15:07:51 +02:00
Adminator be7d9fce1a Empfehlung: 6. Karte 'Laeuft gut' in gleicher Form (volle Liste mit Balken) statt Kompakt-Streifen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 15:05:08 +02:00
Adminator da1e6e6e87 Weltkueche: correct_for im Spiel scharfgeschaltet (V3)
ING-Build kopiert jetzt v.correct_for -> der bestehende Heimat-Branch (ing.correct_for[heimat])
liefert die kuratierten Zweit-Laender: mehl->Ungarn, majoran->DE, safran->Iran, feta/lamm->
Bulgarien (statt Gravitations-Schaetzung). Datenkonsistenz geprueft (Moussaka feta/lamm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 14:50:13 +02:00
Adminator bc468272ba Heli M10: A2-Baden-Zielpunkt zurueck auf die A2-Linie (DB-Wert lag daneben); Prod-SQL ergaenzt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 14:36:26 +02:00
Adminator 4976582400 Heli Routenplanung: Timing-basierte Wertung (3 Stufen) + Bonus vor rotem Hint
Pro Wegpunkt: 2=Treffer VOR der roten Einblendung (Bonus) · 1=waehrend der 10 s · 0=danach.
computePlanStars() aus Ø-Timing (5/4/3/2/1); Fehlklick-Abbruch bleibt 0. wpRevealAt merkt
Reveal-Zeitpunkt, onPlanClick klassifiziert. Bonus im Endbildschirm + Assessment (planBeforeRed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 14:32:02 +02:00
Adminator bf5b026b46 Heli-Wegpunkt-Fix: Prod-SQL persistent im Repo (Live-DB hat gleiche Luecke)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 14:04:32 +02:00
Adminator 77023ff7a3 Heli: fehlende Wegpunkte-Datenluecke behoben + Routing-Score = Klick-Genauigkeit
- 5 Wegpunkte fehlten in geo_waypoints (bodensee_bregenz/kaiser_kufstein/felswand_raetikon/
  schlucht_mellau/maria_saal) -> Missionen M5/M7 u.a. hingen nach dem vorletzten WP. In v3-DB
  eingespielt; Prod-SQL in .dontDeploy (Live-DB gleiche Luecke). waypoints.js: maria_saal ergaenzt.
- GGS_LIVE_STATE score: Routing zaehlt jetzt Klick-Genauigkeit (richtige/alle) statt Fortschritt
  -> kein roter Tag-Balken mehr am Planungs-Anfang (0 Klicks = grau, nicht rot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 14:03:53 +02:00
Adminator aaa40e9f1a Heli Aktuell: letzten Einsatz in der Auftragswahl behalten
game.html: bei Mission-Ende Snapshot game.lastResult (Start/Landung/Gesamt); GGS_LIVE_STATE
zeigt in mission/briefing den letzten Einsatz statt leerer Spalten. teacher.html Status:
"✓/⚠ letzter Einsatz" wenn Werte aus dem letzten Lauf stammen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 13:57:57 +02:00
Adminator 0d9481cae2 Tag-Ansicht: pausierte Zeiten bleiben als Verlauf stehen (verschwinden nicht mehr)
live-client loggt Pausen-Spannen (__pauseSpans: {s,e}), live.php reicht sie durch,
renderLiveSummary zeichnet jede als bleibendes halbhohes Segment im Tag-Balken
(offene/laufende Pause blinkt). Fallback fuer Sim-Pause via phase bleibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:53:07 +02:00
Adminator 0d765961af Idle-Pause fuer Heli funktionsfaehig + iframe-aware Interaktionserfassung
- Interaktionen aus gleich-origin iFrames (Heli-Start/-Flug/-Landung) zaehlen jetzt
  (periodisches _hookFrame), plus postMessage {type:'ggs-activity'} + __GGS_LIVE__.ping().
  Aktives Steuern gilt nicht mehr faelschlich als idle.
- EVENTS erweitert (keyup/touchmove/pointermove). Heli in IDLE_PAUSE_SIMS aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:50:30 +02:00
Adminator 7986bb2eb6 Heli: Treibstoff kann leer werden (1★-Takeover) + Live-Score fuer Tag-Farbe + Aktuell-Status/Phase
- start.html: Sprit-Floor 0.05->0; Treibstoff-leer loest Chefpilotin-Takeover aus (stars:1)
- game.html: GGS_LIVE_STATE liefert progressiven score (Routing/Start/Landung/Gesamt) -> farbige Tag-Balken
- teacher.html: Phasen-Map + Status-Spalte fuer mission/briefing/unterwegs (kein leeres "...")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:48:26 +02:00
Adminator 6dfcf4d57f Idle-Pause vorerst NUR in Klima (per-App-Freischaltung)
Die plattformweite Idle-Pause stoerte passive/iframe-Phasen (Heli-Flug: aktiv gesteuert,
aber Steuerung im iframe/Halte-Taste zaehlte nicht als Interaktion -> Fehlpause).
Jetzt Allowlist (klima/klima-3d) bzw. window.GGS_IDLE_PAUSE=true. Andere Apps: keine
Auto-Pause, bis sie dort einzeln getestet + die Interaktionserfassung angepasst ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:27:01 +02:00
Adminator 0532327e6a Heli: Onboarding-Zurueck-Button + Routen-Card-Sicherheitsnetz (ohne Ton)
- Onboarding: "Zurueck"-Button (ab Schritt 2, prevHeliOnboarding)
- Routenplanung: planInstruction-Card erscheint garantiert auch ohne Ton / bei
  Audio-Ladefehler (Fallback + Timeout-Netz); bleibt bis zum Wegpunkt-Fund stehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:25:08 +02:00
Adminator 5d79fa3c3a Klima: Flut-Bewertung ans 40%-Spielziel angeglichen
Flut-Score (End- + Live-Score in game-2d/3d) = max(0, 25*(1-Flut/50)): 0% volle 25 Pkt,
40% noch ~5 Pkt, erst ~50% gegen 0 (statt 0 schon ab 31%). Lehrer-Geflutet-Ampel gruen
<25 / gelb <=40 / rot >40. Klima-Stimmung nutzt jetzt den (fairen) Score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:19:41 +02:00
Adminator c9eb0e7764 Cockpit: "Empfohlene Aktivitaet fuer dich" zweigeteilt - HEUTE (oben) + insgesamt (darunter)
HEUTE triagiert exakt gleich (support/more/work/good/idle), aber nur auf die heutigen
Laeufe (Erfolg = Ø der heutigen Scores aus summary-Endpoint). Karten-Renderer in
_ivTriageCards ausgelagert + wiederverwendet. Neuer summary-Fetch im Promise.all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:16:10 +02:00
Adminator 3c28794012 Tag-Ansicht: pausierte Laeufe als halbhoher weiterlaufender Balken
live.php meldet paused (Idle-Pause/Sim-Pause/phase=paused) + activeUntilMs (letzte
Interaktion). renderLiveSummary: Hauptbalken volle Hoehe bis Pausenbeginn, danach
halbhoher gedaempfter "Pausen-Schwanz" der mit der Zeit weiterwaechst. Legende + tz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:02:27 +02:00
Adminator 5633d3f9f9 Lehrer-Live-Zeiten fest auf Europe/Vienna (Server laeuft UTC) -> immer oesterreichische Zeit
_hhmm/_vdt/_fmtHM formatieren mit timeZone Europe/Vienna; _asMs normalisiert Epoch UND
MySQL-UTC-Strings. Betrifft Tag-Achse+Balken, Session-Detail, "Heute erledigt", Cockpit
"zuletzt aktiv", Reflexionen, letztes Lebenszeichen. Vorher zeigte new Date().getHours()
je nach Geraet evtl. UTC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:55:44 +02:00
Adminator e4b877de4b Tag-Ansicht: Zeitachse hugt die Aktivitaet (Start am ersten Spiel statt gerundeter halber Stunde)
Vorher rundete winStart auf 30-min + 15-min-Polster -> bis zu 45 min leerer Platz vor
dem ersten Balken (z.B. Spiel 09:44, Achse begann 09:00). Jetzt winStart = erstes Spiel
minus 4 min, Marker adaptiv auf sauberen Uhrzeiten (15/30/60-min je nach Spanne).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:48:45 +02:00
Adminator 550a35eae8 Klima Aktuell: Bevoelkerungs-Balken relativ zur Startbevoelkerung (klemmte vorher bei 100%)
Skala war fix 1500-4500 -> alle Populationen (5000-8800) klammerten bei 100%. Jetzt
relativ zum Level-Start (6000/7000), 30%-160% -> unter Start rot, gewachsen gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:45:21 +02:00
Adminator 47adf0497d Klima: Geflutet an Meeresspiegel koppeln (kein Deckel-Plateau) -> Zahl passt zur Karte
floodedPct = sea*0.45 + max(0,sea-prot)*1.1. Vorher deckelte der Strand-Anteil bei
12%, sobald Schutz das Inland trocken hielt -> alle Spieler zeigten 12% egal wie das
Klima stand. Die Karte zeichnet die Flut aber aus seaLevelCm -> Zahl und Bild liefen
auseinander. Jetzt steigt die Zahl stetig mit dem Meeresspiegel (Klimafolge), Schutz
daempft nur den Zusatzschaden. Node-validiert: gutes Klima=niedrige Flut, hohe Temp=
hohe Flut, gleiche-Tick-Spreizung statt Plateau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:43:58 +02:00
Adminator 48971b4588 Plattformweit: Idle-Pause-Overlay nach 3 min Untaetigkeit mit "Weiter"-Button
live-client.js (laedt in JEDER Sim): nach 3 min ohne Interaktion sichtbare Pause
(friert Aktivzeit ein -> Idle zaehlt nicht als Arbeitszeit), Weiterarbeiten per
Klick. Heartbeat meldet __paused/__idlePaused. Kein Sim-Code noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:32:50 +02:00
Adminator 2386756a20 Klima: Geflutet-Kurve stetig + Schutz wirksam (Rebalance) · Lehrer-Cockpit "Nicht aktiv" responsiv
- engine.js: floodedPct steigt jetzt stetig mit dem schutzbereinigten Meeresspiegel statt bei 15% zu plateauen. Guter Schutz haelt das Inland lange trocken (effRise = sea - prot*0.85), Strand-Verlust gedeckelt bei 12%. Downstream mitrebalanciert: Pop-Verlust-Multiplikator 5->3, Wachstums-Schwellen 15/35, Flut-Events 8-20/40, Ende bei >60. Node-validiert: guter Spieler gewinnt + Bevoelkerung waechst (statt Kollaps), Flut diskriminiert gut/mittel/schlecht.
- teacher.html: iv-prow/iv-cols Grid flexibel (Name minmax(0,1fr) + Ellipsis, Datenspalten auto) -> "Nicht aktiv"-Liste laeuft nicht mehr aus der Karte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:29:39 +02:00
Adminator 0c4fb84606 Lehrer-Live: Avatar-Slug (avatar:xxx) wird als Bild gerendert statt als Rohtext (Reflexionen + Session-Detail)
Gemeinsamer _avatarHtml-Helper: erkennt Slug in avatar_slug ODER als "avatar:<slug>" im emoji-Feld.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:21:32 +02:00
Adminator 2160a99896 Lehrer-Live: laufende Klima-Balken farbig (Ampel) + "Heute bereits erledigt" zeigt alle heutigen Durchgaenge
- klima game-2d/3d: GGS_LIVE_STATE liefert laufenden Punktestand (score, gleiche Formel wie Endbewertung)
- live.php summary: laufende Sessions jeder Sim bekommen Ampel-Score (nicht mehr nur Farmer) -> Tag-Ansicht faerbt laufende Balken statt grau
- teacher.html _renderInactiveToday: zeigt jetzt ALLE heute abgeschlossenen Laeufe (auch von gerade aktiven Schueler:innen); nur das laufende Spiel bleibt oben. Titel -> "Heute bereits erledigt"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:19:23 +02:00
Adminator 8cc48b1bc6 Klima: Aktuell-Linien gruen->rot (CO2/Temp/Geflutet/Budget/Bev.) + "Nochmal spielen" setzt wirklich zurueck
- teacher.html: LIVE_PRIMARY_FIELDS.klima Kennzahlen mit _liveBar-Ampel (absolute Skalen), _liveBar +Nachkomma-Param; tick-Spalte entfernt (redundant zu Jahr)
- game-2d/3d: beendete Laeufe (phase=end) werden beim Resume verworfen -> "Nochmal spielen"/erneutes Oeffnen startet frisch statt im Endbildschirm

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:17:33 +02:00
Adminator eb67cbc79f Aktuell/Fortschritt: "Heute bereits aktiv" mit allen Aktivitaeten pro Name
Umbenannt von "Heute nicht mehr aktiv"; zeigt jetzt ALLE heutigen Aktivitaeten
pro Schueler:in (group by name, eine Zeile je Aktivitaet, Trennlinie zwischen
Personen) mit Uhrzeit + Leistung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 16:47:52 +02:00
Adminator 3c856ff5ce Aktuell + Fortschritt: zweite Tabelle "Heute nicht mehr aktiv" darunter
Unter den aktiven Schueler:innen eine eigene Tabelle (gleiche Form): wer HEUTE
schon gespielt hat, gerade aber offline ist -> Zuletzt aktiv (Uhrzeit),
Aktivitaet (Sim), Leistung (Score-Prozent, Ampelfarbe). Reusable
_renderInactiveToday(activeSet), gespeist aus dem summary-Endpoint (heute),
laufende/online ausgenommen. In renderLive und renderLiveCompare eingehaengt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 15:21:51 +02:00
Adminator 64a9a62157 Gesamt/Fleiss: laufende Sessions als blinkende Saeule im aktuellen Bucket
Statt separatem Top-Streifen (rueckgebaut): results.php view=fleiss spielt
laufende live_sessions in den aktuellen Bucket (Stunde/Wochentag/Tag/Monat) ein,
markiert ihn `live`. renderFleiss laesst diese Saeule blinken (roter Rahmen).
So sieht man Bens laufendes Spiel direkt bei "Fleiss pro Schueler*in".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 15:02:19 +02:00
Adminator ad8c6b7d7e Gesamt: "Jetzt live"-Streifen oben (laufende Spiele, blinkend)
In Ergebnisse/Gesamt fehlten laufende Sessions (Fleiss baut nur auf fertigen
Laeufen). Neuer blinkender Live-Streifen ganz oben aus live_sessions: Name +
Sim-Symbol + Laufzeit, Klick = zuschauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:55:32 +02:00
Adminator 71226581fa Tag-Ansicht: laufende Spiele als langsam blinkende Bloecke
Running-Balken (isLive/running) blinken jetzt sanft (_tagblink 1.8s), damit
laufende Spiele klar von fertigen zu unterscheiden sind. Breite = Spieldauer
(waechst mit der Zeit) war bereits so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:47:51 +02:00
Adminator 540b27a2bd Cockpit: "Heute in der Klasse"-Zeile ganz oben in der Klassenuebersicht
Immer sichtbare Live-Zeile ueber den Empfehlungen: wie viele gerade aktiv +
welche Sims, und wer JETZT Hilfe braucht (needsHelp) als klickbare Chips mit
Klartext-Grund (z.B. "Ben - Trefferquote 10 %"). Quelle: cockpit.students.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:30:07 +02:00
Adminator 5635fa26b4 Lehreransicht: Ampel Farmer=Trefferquote, Vergleich->Fortschritt+Qualitaet, Mini-Balken ueberall
- _liveMood: farmer-Fall (hit_rate >=60 gruen / >=30 gelb / sonst rot) -> Ampel
  spiegelt Qualitaet, nicht mehr nur Fortschritt.
- qualityScore: farmer hit_rate -> "Qualitaet"-Spalte im Fortschritt-View gefuellt.
- "Vergleich"-Button/Titel -> "Fortschritt (& Qualitaet)".
- "Aktuell"-Tabelle: Mini-Balken jetzt fuer ALLE Zahlenspalten aller Sims
  (relativ zum Spalten-Max, neutral), Spalten mit eigenem Balken ausgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:26:47 +02:00
Adminator 4e38b27c64 Tag-Ansicht: echte Spieldauer + Ampelfarbe nach Erfolg + Sim-Symbol
Bug: fertig gespielte Laeufe (Farmer-Endbericht = 1 Assessment) erschienen als
kurzer "Abbruch"-Balken (Dauer 0). Fix:
- live.php summary: nutzt duration_ms -> Balken decken die echte Spielzeit ab und
  fluchten am Start; kein Falsch-Abbruch mehr. Neues Feld `score` (0-100) je
  Session (Score/Sterne); laufende Farmer-Sessions bekommen hit_rate als Live-Score.
- teacher.html Tag-Balken: einheitliche Hoehe (Hoehe raus), Farbe = Ampel nach
  Erfolg (gruen->rot), App-Farbe ersetzt durch Sim-Symbol; Klick = Details.
  Legende angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:56:47 +02:00
Adminator 85ee58c56d Farmer: "Braucht Hilfe" auch bei schlechter Trefferquote (nicht nur cash<0)
Ben spielt bewusst schlecht (hit_rate ~10%), Konto aber wieder im Plus -> blinkte
nicht. needsHelp (teacher.html client + live.php server) jetzt: cash<0 ODER
(attempts>=6 UND hit_rate<30). Faengt dauerhaft falsche Boden/Klima-Wahl; Clara
(hit 75, 3 Krankheiten) bleibt bewusst ruhig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:41:39 +02:00
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (=
"bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex
/([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung
unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle
JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein
Logik-Key) -> safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:38:19 +02:00
Adminator 9c99359d9c Farmer-Fix: Klima-Fallback laengengrad-bewusst + "Vernalisation" ersetzt
- defaultZoneByLatitude: bei 32-38 Grad nicht mehr pauschal "mittelmeer" (das
  gab kontinentalem Innerasien wie Westchina faelschlich Mittelmeerklima ->
  "Mediterraner Reisanbau" in Xinjiang). Jetzt nur in echten Mittelmeer-
  Laengenfenstern (Mittelmeerbecken/Kalifornien/Chile/Kap/SW-Australien),
  sonst "wueste". lon wird jetzt an die Funktion uebergeben.
- crops.json: Weizen-Fachbegriff "Vernalisation" durch "kuehle Winter, die der
  Winterweizen fuer die spaetere Kornbildung braucht" ersetzt (Thomas: gehoert
  nicht ins Lehrwerk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:25:57 +02:00
Adminator b32097b617 Farmer-Fix: Begruendungstext passt jetzt zur echten Klimazone (nicht mehr zufaellig)
Bug: Kamel in tropischem Brasilien zeigte "Polargebiet/Permafrost", weil der
generische N2-Pool tropisch+polar mischt und pickFromPool zufaellig zog. Ebenso
"Kamele in Nordeuropa" auf einem Feld in Suedamerika (fester Regionsname).
- engine.js: pickZoneAware() waehlt per Klima-Stichwoertern (ZONE_KEYWORDS) den
  zur Zone passenden Grund-Text statt zufaellig; Fallback = alt.
- crops.json: Kamel-N1-Text regions-neutral ("viel zu feucht" statt "Nordeuropa").
S1-Erfolgstexte mit Regions-Beispielen (Reis/Kaffee/Datteln) bewusst belassen
(Vergleich auf passendem Feld, didaktisch ok).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:19:32 +02:00
Adminator 257b5e5099 Lehreransicht "Aktuell": Mini-Balken unter den Zahlen (Farmer-Spalten)
Kleine Balkengrafik unter jedem Zahlenwert: bounded Metriken (Jahr/Trefferquote/
Sorten absolut zur Skala) + offene Werte (Konto/Laender/Felder/Versuche/CO2)
relativ zum groessten Wert der Spalte ueber alle Schueler:innen -> auf einen
Blick "wer liegt vorn". Konto signiert (rot bei minus), CO2/Krankheiten
invers (rot = mehr). Helfer _liveMiniBar/_liveBarColor, colMax pro Spalte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:03:29 +02:00
Adminator 532b5471ec Lehreransicht: Farmer-Spalten in "Aktuell" uebersetzt statt roher Keys
LIVE_PRIMARY_FIELDS um farmer-Eintrag ergaenzt: Jahr /10, Markt, Konto (rot bei
minus), Trefferquote (Balken), Sorten /10, Laender, Felder, Versuche, CO2 kg,
Krankheiten (rot bei >0). Redundante/interne Keys (phase, market-id, progressPct,
simId, timestamp) fallen weg. Vorher fiel die Tabelle auf rohe englische Keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:58:45 +02:00
Adminator ff751f3a05 Farmer: Onboarding-Schritt korrigiert - keine Geraete, Sorten werden freigeschaltet
"Ertraege verkaufen, in Geraete und Sorten investieren" -> "Ernten und
verkaufen - mit den Jahren werden neue Sorten freigeschaltet". Es gibt keine
Geraete-Mechanik, und Sorten werden automatisch (nach 3 Jahren) freigeschaltet,
nicht gekauft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:53:56 +02:00
Adminator 714abc026a Farmer: Intro-Satz korrigiert - "Hoefe auf der ganzen Welt"
"Du bewirtschaftest einen Hof" -> "In dieser Simulation bewirtschaftest du
Hoefe auf der ganzen Welt" (die Sim spielt weltweit, nicht auf einem Hof).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:52:38 +02:00
Adminator 8c316f22e8 Weltkueche: Marillenknoedel auf reinen Topfenteig (Erdaepfel raus) + Panel final
- wachauer-marillenknoedel Rezept-Schritt 1: Erdaepfel entfernt, klassischer
  Topfenteig (Topfen/Ei/Butter/Mehl/Griess). Passt jetzt zu den ings.
- Admin-Maengelliste: komplett abgearbeitet markiert; mediterrane Kraeuter
  bleiben (Thomas-Entscheidung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:49:33 +02:00
Adminator 8ffc43d7b2 Weltkueche: Maengelliste abgearbeitet (14 Zutaten-Fixes) + Guanciale neu
Zutaten, die im Rezept nicht vorkamen/falsch waren, korrigiert:
- raus: Apfelstrudel(zitrone,essig), Sauerbraten(mehl), Pierogi(milch),
  Gulasch(tomaten), Guacamole(olivenoel), Groestl(kuemmel)
- ersetzt: Kirschtorte/Zuercher/Quiche milch->sahne, Pizza hartweizen->mehl
  (Weichweizen; hartweizen bleibt bei Carbonara=korrekt Durum), Gulasch
  butter->oel, Curry+PadThai olivenoel->oel, Carbonara speck->GUANCIALE (neu).
- guanciale (NEU, production-matrix): feste Herkunft Italien + Distraktoren.
Admin-Maengelliste als erledigt markiert; offen bleiben mediterrane Kraeuter
+ Marillenknoedel-Teig (Thomas-Entscheidung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 01:17:01 +02:00
Adminator 9bc3ca7b26 Weltkueche-Admin: Maengelliste Zutaten vs. Rezept (manuelle Pruefung aller 30 Gerichte)
Panel oben im QA-Tool: Zutaten, die im Rezepttext nicht vorkommen oder falsch
sind (z.B. Sauerbraten/mehl, Kirschtorte/milch, Pizza/hartweizen, Carbonara/speck
statt Guanciale, Gulasch/tomaten+butter, Curry+PadThai+Guacamole/olivenoel).
Plus mediterrane-Kraeuter-Kandidaten und kleinere Rezept-Themen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:41:26 +02:00
Adminator 13f14c26a8 Weltkueche: 6 Zutaten mit fest verdrahteter Herkunft (Thomas-Vorgaben)
Kein Automatismus mehr - Zutat hat feste Laenderliste (correct) + feste
Distraktoren, die das Spiel 1:1 ausliest. Vorrang vor Consumer/Heimat.
- greyerzer (NEU, "Greyerzer & Vacherin"): Schweiz+Frankreich -> ersetzt den
  mozzarella-Hack im Fondue (Signatur entfernt, Rezept nannte Gruyere+Vacherin
  schon korrekt).
- hefe: Oesterreich+Frankreich | lamm: Neuseeland+UK (Import, nicht Griechenland)
  tomaten: Niederlande+Spanien | schmand: Oesterreich+Deutschland
  fischsauce: Thailand+Vietnam (nicht Italien).
game.html: schlanker Auslese-Zweig fuer `correct`/`distractors` (isWorldIngredient
+ ING-Build + Aufloesungstext). admin spiegelt als "Feste Herkunft".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:35:24 +02:00
Adminator d7dd3f9de5 Weltkueche: Cache-Busting (no-store) fuer die Daten-Fetches im Spiel
Damit das Spiel nach einem Deploy nie eine gecachte dishes/production-matrix
zeigt (gleiche Klasse Fix wie im Admin-Tool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:55:33 +02:00
Adminator 8977901e89 Weltkueche: Consumer-Regel fuer ALLE DACH-Grundzutaten + Alkohol raus + Fixes
HEIMAT-REGEL-FIX (Thomas): Grundzutaten haengen nicht mehr am Gericht-Land,
sondern am User-Land, wenn dort nennenswerte Produktion. Quiche-Milch in AT =
oesterreichische Milch, nicht franzoesische.
- heimisch+market (AT/DE/CH, market DE) an: milch, butter, topfen, fleisch,
  rind-ungarn, apfel, zucker, zwiebel, champignons, wurzelgemuese, mais,
  kirschen, mehl (+ eier/kartoffel schon). = 16 Grundzutaten consumer-anchored.
- huhn-spanien (Paella): heimisch AT/DE/CH, market DE, Distraktoren
  spanien/frankreich/marokko/aegypten/tuerkei (Spanien = Gericht-Land als Lockvogel).
- BEWUSST NICHT heimisch: tomaten + lamm (Import >2/3) -> bleiben international.
  Signaturzutaten (speck/feta/mozzarella/bergkaese) bleiben am Gericht.
- Zurueckgehalten fuer Thomas-Review: kuemmel/majoran (Kraeuter-Graubereich).

ALKOHOL RAUS (Thomas: "ist fuer Kinder", alle Gerichte):
- Apfelstrudel: Rum-Ing raus. Sauerbraten: Wein-Ing raus, Marinade Rotwein->
  roter Traubensaft. Kirschtorte: Kirschwasser->Kirschsaft. Fondue: Wein-Ing raus,
  Weisswein->Apfelsaft+Zitrone, Kirschwasser->Wasser, Schnaps-Gag->Lied singen.
  Zuercher Geschnetzeltes: Wein-Ing raus, ablöschen mit Gemuesebruehe. Moussaka:
  Wein-Ing raus, einkochen mit Gemuesebruehe.
- Bayerische Brezel: Mozzarella raus (Obatzda = Camembert+Frischkaese).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:54:43 +02:00
Adminator f934a7f9e4 Weltkueche: mediterrane Kraeuter raus aus Maultaschen + Sauerbraten
Kommen im Rezept nicht vor und passen historisch nicht in die regionale
DACH-Kueche (Thomas). Mediterrane Gerichte (Pizza/Ratatouille/Moussaka/Paella)
behalten Kraeuter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:41:34 +02:00
Adminator 0c2225594b Weltkueche: Semmelbroesel zurueck in Speisekammer + Brezel-Rezept bereinigt
- semmelbroesel: heimisch/market/distractors wieder entfernt -> nicht mehr
  abgefragt, kommt aus der Speisekammer (Thomas: verarbeitete Staples = Pantry).
- bayerische-brezel: Wein + mediterrane Kraeuter aus ings raus (kommen im
  Rezept nicht vor). Bier im Obatzda-Schritt durch einen Schuss Milch ersetzt
  (fuer Kinder ungeeignet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:38:38 +02:00
Adminator 7f997bff84 Weltkueche-Admin: Cache-Busting der Daten-Fetches (?v=ts + no-store)
Verhindert, dass die QA-Uebersicht eine gecachte production-matrix.json/dishes.json
zeigt. Loest "Admin zeigt noch alten Stand nach Deploy".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 18:35:52 +02:00
Adminator 423490260d Weltkueche Teil B: Consumer-Anker (Herkunft folgt dem User-Land) — Pilot Ei/Kartoffel/Semmelbroesel
Grundzutaten, die im Land des Users heimisch sind, kommen jetzt aus dem Land
des USERS statt aus dem Gericht-Land (Carbonara-Ei in AT = AT-Ei). Opt-in pro
Zutat ueber neues Feld `heimisch`; Signaturzutaten bleiben am Gericht.

game.html:
- buildTaskFor: neuer Consumer-Zweig (vor Heimat-Regel). correct = [User-Land,
  ...market], Distraktoren explizit (`distractors`) oder berechnet. Immer 5 Karten.
- _wkUser(): WK_COUNTRY (AT/DE/CH/LI) -> Laender-Slug, Fallback AT.
- isWorldIngredient: Zutat mit `heimisch` wird immer abgefragt (z.B. Semmelbroesel).
- ING-Build: heimisch/market/distractors werden aus der Matrix mitkopiert
  (wurden vorher weggelassen -> Felder kamen nie im Spiel an).
- Aufloesungstext: Consumer-Fall ("kaufst du, wo du einkaufst - im eigenen Land").

Daten (production-matrix.json), Thomas-Vorgaben:
- eier:  heimisch AT/DE/CH, market DE+NL  -> AT-User: AT+DE+NL, DE-User: DE+NL
- kartoffel: heimisch AT/DE/CH, market DE -> AT+DE (alle Gerichte)
- semmelbroesel: heimisch AT/DE, market DE, distractors GR/TR/EG/TN/IE; jetzt abgefragt

admin-weltkueche.html: Consumer-Zweig gespiegelt + AT/DE/CH-Umschalter.

Nebenbefund (NICHT angefasst): correct_for wird im Spiel nie gelesen (nur Admin
las es) -> mehl/majoran/safran/feta-lamm-Kuratierung wirkte bisher nur im Admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 18:21:39 +02:00
Adminator b6b6c6ccce Weltkueche: Ei-Zweitherkunft AT = Deutschland+Niederlande (statt Slowakei)
- eier.correct_for.oesterreich: ["deutschland","niederlande"] (war ["slowakei"])
  -> AT-Ei-Gerichte zeigen jetzt AT + DE + NL (Thomas-Vorgabe: NL/DE sind die
  echten Import-Herkuenfte, SK spielt kaum Rolle). Gilt fuer alle 7 AT-Gerichte
  mit Ei; DE-Gerichte zeigen via Heuristik ohnehin schon DE + NL.
- buildTaskFor Heimat-Zweig: Distraktoren auf 5-correct.length gedeckelt, damit
  bei 3 richtigen Laendern trotzdem 5 Karten bleiben (Punkte-Norm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 18:08:36 +02:00
Adminator a68c58ac9e Weltkueche Teil A: Land pro Lehrer persistent (teachers.country) + Sim-Verdrahtung
- profil.html: Länderauswahl (AT/DE/CH/LI) im Lehrer-Profil, lädt/zeigt aktuelles Land.
- profile.php: speichert country in teachers.country; gibt country zurück.
- weltkueche.php: injiziert window.WK_COUNTRY = Country::current() (Kaskade Schüler→Klasse→Lehrer→AT) — Fundament für die konsumentenland-basierte Herkunft (Teil B).
Spalte teachers.country existierte bereits, keine Migration nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 12:13:27 +02:00
Adminator c3e61f4da7 Weltkueche: Auflösungs-Aussage korrigiert + Admin-QA kennt correct_for
- Auflösung: „Das ist ein Gericht aus <Land> → Zutaten aus <Land>" nur noch bei
  ECHTEN Signatur-Zutaten (Tiroler Speck etc.). Grundzutaten (Butter, Mehl) bekommen
  die neutrale „aus der Nähe"-Begründung statt der falschen Gericht-Herkunft-Logik.
- admin-weltkueche.html: classify() respektiert jetzt correct_for (zeigt sonst falsche
  Herkünfte, z.B. Mehl AT+DE statt AT+Ungarn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 11:50:07 +02:00
Adminator b353f25b07 Weltkueche Punktesystem: neue Formel (25 x verbleibende Falsche) + immer 5 Karten
- Auswahl auf IMMER 2 richtig + 3 Distraktoren (5 Karten) genormt (Welt-Zweig
  wuerfelte 2-4). Damit ist die Bewertung fair & vergleichbar.
- Punkte pro richtigem Klick = 25 x (noch verbleibende falsche Optionen).
  Frueh gefunden = mehr; jeder Fehlklick vorher senkt den Wert. Max 2x75 = 150,
  0 moeglich (alle falsch zuerst). 50er-Bonus entfaellt (150 = 75+75).
- Live-Feedback: schwebendes +75/+50/+25/+0 (gruen) bzw. "Fehlversuch -
  erreichbare Punkte sinken" (rot) bei jedem Klick.
- Banner/Interim/Onboarding-Text angepasst; trade-Abzeichen 3000 -> 2000
  (da Max jetzt 150 statt ~250 pro Zutat).
- BUGFIX: dasselbe richtige Land liess sich doppelt anklicken/zaehlen -> Guard.
Playwright-verifiziert: 5 Karten, 75+0(Guard)+75=150, 0 JS-Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 11:25:49 +02:00
Adminator 5fbbf506b5 Schulbuch: 26 Fotos aus Prod-Kollegen-Uploads + lokalen Zuordnungen eingebaut
Prod (Kollegen, echte VBG-Orte, je Slot bestes gewaehlt): kofferdetektiv komplett
(Gepaeckband Stuttgart, Koffer-Indizien, Anzeigetafel, Nachtzug, Kondensstreifen,
Hafen-Bregenz), busfahrt (Reisebus, Flixbus, Grenzschild, Pfaender-Dreilaenderblick),
energiemanager (Luenersee-Stausee, Pumpspeicher, Smartmeter, Strommast), klima
(Gletscher, Gewaechshaus), sonnensystem (Sonnenaufgang), eu-werkstatt (EU-Flagge),
farmer (Alp-Kuehe), tourismusregion (Wegweiser, Parkplatz Golm), weltkueche
(Fairtrade, Fruehstueckstisch). Colleague-Fotos ersetzen meine Trip-Platzhalter bei
fairtrade-siegel + opener-parkplatz-voll. Lokal: souvenirs, marktstand, radweg.
Alle: auto-orient, EXIF/GPS gestrippt, 1600px, WebP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 23:22:31 +02:00
Adminator d4c7fd5009 Schulbuch: 9 neue Fotos (EXIF/GPS gestrippt, WebP) in leere Buch-Slots
- weltkueche: sennerei-kaese, fairtrade-siegel, supermarkt-herkunftsschilder
- tourismustal: sommerloch-leere-terrasse (Skiort im Sommer)
- tourismusregion: opener-parkplatz-voll, standort-jausenstation (NEU Ordner)
- logistik: alpen-transit (Container-LKW)
- energiemanager: strommix-wasserkraft (Wasserkraft-Turbine)
- farmer: opener-feld-berg (Maisfeld)
Aus .humanInput/fotosNeu2; auto-orientiert, 1600px, q82.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 22:55:04 +02:00
Adminator bf2a4cd0d1 Weltkueche Herkunft: kuratierte AT-Zweitlieferanten + Distraktor-Fixes (Thomas-Review)
Neu: additives `correct_for[heimat]` in buildTaskFor — uebersteuert die Gravitations-
Schaetzung NUR bei kuratierten Zutaten (echte Handelsdaten), alles andere unveraendert.
- mehl→AT+Ungarn (Weizenimport 2023: HU 604k >> DE 41k), kartoffel→AT+Schweiz,
  eier→AT+Slowakei (Variation weg vom Dauer-AT+DE), majoran→AT+Deutschland (statt FR),
  safran→Spanien+Iran (statt IT mit 1 t), feta/lamm→Griechenland+Bulgarien (Balkan statt FR).
- Datenfixes gegen „echter Lieferant faelschlich als Distraktor": AT raus aus Mozzarella-
  climate_possible + als Produzent ergaenzt; CH raus aus Kartoffel-climate_possible +
  Produzent; SK als Eier-Produzent; Aegypten-Zwiebel local→global (echter kleiner Lieferant).
- Verifiziert: 7 kuratierte korrekt; keine Regression (DE-Gerichte nutzen weiter Algorithmus).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 21:36:48 +02:00
Adminator 4c0183f2e0 Weltkueche Zutaten-Review R2: Apfelstrudel/Sachertorte/Marillenknoedel/Schnitzel exakt nach Rezept
- Apfelstrudel: exakte Liste (Apfel, Zitrone, Butter, Mehl, Oel, Essig, Semmelbroesel,
  Zucker, Zimt, Rosinen, Rum, Staubzucker). Eier + Mandeln + Olivenoel raus.
- Sachertorte: Schokoladenkuvertuere statt Kakao, Kristall- + Staubzucker, Schlagobers
  (mit Hinweis: kommt DANEBEN, nicht in den Teig) statt Milch.
- Marillenknoedel: Topfenteig, Kartoffel raus.
- Wiener Schnitzel: Kalbfleisch, Mehl, Ei, Semmelbroesel, Butterschmalz (+ Zitrone,
  Erdaepfelsalat-Beilage). Pfeffer + Butter raus.
- 8 neue Zutaten: 3 Welt (Schokolade=Kakao-Herkunft, Kristallzucker=EU-Ruebe, Rum=Karibik),
  5 lokal (Semmelbroesel, Oel, Staubzucker, Butterschmalz, Schlagobers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 21:23:56 +02:00
Adminator 0c4064bb58 Weltkueche Zutaten-Review (Thomas): echte kulinarische Fehler bei AT-Gerichten behoben
- Mediterrane Kraeuter RAUS bei Groestl, Kasnudeln, Wurzelfleisch, Kaesespaetzle, Schnitzel (unpassend).
- Olivenoel RAUS bei Wurzelfleisch + Apfelstrudel (untypisch).
- Mozzarella RAUS bei Kaesespaetzle -> echter neuer `bergkaese` (Vorarlberger Bergkaese; Herkunft AT + Schweiz statt faelschlich Italien).
- Hartweizen/Durum RAUS bei Marillenknoedel.
- Wiener Schnitzel Fleisch -> neuer `kalbfleisch` (ausschliesslich Kalb; Herkunft AT + Niederlande = EU-Nr.1 Kalbfleisch).
- Kasnudeln: neuer `minze` (Nudelminze, charakteristisch; Herkunft AT + Deutschland).
- 3 neue Zutaten in production-matrix mit realistischen Produzenten/Markt; Gravitation liefert plausible 2. Lieferanten (verifiziert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 21:08:15 +02:00
Adminator 5d3cd1bdcf Fix: BASE_URL folgt dem echten Host (Glossar-Fetch auf v3) + Weltkueche „Nachbarland"-Wortlaut
- app.php: BASE_URL fuer *.geograsim.at war hart 'https://geograsim.at' -> auf
  v3.geograsim.at lud die Glossar-API cross-origin -> CORS/NetworkError
  ("Glossar konnte nicht geladen werden"). Jetzt BASE_URL='https://'.$host
  (same-origin auf jeder Umgebung; prod-neutral, geograsim.at bleibt gleich).
- weltkueche/game.html: Zweit-Lieferant hiess pauschal „Nachbarland" — Frankreich
  ist aber kein Nachbar Oesterreichs. Jetzt „europaeisches Land in unserer Naehe".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 16:30:59 +02:00
Adminator 5b0505b140 Sonnensystem-Review: Finsternis-Geometrie-Fix + Vergleichsansichten + Anleitung + Photon-Skala
Aufgaben (observations.json):
- Finsternis-Bug behoben: Mond wurde siderisch statt synodisch positioniert -> Konstellation schief. Jetzt exakt Neumond (solar)/Vollmond (lunar) via moonPhaseTimeDays(). Sim-Detektor bestaetigt (Badge) SONNEN-/MONDFINSTERNIS.
- Pol-Aufgaben (Polartag/Polarnacht): Kamera -> neue erdzentrierte Draufsicht earth-top (Sonne geht am Pol nie unter/auf sichtbar).
- Mondphasen: Start bei Neumond (moonPhaseStart) passend zur Reihenfolge Neumond->zunehmend->Vollmond->abnehmend.
- Vergleichsansichten (Strahlen-Steilheit, Tag-Nacht-Gleiche, ohne Achsneigung): neue nahe Seitenansicht earth-side + Sommer/Winter- bzw. Maerz/Sept-Phasen + klare watchHints.
- "Was bewegt sich worum": Antwort praezisiert (Mond um die Erde).
- Apfel-Vergleich: 109 x (Leerzeichen), Erde = Mohnkorn (~1 mm), lange Striche raus, Distraktor Murmel angepasst.
- Photon: schwankende 8:20 erwaehnt, watchHint "mind. 30 s zaehlen + hochrechnen", Speed watchbarer.

game.html:
- Graue/unsichtbare Fact-/Onboarding-Texte: dunkle Lesefarbe gesetzt.
- Kameras earth-top + earth-side; obsApplyState-Fallback fuer button-lose Kameras.
- moonPhaseTimeDays(new|full) (synodisch, Anker Vollmond 3.1.).
- Anleitung-Button (Intro+Aufloesung) + Overlay: erklaert das Bedienfeld Schritt fuer Schritt.
- Photon-Skala unten: 8 Segmente (1/min), fuellt mit Fortschritt; Photon pulsiert die ganze Strecke.

Getestet mit Playwright: 0 JS-Fehler, Finsternis-Badges korrekt, Photon-Skala fuellt, alle DOM-Elemente da.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 12:04:53 +02:00
Adminator c950f954d1 Admin-ToDo/Review-Seite + Staging-Self-Service-Admin-Login
- admin-todo.html: mobile-freundliche ToDo/Review-Liste (Prioritaeten, Haekchen +
  Notizfelder je Punkt, localStorage, Notizen-Export), admin-gated, noindex. In
  admin-licenses.html Nav + Backend-Werkzeuge verlinkt.
- admin.php: devPin auch bei STAGING=1 (v3, Mail bewusst tot) -> Self-Service-
  Admin-Login ohne Mailversand. Prod unveraendert (STAGING dort nie definiert,
  2FA-Mail-Pflicht bleibt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 11:31:02 +02:00
Adminator a568f81b0a vendor-Fix: PHPMailer (v7.0.2, hand-vendored) im Autoloader wiederhergestellt
Beim Hinzufuegen von firebase/php-jwt + predis war PHPMailer aus composer.json
und dem Autoloader gefallen (Dateien fehlten ganz). Folge: `new PHPMailer` fatalt
-> 500 bei register/reset/2FA/Schueler-Zugang. Auf v3 aufgedeckt und behoben:
composer.json require + installed.json + Autoloader (App/vendor) mit PHPMailer,
firebase, predis. Verifiziert: alle 3 Namespaces laden. KRITISCH fuer Prod-Deploy
(Manifest spiegelt vendor/), sonst waere dort der Mailversand gebrochen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 00:29:03 +02:00
Adminator 182a87978e Auth-Umschalter: Session.php verzweigt auf AUTH_BACKEND (jwt = JWT-Access-Cookie + widerrufbare Redis-Session)
- teacher/admin Login/Read/Logout branchen auf AUTH_BACKEND; session-Pfad unveraendert (Live-Default, prod-sicher).
- jwt: loginTeacher/loginAdmin legen Redis-Session (SessionStore) an + setzen kurzlebiges JWT-Cookie (ggs_t/ggs_a) + langes Sid-Cookie (ggs_ts/ggs_as).
- Read: Fast-Path via signiertem JWT (zustandslos), Refresh-Path via Sid->Redis + frisches JWT; Widerruf = Redis DEL bei Logout.
- Teacher/Admin getrennte Cookies (koexistieren), typ-Check gegen Rollenverwechslung. Lazy-require von Jwt/SessionStore (jeder Einstiegspfad).
- Schueler unveraendert (UUID-Cookie + DB, schon pod-unabhaengig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 00:15:22 +02:00
295 changed files with 19105 additions and 1115 deletions
+34
View File
@@ -0,0 +1,34 @@
const { chromium } = require('@playwright/test');
(async () => {
const b = await chromium.launch(); const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.route('**/api/admin', r => r.fulfill({ contentType:'application/json', body: JSON.stringify({authenticated:true}) }));
await p.goto('http://localhost/geograsim/App/admin-weltkueche.html');
await p.waitForTimeout(2000);
const r = await p.evaluate(() => {
const dishes = [...document.querySelectorAll('.dish')];
const names = dishes.map(d => d.querySelector('.nm')?.textContent);
function dishInfo(namePart){
const d = dishes.find(x => (x.querySelector('.nm')?.textContent||'').toLowerCase().includes(namePart));
if(!d) return null;
const rows = [...d.querySelectorAll('tbody tr')].map(tr => ({
ing: tr.querySelector('.ing-nm')?.textContent.replace(/\s+/g,' ').trim(),
ok: [...tr.querySelectorAll('.cty.ok')].map(c=>c.textContent.replace(/\s+/g,' ').trim()),
dist: [...tr.querySelectorAll('.cty.dist')].map(c=>c.title.split(' · ')[0]),
}));
return rows;
}
const groestl = dishInfo('tiroler gröstl');
const kart = groestl && groestl.find(r => /Kartoffel/i.test(r.ing));
const eierRow = groestl && groestl.find(r => /Eier/i.test(r.ing));
return {
dishCount: dishes.length,
sampleNames: names.slice(0,6),
kartoffelOk: kart ? kart.ok : null,
eierOk: eierRow ? eierRow.ok : null,
warnCount: document.querySelectorAll('.warn').length,
};
});
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
await b.close();
})();
+19
View File
@@ -0,0 +1,19 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const r = await page.request.get('https://geograsim.at/teacher.html');
const html = await r.text();
console.log('STATUS', r.status(), 'LEN', html.length);
console.log('has renderLiveGesamt:', html.includes('renderLiveGesamt'));
console.log('has _liveQualityBar:', html.includes('_liveQualityBar'));
console.log('has tab-bar:', html.includes('tab-bar'));
console.log('has Ergebnisse:', html.includes('Ergebnisse'));
console.log('has external script src (module?):', /<script[^>]+src=/i.test(html));
// show script tags
const scripts = [...html.matchAll(/<script[^>]*>/gi)].map(m=>m[0]);
console.log('SCRIPT TAGS:', JSON.stringify(scripts, null, 2));
// show first 600 chars to see redirect logic
console.log('HEAD:', html.slice(0, 1200));
await browser.close();
})();
+22
View File
@@ -0,0 +1,22 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const r = await page.request.get('https://geograsim.at/teacher.html');
const html = await r.text();
const idxLogin = html.indexOf('login.html');
console.log('--- context around first login.html reference ---');
console.log(html.slice(idxLogin - 400, idxLogin + 120));
for (const name of ['renderLiveGesamt', '_liveQualityBar']) {
const idx = html.indexOf('function ' + name);
console.log('\n--- "function ' + name + '" @' + idx + ' ---');
if (idx >= 0) console.log(html.slice(idx - 60, idx + 120));
}
const idxErg = html.indexOf('Ergebnisse');
console.log('\n--- context around Ergebnisse ---');
console.log(html.slice(idxErg - 220, idxErg + 80));
await browser.close();
})();
+29
View File
@@ -0,0 +1,29 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const r = await page.request.get('https://geograsim.at/teacher.html');
const html = await r.text();
// 1) Static tab-bar markup
const tb = html.indexOf('id="tab-bar"');
console.log('--- #tab-bar markup ---');
console.log(tb >= 0 ? html.slice(tb - 40, tb + 700) : 'tab-bar id NOT found');
// 2) Extract _liveQualityBar function body and run it in isolation
const start = html.indexOf('function _liveQualityBar');
// find matching braces
let i = html.indexOf('{', start), depth = 0, end = -1;
for (; i < html.length; i++) {
if (html[i] === '{') depth++;
else if (html[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
}
const fnSrc = html.slice(start, end);
const fn = new Function(fnSrc + '\nreturn _liveQualityBar;')();
const sample = fn(8, 2);
console.log('\n--- _liveQualityBar(8,2) OUTPUT ---');
console.log(sample);
console.log('\ncontains "80":', String(sample).includes('80'));
await browser.close();
})();
@@ -0,0 +1,294 @@
/**
* gen-display-preview.js
* ----------------------
* Lokales Admin-Werkzeug: erstellt Bildschirmfotos aller Simulationen
* (Onboarding/Start + Spielverlauf) in zwei Auflösungen (iPad + Full HD)
* und generiert daraus eine statische Galerie-Seite.
*
* Ausgabe: App/.LocalDeveloperTools/display-preview/
* shots/<id>__<vp>__<screen>.png
* display_preview.html
*
* Start: node gen-display-preview.js
*/
const { chromium } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
// --- Pfade ---------------------------------------------------------------
const APP_DIR = 'C:/xampp/htdocs/geograsim/App';
const SIMS_DIR = path.join(APP_DIR, 'sims');
const OUT_DIR = path.join(APP_DIR, '.LocalDeveloperTools', 'display-preview');
const SHOTS_DIR = path.join(OUT_DIR, 'shots');
const BASE_URL = 'http://localhost/geograsim/App/sims';
// --- Viewports -----------------------------------------------------------
const VIEWPORTS = [
{ key: 'ipad', label: 'iPad', w: 1180, h: 820 },
{ key: 'fullhd', label: 'Full HD', w: 1920, h: 1080 },
];
// --- Freundliche Namen ---------------------------------------------------
const NAMES = {
weltkueche: '🍲 Welt-Küche',
fluss: '🌊 Flussmanagement',
heli: '🚁 Helikopter-Navigation',
energiemanager: '⚡ Energiemanager',
'eu-werkstatt': '🇪🇺 EU-Werkstatt',
farmer: '🌾 Farmer',
busfahrt: '🚌 Busfahrt',
sonnensystem: '🪐 Sonnensystem',
tourismustal: '🏔️ Tourismustal',
tourismusregion: '🗺️ Tourismusregion',
logistik: '🚚 Logistik',
fluggesellschaft:'✈️ Fluggesellschaft',
staustufen: '💧 Staustufen',
entscheidungstag:'📅 Entscheidungstag',
vulkan: '🌋 Vulkan',
wal: '🐋 Wal',
kofferdetektiv: '🧳 Kofferdetektiv',
stadt: '🏙️ Stadt',
};
// --- Sims finden ---------------------------------------------------------
function findSims() {
return fs.readdirSync(SIMS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory() && fs.existsSync(path.join(SIMS_DIR, d.name, 'game.html')))
.map(d => d.name)
.sort();
}
// --- In-Page: sichtbaren Start-/Weiter-Button klicken --------------------
async function clickStartLike(page) {
return await page.evaluate(() => {
const isVisible = (el) => {
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return false;
if (r.bottom < 0 || r.right < 0) return false;
const st = getComputedStyle(el);
return st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0'
&& !el.disabled && !el.classList.contains('hidden');
};
// 1) Prioritäts-Selektoren (bekannte Start-/Primary-Buttons der Plattform)
const prio = [
'.ggs-qi-btn-primary', '[data-action="start"]',
'#btnStart', '#obs-start', '#phase-start',
'.sn-btn-primary', '.ggs-btn-primary',
'.era-card-cta', '.scene-btn',
'.map-btn', '.btn.primary', '.primary', '.next',
];
for (const sel of prio) {
const el = [...document.querySelectorAll(sel)].find(isVisible);
if (el) { el.click(); return sel + ' :: ' + (el.textContent || '').trim().slice(0, 24); }
}
// 2) Text-basiert — führende Emojis/Pfeile/Ziffern strippen, dann Anfang prüfen
const clean = (s) => (s || '').replace(/^[^\p{L}]+/u, '').trim();
const rx = /^(los geht|los!|los →|los\b|start|starten|jetzt starten|neue partie|partie starten|spielen|spiel starten|beginnen|anfangen|einsteigen|erntejahr starten|routenplanung starten|in dieser welt starten|szene|weiter|verstanden|fertig|karte wählen|auswählen)/i;
const cands = [...document.querySelectorAll('button, a.btn, a[role=button], .btn, [role=button], [onclick]')];
const el = cands.find(e => {
if (!isVisible(e)) return false;
const t = clean(e.textContent);
return t.length > 0 && t.length < 44 && rx.test(t);
});
if (el) { el.click(); return 'text :: ' + (el.textContent || '').trim().slice(0, 24); }
return null;
}).catch(() => null);
}
// --- Ein Screenshot ------------------------------------------------------
async function shoot(page, file) {
try {
await page.screenshot({ path: file }); // Viewport-Ausschnitt (exakte Auflösung)
return true;
} catch (e) {
console.log(' ! Screenshot-Fehler:', e.message.slice(0, 80));
return false;
}
}
(async () => {
const sims = findSims();
// Optional: nur bestimmte Sims neu schießen (HTML wird immer komplett neu erzeugt).
const onlyArg = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7);
const shootList = onlyArg ? onlyArg.split(',').map(s => s.trim()).filter(Boolean) : sims;
console.log('Gefundene Sims (' + sims.length + '):', sims.join(', '));
if (onlyArg) console.log('Neu geschossen werden nur:', shootList.join(', '));
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await chromium.launch();
// results[id][vp] = { start:bool, play:bool, play2:bool, errs:[] }
const results = {};
for (const vp of VIEWPORTS) {
console.log('\n===== Viewport ' + vp.label + ' (' + vp.w + '×' + vp.h + ') =====');
for (const id of shootList) {
results[id] = results[id] || {};
const r = { start: false, play: false, play2: false, errs: [] };
results[id][vp.key] = r;
const ctx = await browser.newContext({
viewport: { width: vp.w, height: vp.h },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
page.on('pageerror', e => { if (r.errs.length < 4) r.errs.push('PE:' + e.message.slice(0, 60)); });
page.on('console', m => { if (m.type() === 'error' && r.errs.length < 4) r.errs.push('CON:' + m.text().slice(0, 60)); });
const url = BASE_URL + '/' + id + '/game.html';
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
} catch (e) {
r.errs.push('GOTO:' + e.message.slice(0, 50));
}
// 1) Onboarding/Start
await page.waitForTimeout(2500);
r.start = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__start.png'));
// 2) Spielverlauf: mehrere Runden klicken (Onboarding schließen -> Menü -> Spiel -> Tutorial weg)
let clicks = 0, last = null;
for (let round = 0; round < 4; round++) {
const clicked = await clickStartLike(page);
if (!clicked) break;
clicks++;
if (clicked === last) break; // gleiches Element erneut -> Abbruch
last = clicked;
await page.waitForTimeout(800);
}
if (clicks > 0) {
await page.waitForTimeout(2500);
r.play = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__play.png'));
// 3) optionaler zweiter Play-Screen: noch ein Weiter/Start, falls vorhanden
const more = await clickStartLike(page);
if (more && more !== last) {
await page.waitForTimeout(2000);
r.play2 = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__play2.png'));
}
}
const flags = [r.start ? 'S' : '-', r.play ? 'P' : '-', r.play2 ? 'P2' : '--'].join(' ');
console.log(' ' + id.padEnd(18) + '[' + flags + ']'
+ (clicks ? ' clicks=' + clicks : ' (kein Start-Button)')
+ (r.errs.length ? ' ' + r.errs.slice(0, 2).join(' | ') : ''));
await ctx.close();
}
}
await browser.close();
// --- HTML generieren ---------------------------------------------------
const genDate = new Date().toLocaleString('de-AT', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
const screens = [
{ key: 'start', label: 'Onboarding / Start' },
{ key: 'play', label: 'Spielverlauf' },
{ key: 'play2', label: 'Spielverlauf (2)' },
];
function cell(id, vp, screenKey) {
const rel = 'shots/' + id + '__' + vp.key + '__' + screenKey + '.png';
const abs = path.join(SHOTS_DIR, id + '__' + vp.key + '__' + screenKey + '.png');
if (fs.existsSync(abs)) {
return '<a href="' + rel + '" target="_blank" class="shot">'
+ '<img loading="lazy" src="' + rel + '" alt="' + id + ' ' + vp.label + ' ' + screenKey + '">'
+ '<span class="cap">' + vp.label + ' · ' + vp.w + '×' + vp.h + '</span></a>';
}
return '<div class="missing">— kein Screenshot —<span class="cap">' + vp.label + '</span></div>';
}
let blocks = '';
for (const id of sims) {
const name = NAMES[id] || (id.charAt(0).toUpperCase() + id.slice(1));
// nur Screen-Zeilen zeigen, die mindestens ein Bild haben
const activeScreens = screens.filter(sc =>
VIEWPORTS.some(vp => fs.existsSync(path.join(SHOTS_DIR, id + '__' + vp.key + '__' + sc.key + '.png')))
);
const rows = (activeScreens.length ? activeScreens : [screens[0]]).map(sc =>
'<tr><th class="screen">' + sc.label + '</th>'
+ VIEWPORTS.map(vp => '<td>' + cell(id, vp, sc.key) + '</td>').join('')
+ '</tr>'
).join('\n');
blocks += '\n<section class="sim" id="sim-' + id + '">'
+ '<h2>' + name + ' <span class="simid">' + id + '</span></h2>'
+ '<table><thead><tr><th class="screen"></th>'
+ VIEWPORTS.map(vp => '<th>' + vp.label + ' <span class="res">' + vp.w + '×' + vp.h + '</span></th>').join('')
+ '</tr></thead><tbody>' + rows + '</tbody></table></section>';
}
const nav = sims.map(id =>
'<a href="#sim-' + id + '">' + (NAMES[id] || id) + '</a>'
).join('');
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Display-Vorschau (lokal)</title>
<style>
:root { --bg:#f4f2ec; --card:#fff; --ink:#1f2b26; --accent:#1f4b37; --muted:#6b7a72; --line:rgba(0,0,0,.08); }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
header { background:var(--accent); color:#fff; padding:20px 28px; position:sticky; top:0; z-index:10; box-shadow:0 2px 10px rgba(0,0,0,.15); }
header h1 { margin:0; font-size:1.4rem; }
header .meta { font-size:.82rem; opacity:.85; margin-top:4px; }
nav.toc { display:flex; flex-wrap:wrap; gap:6px; padding:14px 28px; background:#e9e6dd; border-bottom:1px solid var(--line); }
nav.toc a { font-size:.82rem; text-decoration:none; color:var(--accent); background:#fff; border:1px solid var(--line); border-radius:999px; padding:4px 11px; }
nav.toc a:hover { background:var(--accent); color:#fff; }
main { padding:24px 28px 60px; max-width:1500px; margin:0 auto; }
section.sim { background:var(--card); border-radius:14px; padding:18px 20px 22px; margin-bottom:26px; box-shadow:0 4px 14px rgba(0,0,0,.06); scroll-margin-top:90px; }
section.sim h2 { margin:0 0 14px; font-size:1.25rem; color:var(--accent); display:flex; align-items:baseline; gap:10px; }
.simid { font-size:.72rem; font-weight:600; color:var(--muted); background:#eef0ec; padding:2px 8px; border-radius:6px; letter-spacing:.03em; }
table { width:100%; border-collapse:collapse; table-layout:fixed; }
thead th { text-align:left; font-size:.82rem; color:var(--muted); padding:0 0 8px; font-weight:700; }
thead th .res { font-weight:500; opacity:.7; }
th.screen { width:150px; vertical-align:top; font-size:.9rem; color:var(--ink); padding-top:8px; }
td { padding:6px 6px 14px; vertical-align:top; width:calc((100% - 150px)/2); }
a.shot { display:block; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#0d1512; text-decoration:none; }
a.shot img { display:block; width:100%; max-width:100%; height:auto; }
.cap { display:block; font-size:.72rem; color:var(--muted); padding:5px 8px; background:#fafafa; border-top:1px solid var(--line); }
a.shot .cap { color:#c8d4ce; background:#141f1a; border-top:none; }
.missing { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; min-height:120px; border:1px dashed var(--line); border-radius:8px; color:var(--muted); font-size:.85rem; background:#faf9f5; }
.missing .cap { background:none; border:none; }
@media (max-width:820px){ th.screen{width:90px;} td{width:calc((100% - 90px)/2);} }
</style>
</head>
<body>
<header>
<h1>Display-Vorschau (lokal)</h1>
<div class="meta">${sims.length} Simulationen · je Onboarding/Start + Spielverlauf · iPad 1180×820 &amp; Full HD 1920×1080 · generiert am ${genDate}</div>
</header>
<nav class="toc">${nav}</nav>
<main>${blocks}
</main>
</body>
</html>`;
const outFile = path.join(OUT_DIR, 'display_preview.html');
fs.writeFileSync(outFile, html, 'utf8');
console.log('\nHTML geschrieben: ' + outFile);
// --- Zusammenfassung ---------------------------------------------------
console.log('\n===== ZUSAMMENFASSUNG =====');
let totalShots = 0, missingPlay = [];
for (const id of sims) {
const parts = [];
for (const vp of VIEWPORTS) {
const r = (results[id] && results[id][vp.key]) || {};
const n = (r.start ? 1 : 0) + (r.play ? 1 : 0) + (r.play2 ? 1 : 0);
totalShots += n;
parts.push(vp.label + ':' + n + '/3(' + (r.start ? 'S' : '-') + (r.play ? 'P' : '-') + (r.play2 ? '2' : '-') + ')');
if (!r.play) missingPlay.push(id + '/' + vp.key);
}
console.log(' ' + id.padEnd(18) + parts.join(' '));
}
console.log('\nScreenshots gesamt: ' + totalShots);
console.log('Fehlende Play-Screens: ' + (missingPlay.length ? missingPlay.join(', ') : 'keine'));
console.log('Seite: http://localhost/geograsim/App/.LocalDeveloperTools/display-preview/display_preview.html');
})();
@@ -0,0 +1,17 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const res = await page.goto('http://localhost/geograsim/App/modul-weltkueche');
await page.waitForTimeout(700);
const r = await page.evaluate(() => {
const sec = document.getElementById('lehrplan-bezug');
const komps = [...document.querySelectorAll('.md-kompetenz .md-kompetenz-title')].map(e=>e.textContent.trim());
const anchors = document.querySelectorAll('.md-anchor').length;
const countryChips = [...document.querySelectorAll('.md-lehrplan-filters .md-chip')].map(c=>c.textContent.trim());
const empty = document.body.textContent.includes('Noch keine Kompetenzen zugeordnet');
return { hasSection: !!sec, kompetenzTitles: komps, anchorCount: anchors, countryChips, showsEmpty: empty };
});
console.log(JSON.stringify({ httpStatus: res.status(), result: r }, null, 2));
await browser.close();
})();
@@ -0,0 +1,17 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const res = await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil: 'networkidle' });
await page.waitForTimeout(700);
const r = await page.evaluate(() => {
const sec = document.getElementById('lehrplan-bezug');
const komps = [...document.querySelectorAll('.md-kompetenz .md-kompetenz-title')].map(e=>e.textContent.trim());
const anchors = document.querySelectorAll('.md-anchor').length;
const countryChips = [...document.querySelectorAll('.md-lehrplan-filters .md-chip')].map(c=>c.textContent.trim());
const empty = document.body.textContent.includes('Noch keine Kompetenzen zugeordnet');
return { hasSection: !!sec, kompetenzTitles: komps, anchorCount: anchors, countryChips, showsEmpty: empty };
});
console.log(JSON.stringify({ httpStatus: res.status(), result: r }, null, 2));
await browser.close();
})();
@@ -0,0 +1,37 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
await page.goto('http://localhost/geograsim/App/modul-weltkueche', { waitUntil:'domcontentloaded' });
await page.waitForTimeout(700);
async function snapshot() {
return await page.evaluate(() => {
const all = [...document.querySelectorAll('.md-anchor')];
const visible = all.filter(a => !a.classList.contains('hidden'));
const byType = t => visible.filter(a => a.dataset.anchorType===t).length;
const fachCountries = {};
visible.filter(a=>a.dataset.anchorType==='fach').forEach(a=>{ fachCountries[a.dataset.country]=(fachCountries[a.dataset.country]||0)+1; });
return { total: all.length, visible: visible.length, fachVisible: byType('fach'), fachCountries };
});
}
async function clickChip(country) {
await page.evaluate((c) => {
const btn = [...document.querySelectorAll('.md-chip')].find(b => (b.dataset.country||'')===c);
if (btn) btn.click();
}, country);
await page.waitForTimeout(200);
}
const start = await snapshot();
await clickChip(''); // Alle Länder
const alle = await snapshot();
await clickChip('DE'); // Deutschland
const de = await snapshot();
await clickChip('AT'); // Österreich
const at = await snapshot();
console.log(JSON.stringify({ start, alle, de, at, pageErrors: errs }, null, 2));
await browser.close();
})();
+15
View File
@@ -0,0 +1,15 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil:'domcontentloaded' });
await page.waitForTimeout(900);
async function snap(){ return await page.evaluate(()=>{ const all=[...document.querySelectorAll('.md-anchor')]; const vis=all.filter(a=>!a.classList.contains('hidden')); const fc={}; vis.filter(a=>a.dataset.anchorType==='fach').forEach(a=>fc[a.dataset.country]=(fc[a.dataset.country]||0)+1); return {total:all.length, visible:vis.length, fach:vis.filter(a=>a.dataset.anchorType==='fach').length, fachCountries:fc}; }); }
async function chip(c){ await page.evaluate(c=>{const b=[...document.querySelectorAll('.md-chip')].find(x=>(x.dataset.country||'')===c); if(b)b.click();},c); await page.waitForTimeout(200); }
await chip(''); const alle=await snap();
await chip('DE'); const de=await snap();
await chip('AT'); const at=await snap();
console.log(JSON.stringify({alle,de,at,pageErrors:errs},null,2));
await browser.close();
})();
+25
View File
@@ -0,0 +1,25 @@
const { chromium } = require('@playwright/test');
(async () => {
const b = await chromium.launch(); const p = await b.newPage();
await p.addInitScript(() => {
window.__GGS__ = { simId: 'test', baseUrl: '' };
window.fetch = () => Promise.resolve({ ok:true, json:()=>Promise.resolve({}) }); // Heartbeat stummschalten
});
await p.goto('about:blank');
await p.addScriptTag({ path: 'C:/xampp/htdocs/geograsim/App/assets/js/live-client.js' });
const read = () => p.evaluate(() => window.__GGS_LIVE__ ? window.__GGS_LIVE__.activeMs : null);
const set = (v) => p.evaluate((v) => window.__GGS_LIVE__.setPaused(v), v);
await p.waitForTimeout(300);
const a1 = await read();
await p.waitForTimeout(2500); const a2 = await read(); // sollte gestiegen sein
await set(true);
await p.waitForTimeout(2500); const a3 = await read(); // sollte eingefroren sein
await set(false);
await p.waitForTimeout(2500); const a4 = await read(); // sollte wieder steigen
const grewNormal = (a2 - a1) > 1500;
const frozen = (a3 - a2) < 300;
const resumed = (a4 - a3) > 1500;
console.log(JSON.stringify({ a1,a2,a3,a4, grewNormal, frozen, resumed,
verdict: (grewNormal && frozen && resumed) ? 'PASS' : 'FAIL' }, null, 2));
await b.close();
})();
+74
View File
@@ -0,0 +1,74 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const out = {};
// 1) modul-weltkueche: Lehrplan-Bezug jetzt vorhanden?
{
const page = await browser.newPage();
await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
out.modulWeltkueche = await page.evaluate(() => ({
kompetenzTitles: [...document.querySelectorAll('.md-kompetenz-title')].map(e=>e.textContent.trim()),
anchorCount: document.querySelectorAll('.md-anchor').length,
showsEmpty: document.body.textContent.includes('Noch keine Kompetenzen zugeordnet'),
}));
await page.close();
}
// 2) Weltküche-Sim: Artikel + Ukraine-Feedback
{
const page = await browser.newPage();
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
await page.goto('https://geograsim.at/sims/weltkueche/game.html', { waitUntil:'domcontentloaded' });
await page.waitForTimeout(2000);
out.weltkueche = await page.evaluate(async () => {
try {
if (!DISHES.length) await loadWeltkuecheData();
const dish = DISHES.find(d=>d.id==='tiroler-groestl'); current={dish};
buildTaskFor('kartoffel');
return {
kartoffelArt: ING.kartoffel.art,
badArticles: Object.entries(ING).filter(([k,v])=>!['der','die','das'].includes(v&&v.art)).map(([k])=>k),
wrongUA: explainChoice('kartoffel','ukraine',false).text,
};
} catch(e){ return { error:String(e) }; }
});
out.weltkueche.pageErrors = errs;
await page.close();
}
// 3) teacher.html: lädt + neue Funktionen da (direkter Link, den das Produkt nutzt)
{
const page = await browser.newPage();
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
// teacher.html leitet ohne Login auf login.html um — Navigation abfangen, sofort auswerten
await page.route('**/login.html', r=>r.abort());
await page.goto('https://geograsim.at/teacher.html', { waitUntil:'domcontentloaded' }).catch(()=>{});
await page.waitForTimeout(1500);
out.teacher = await page.evaluate(() => ({
hasRenderGesamt: typeof renderLiveGesamt === 'function',
hasQualityBar: typeof _liveQualityBar === 'function',
qualitySample: (typeof _liveQualityBar==='function') ? _liveQualityBar(8,2) : null,
tabLabels: [...document.querySelectorAll('#tab-bar .tab')].map(t=>t.textContent.trim()),
})).catch(e=>({evalError:String(e)}));
out.teacher.pageErrors = errs;
await page.close();
}
// 4) Tourismusregion passstrasse Karte
{
const page = await browser.newPage();
const mc = await page.request.get('https://geograsim.at/sims/tourismusregion/js/maps-custom.json');
const j = await mc.json();
out.tourismus = {
keys: Object.keys(j),
passBergaufPoints: j.passstrasse?.routes?.bergauf?.length ?? null,
passSlots: (j.passstrasse?.slots||[]).length,
};
await page.close();
}
console.log(JSON.stringify(out, null, 2));
await browser.close();
})();
@@ -0,0 +1,21 @@
const { chromium } = require('@playwright/test');
(async () => {
const b = await chromium.launch(); const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.route('**/login.html', r=>r.abort());
await p.goto('http://localhost/geograsim/App/teacher.html',{waitUntil:'domcontentloaded'}).catch(()=>{});
await p.waitForTimeout(1200);
const r = await p.evaluate(() => ({
hasSimWord: typeof simWord==='function',
hasSimCode: typeof simCode==='function',
logistikWord: typeof simWord==='function' ? simWord('logistik') : null,
logistikCode: typeof simCode==='function' ? simCode('logistik') : null,
unknownCode: typeof simCode==='function' ? simCode('irgendwas') : null,
logistikFields: (typeof LIVE_PRIMARY_FIELDS==='object' && LIVE_PRIMARY_FIELDS.logistik) ? LIVE_PRIMARY_FIELDS.logistik.map(f=>f.label) : null,
liveShortLogistik: typeof LIVE_SIM_SHORT==='object' ? LIVE_SIM_SHORT.logistik : null,
// Kürzel-Eindeutigkeit
codesUnique: (function(){ if(typeof SIM_LABELS!=='object') return null; var seen={},dup=[]; Object.keys(SIM_LABELS).forEach(k=>{var c=SIM_LABELS[k].c; if(seen[c])dup.push(c); seen[c]=1;}); return dup; })(),
}));
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
await b.close();
})();
@@ -0,0 +1,26 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
const res = await page.goto('http://localhost/geograsim/App/teacher');
await page.waitForTimeout(1000);
const r = await page.evaluate(() => {
const tabs = [...document.querySelectorAll('#tab-bar .tab')].map(t => t.textContent.trim());
let toggle = null;
try { toggle = (typeof _liveToggleHtml === 'function') ? _liveToggleHtml() : null; } catch(e){ toggle = 'ERR:'+e.message; }
return {
tabLabels: tabs,
hasSetLiveMode: typeof setLiveMode === 'function',
hasRenderGesamt: typeof renderLiveGesamt === 'function',
hasQualityBar: typeof _liveQualityBar === 'function',
qualitySample: (typeof _liveQualityBar === 'function') ? _liveQualityBar(8,2) : null,
toggleHasGesamt: (typeof toggle === 'string') ? toggle.includes('Gesamt') : toggle,
toggleHasTag: (typeof toggle === 'string') ? toggle.includes('Tag') : toggle,
};
});
console.log(JSON.stringify({ httpStatus: res.status(), result: r, pageErrors: errors }, null, 2));
await browser.close();
})();
@@ -0,0 +1,21 @@
const { chromium } = require('@playwright/test');
(async () => {
const b = await chromium.launch(); const p = await b.newPage({ viewport:{width:1180,height:820} });
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
await p.waitForTimeout(1500);
await p.click('.map-btn'); // Route klicken → Spiel startet → Willkommenskarte
await p.waitForTimeout(2000);
const r = await p.evaluate(() => {
const card = document.getElementById('cardText');
const txt = card ? card.textContent : '';
return {
started: !!window.__ttState,
cardHasEnvText: /Umwelt ist deine Landschaft/.test(txt),
cardHasNaturschutz: /Naturschutzgebiet/.test(txt),
envKpi: document.getElementById('kEnv')?.textContent,
};
});
console.log(JSON.stringify({ result:r, pageErrors: errs.filter(e=>!/404|Failed to load resource/.test(e)) }, null, 2));
await b.close();
})();
@@ -0,0 +1,38 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
// 1) maps-custom.json direkt prüfen
const mc = await page.request.get('http://localhost/geograsim/App/sims/tourismusregion/js/maps-custom.json');
const mcJson = await mc.json();
const ps = mcJson.passstrasse || null;
// 2) Hintergrundbild erreichbar?
const img = await page.request.get('http://localhost/geograsim/App/sims/tourismusregion/assets/maps/passstrasse.png');
// 3) Sim-Startscreen laden, Kartenliste prüfen
const res = await page.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
await page.waitForTimeout(2500);
const startInfo = await page.evaluate(() => {
const list = document.getElementById('mapList');
const labels = list ? [...list.querySelectorAll('*')].map(e=>e.textContent).filter(t=>t && t.length<40) : [];
const hasPass = !!(list && /Passstra/i.test(list.textContent||''));
return { mapListText: (list?list.textContent:'').replace(/\s+/g,' ').trim().slice(0,300), hasPass };
});
console.log(JSON.stringify({
httpStatus: res.status(),
passInMapsCustom: !!ps,
routeKeys: ps ? Object.keys(ps.routes||{}) : null,
bergaufPoints: ps && ps.routes && ps.routes.bergauf ? ps.routes.bergauf.length : null,
slotCount: ps ? (ps.slots||[]).length : null,
bgImageStatus: img.status(),
startInfo,
pageErrors: errors
}, null, 2));
await browser.close();
})();
@@ -0,0 +1,30 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1180, height: 820 } }); // iPad Landscape
const errs=[]; page.on('pageerror',e=>errs.push(e.message)); page.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await page.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
await page.waitForTimeout(1500);
const before = await page.evaluate(() => ({
hasPlayerName: !!document.getElementById('playerName'),
hasClassCode: !!document.getElementById('classCode'),
hasBtnStart: !!document.getElementById('btnStart'),
hasBtnSave: !!document.getElementById('btnSave'),
mapBtnCount: document.querySelectorAll('.map-btn').length,
startOverlayHidden: document.getElementById('startOverlay').classList.contains('hidden'),
}));
// ② Klick auf erste Route sollte SOFORT starten
await page.click('.map-btn');
await page.waitForTimeout(2500);
const after = await page.evaluate(() => ({
startOverlayHidden: document.getElementById('startOverlay').classList.contains('hidden'),
gameStarted: !!window.__ttState,
mapId: window.__ttState && window.__ttState.map && window.__ttState.map.id,
}));
await page.screenshot({ path: 'C:/Users/herr_/AppData/Local/Temp/claude/c--xampp-htdocs-geograsim/c02572ea-7616-4524-a974-dff0e5670d2c/scratchpad/tr-ipad.png' });
console.log(JSON.stringify({ before, after, pageErrors: errs }, null, 2));
await browser.close();
})();
@@ -0,0 +1,27 @@
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errors = [];
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
await page.goto('http://localhost/geograsim/App/sims/weltkueche/game.html');
await page.waitForTimeout(1500);
const r = await page.evaluate(async () => {
try {
if (!DISHES.length) await loadWeltkuecheData();
const dish = DISHES.find(d => d.id === 'tiroler-groestl');
if (!dish) return { error: 'no tiroler-groestl dish' };
current = { dish };
const task = buildTaskFor('kartoffel');
const art = ING.kartoffel.art;
const correctAT = explainChoice('kartoffel', 'oesterreich', true).text;
const correctDE = explainChoice('kartoffel', 'deutschland', true).text;
const wrongUA = explainChoice('kartoffel', 'ukraine', false).text;
const bad = Object.entries(ING).filter(([k,v]) => !['der','die','das'].includes(v && v.art)).map(([k])=>k);
return { art, taskCorrect: task.correct, correctAT, correctDE, wrongUA, badArticles: bad, ingCount: Object.keys(ING).length };
} catch (e) { return { error: String(e), stack: e.stack }; }
});
console.log(JSON.stringify({ result: r, consoleErrors: errors }, null, 2));
await browser.close();
})();
+38
View File
@@ -0,0 +1,38 @@
const { chromium } = require('@playwright/test');
(async () => {
const b = await chromium.launch(); const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.goto('http://localhost/geograsim/App/sims/weltkueche/game.html');
await p.waitForTimeout(1500);
const r = await p.evaluate(async () => {
try {
if (!DISHES.length) await loadWeltkuecheData();
function taskFor(ingKey, heimat){
current = { dish: { id:'test-'+ingKey, heimat: heimat, ings:[ingKey], signature:{} } };
try { delete _taskCache[ingKey]; } catch(_){}
return buildTaskFor(ingKey);
}
const dEggsDE = _wkDist('oesterreich','deutschland');
const dEggsFR = _wkDist('oesterreich','frankreich');
const eier = taskFor('eier','oesterreich');
const kart = taskFor('kartoffel','oesterreich');
const milch = taskFor('milch','oesterreich');
// alle Zutaten müssen fehlerfrei bauen
let buildErr = null;
for (const k of Object.keys(ING)) {
current = { dish:{id:'x',heimat:'oesterreich',ings:[k],signature:{}} };
try { delete _taskCache[k]; buildTaskFor(k); } catch(e){ buildErr = k+': '+e.message; break; }
}
return {
distDE: Math.round(dEggsDE), distFR: Math.round(dEggsFR),
eierCorrect: eier.correct, eierChoices: eier.choices,
eierDistractors: eier.choices.length - eier.correct.length,
kartoffelCorrect: kart.correct,
milchCorrect: milch.correct,
buildErr, ingCount: Object.keys(ING).length,
};
} catch(e){ return { error:String(e), stack:e.stack }; }
});
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
await b.close();
})();
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Display-Vorschau (lokal)</title>
<style>
:root { --bg:#f4f2ec; --card:#fff; --ink:#1f2b26; --accent:#1f4b37; --muted:#6b7a72; --line:rgba(0,0,0,.08); }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
header { background:var(--accent); color:#fff; padding:20px 28px; position:sticky; top:0; z-index:10; box-shadow:0 2px 10px rgba(0,0,0,.15); }
header h1 { margin:0; font-size:1.4rem; }
header .meta { font-size:.82rem; opacity:.85; margin-top:4px; }
nav.toc { display:flex; flex-wrap:wrap; gap:6px; padding:14px 28px; background:#e9e6dd; border-bottom:1px solid var(--line); }
nav.toc a { font-size:.82rem; text-decoration:none; color:var(--accent); background:#fff; border:1px solid var(--line); border-radius:999px; padding:4px 11px; }
nav.toc a:hover { background:var(--accent); color:#fff; }
main { padding:24px 28px 60px; max-width:1500px; margin:0 auto; }
section.sim { background:var(--card); border-radius:14px; padding:18px 20px 22px; margin-bottom:26px; box-shadow:0 4px 14px rgba(0,0,0,.06); scroll-margin-top:90px; }
section.sim h2 { margin:0 0 14px; font-size:1.25rem; color:var(--accent); display:flex; align-items:baseline; gap:10px; }
.simid { font-size:.72rem; font-weight:600; color:var(--muted); background:#eef0ec; padding:2px 8px; border-radius:6px; letter-spacing:.03em; }
table { width:100%; border-collapse:collapse; table-layout:fixed; }
thead th { text-align:left; font-size:.82rem; color:var(--muted); padding:0 0 8px; font-weight:700; }
thead th .res { font-weight:500; opacity:.7; }
th.screen { width:150px; vertical-align:top; font-size:.9rem; color:var(--ink); padding-top:8px; }
td { padding:6px 6px 14px; vertical-align:top; width:calc((100% - 150px)/2); }
a.shot { display:block; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#0d1512; text-decoration:none; }
a.shot img { display:block; width:100%; max-width:100%; height:auto; }
.cap { display:block; font-size:.72rem; color:var(--muted); padding:5px 8px; background:#fafafa; border-top:1px solid var(--line); }
a.shot .cap { color:#c8d4ce; background:#141f1a; border-top:none; }
.missing { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; min-height:120px; border:1px dashed var(--line); border-radius:8px; color:var(--muted); font-size:.85rem; background:#faf9f5; }
.missing .cap { background:none; border:none; }
@media (max-width:820px){ th.screen{width:90px;} td{width:calc((100% - 90px)/2);} }
</style>
</head>
<body>
<header>
<h1>Display-Vorschau (lokal)</h1>
<div class="meta">16 Simulationen · je Onboarding/Start + Spielverlauf · iPad 1180×820 &amp; Full HD 1920×1080 · generiert am 19.08.2026, 15:06</div>
</header>
<nav class="toc"><a href="#sim-busfahrt">🚌 Busfahrt</a><a href="#sim-energiemanager">⚡ Energiemanager</a><a href="#sim-entscheidungstag">📅 Entscheidungstag</a><a href="#sim-eu-werkstatt">🇪🇺 EU-Werkstatt</a><a href="#sim-farmer">🌾 Farmer</a><a href="#sim-fluggesellschaft">✈️ Fluggesellschaft</a><a href="#sim-fluss">🌊 Flussmanagement</a><a href="#sim-heli">🚁 Helikopter-Navigation</a><a href="#sim-logistik">🚚 Logistik</a><a href="#sim-sonnensystem">🪐 Sonnensystem</a><a href="#sim-staustufen">💧 Staustufen</a><a href="#sim-tourismusregion">🗺️ Tourismusregion</a><a href="#sim-tourismustal">🏔️ Tourismustal</a><a href="#sim-vulkan">🌋 Vulkan</a><a href="#sim-wal">🐋 Wal</a><a href="#sim-weltkueche">🍲 Welt-Küche</a></nav>
<main>
<section class="sim" id="sim-busfahrt"><h2>🚌 Busfahrt <span class="simid">busfahrt</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/busfahrt__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__ipad__start.png" alt="busfahrt iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/busfahrt__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__fullhd__start.png" alt="busfahrt Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/busfahrt__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__ipad__play.png" alt="busfahrt iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/busfahrt__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__fullhd__play.png" alt="busfahrt Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-energiemanager"><h2>⚡ Energiemanager <span class="simid">energiemanager</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/energiemanager__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__start.png" alt="energiemanager iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__start.png" alt="energiemanager Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/energiemanager__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__play.png" alt="energiemanager iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__play.png" alt="energiemanager Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/energiemanager__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__play2.png" alt="energiemanager iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__play2.png" alt="energiemanager Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-entscheidungstag"><h2>📅 Entscheidungstag <span class="simid">entscheidungstag</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/entscheidungstag__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__ipad__start.png" alt="entscheidungstag iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/entscheidungstag__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__fullhd__start.png" alt="entscheidungstag Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/entscheidungstag__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__ipad__play.png" alt="entscheidungstag iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/entscheidungstag__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__fullhd__play.png" alt="entscheidungstag Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-eu-werkstatt"><h2>🇪🇺 EU-Werkstatt <span class="simid">eu-werkstatt</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/eu-werkstatt__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__ipad__start.png" alt="eu-werkstatt iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/eu-werkstatt__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__fullhd__start.png" alt="eu-werkstatt Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/eu-werkstatt__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__ipad__play.png" alt="eu-werkstatt iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/eu-werkstatt__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__fullhd__play.png" alt="eu-werkstatt Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-farmer"><h2>🌾 Farmer <span class="simid">farmer</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/farmer__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__ipad__start.png" alt="farmer iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/farmer__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__fullhd__start.png" alt="farmer Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/farmer__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__ipad__play.png" alt="farmer iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/farmer__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__fullhd__play.png" alt="farmer Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-fluggesellschaft"><h2>✈️ Fluggesellschaft <span class="simid">fluggesellschaft</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/fluggesellschaft__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__ipad__start.png" alt="fluggesellschaft iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluggesellschaft__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__fullhd__start.png" alt="fluggesellschaft Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/fluggesellschaft__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__ipad__play.png" alt="fluggesellschaft iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluggesellschaft__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__fullhd__play.png" alt="fluggesellschaft Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-fluss"><h2>🌊 Flussmanagement <span class="simid">fluss</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/fluss__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluss__ipad__start.png" alt="fluss iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluss__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluss__fullhd__start.png" alt="fluss Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-heli"><h2>🚁 Helikopter-Navigation <span class="simid">heli</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/heli__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__ipad__start.png" alt="heli iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/heli__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__fullhd__start.png" alt="heli Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/heli__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__ipad__play.png" alt="heli iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/heli__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__fullhd__play.png" alt="heli Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-logistik"><h2>🚚 Logistik <span class="simid">logistik</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/logistik__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__ipad__start.png" alt="logistik iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/logistik__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__fullhd__start.png" alt="logistik Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/logistik__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__ipad__play.png" alt="logistik iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/logistik__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__fullhd__play.png" alt="logistik Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-sonnensystem"><h2>🪐 Sonnensystem <span class="simid">sonnensystem</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/sonnensystem__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__start.png" alt="sonnensystem iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__start.png" alt="sonnensystem Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/sonnensystem__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__play.png" alt="sonnensystem iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__play.png" alt="sonnensystem Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/sonnensystem__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__play2.png" alt="sonnensystem iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__play2.png" alt="sonnensystem Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-staustufen"><h2>💧 Staustufen <span class="simid">staustufen</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/staustufen__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__ipad__start.png" alt="staustufen iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/staustufen__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__fullhd__start.png" alt="staustufen Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/staustufen__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__ipad__play.png" alt="staustufen iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/staustufen__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__fullhd__play.png" alt="staustufen Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-tourismusregion"><h2>🗺️ Tourismusregion <span class="simid">tourismusregion</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/tourismusregion__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__ipad__start.png" alt="tourismusregion iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismusregion__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__fullhd__start.png" alt="tourismusregion Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/tourismusregion__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__ipad__play.png" alt="tourismusregion iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismusregion__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__fullhd__play.png" alt="tourismusregion Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-tourismustal"><h2>🏔️ Tourismustal <span class="simid">tourismustal</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/tourismustal__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__ipad__start.png" alt="tourismustal iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismustal__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__fullhd__start.png" alt="tourismustal Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/tourismustal__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__ipad__play.png" alt="tourismustal iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismustal__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__fullhd__play.png" alt="tourismustal Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-vulkan"><h2>🌋 Vulkan <span class="simid">vulkan</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/vulkan__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__start.png" alt="vulkan iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__start.png" alt="vulkan Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/vulkan__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__play.png" alt="vulkan iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__play.png" alt="vulkan Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/vulkan__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__play2.png" alt="vulkan iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__play2.png" alt="vulkan Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-wal"><h2>🐋 Wal <span class="simid">wal</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/wal__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__ipad__start.png" alt="wal iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/wal__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__fullhd__start.png" alt="wal Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/wal__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__ipad__play.png" alt="wal iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/wal__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__fullhd__play.png" alt="wal Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
<section class="sim" id="sim-weltkueche"><h2>🍲 Welt-Küche <span class="simid">weltkueche</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/weltkueche__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__ipad__start.png" alt="weltkueche iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/weltkueche__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__fullhd__start.png" alt="weltkueche Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
<tr><th class="screen">Spielverlauf</th><td><a href="shots/weltkueche__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__ipad__play.png" alt="weltkueche iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/weltkueche__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__fullhd__play.png" alt="weltkueche Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
</main>
</body>
</html>
@@ -0,0 +1,282 @@
/**
* regen-gameplay-shots.cjs
* ------------------------
* Ersetzt die "Spielverlauf"-Screenshots (play / play2) der Display-Vorschau
* durch ECHTE Spielszenen: Auswahl-Screens (Welt/Karte/Szenario) werden
* betreten und das Onboarding wird zuverlässig weggeklickt (Skip-Buttons +
* mehrseitige "Weiter"-Tutorials, auch wenn der Button unter dem Fold liegt),
* bevor der Screenshot fällt.
*
* Ersetzt NUR die play/play2-Bilder in-place; die start-Bilder (Onboarding)
* und display_preview.html bleiben unangetastet, damit die Galerie
* unverändert funktioniert.
*
* Start (aus dem playwright-Ordner wegen node_modules):
* cd /c/xampp/htdocs/geograsim/.dontDeploy/playwright && \
* NODE_PATH="C:\\xampp\\htdocs\\geograsim\\.dontDeploy\\playwright\\node_modules" \
* node ../../App/.LocalDeveloperTools/display-preview/regen-gameplay-shots.cjs
*
* Optional: --only=sonnensystem,wal --vp=ipad
*/
const { chromium } = require('@playwright/test');
const path = require('path');
const SHOTS_DIR = 'C:/xampp/htdocs/geograsim/App/.LocalDeveloperTools/display-preview/shots';
const BASE_URL = 'http://localhost/geograsim/App/sims';
const VIEWPORTS = [
{ key: 'ipad', w: 1180, h: 820 },
{ key: 'fullhd', w: 1920, h: 1080 },
];
// Sims mit "play"-Screenshot in der Galerie (fluss hat keinen play -> weggelassen).
const SIMS = [
{ id: 'busfahrt' },
{ id: 'energiemanager', play2: true },
{ id: 'entscheidungstag' },
{ id: 'eu-werkstatt' },
{ id: 'farmer' },
{ id: 'fluggesellschaft' },
{ id: 'heli' },
{ id: 'logistik' },
{ id: 'sonnensystem', play2: true },
{ id: 'staustufen' },
{ id: 'tourismusregion' },
{ id: 'tourismustal' },
{ id: 'vulkan', play2: true },
{ id: 'wal' },
{ id: 'weltkueche' },
];
// ---- In-Page: EIN Onboarding-Schritt wegklicken -------------------------
async function skipStep(page) {
return await page.evaluate(() => {
// "loose": gerendert, aber darf auch unter dem Fold liegen (.click() geht trotzdem)
const rendered = (el) => {
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width < 6 || r.height < 6) return false;
const s = getComputedStyle(el);
return s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05
&& !el.disabled && el.getAttribute('aria-hidden') !== 'true'
&& !el.classList.contains('hidden');
};
const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
const norm = (s) => clean(s).replace(/[^\p{L}\p{N} ]+/gu, '').trim().toLowerCase();
// Namensfelder füllen
document.querySelectorAll('input[type=text],input[type=search],input:not([type])').forEach((i) => {
if (rendered(i) && !i.value) {
i.value = 'Demo';
i.dispatchEvent(new Event('input', { bubbles: true }));
i.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// 1) Explizite Skip-Buttons (schließen das GANZE Tutorial auf einmal)
const skipSel = [
'#emOnbSkip', '#tutorial-skip', '#heliOnbSkip', '#snOnbSkip',
'[onclick*="skipTutorial"]', '[onclick*="closeTutorial"]',
'[onclick*="skipOnboarding"]', '[onclick*="skipHeliOnboarding"]',
];
for (const sel of skipSel) {
const el = [...document.querySelectorAll(sel)].find(rendered);
if (el) { el.click(); return 'skip:' + sel; }
}
// Skip per Text
{
const el = [...document.querySelectorAll('button,a,[role=button]')].find(
(e) => rendered(e) && /^(uberspringen|tutorial uberspringen|einfuhrung uberspringen|nicht mehr anzeigen)$/.test(norm(e.textContent))
);
if (el) { el.click(); return 'skiptext'; }
}
// 2) "Weiter"/"Los geht's" NUR innerhalb eines sichtbaren Overlays
// (verhindert das Anklicken von Gameplay-Buttons wie Vulkan "Weiter →")
const overSel = '.ggs-modal-overlay,.ggs-overlay,.ggs-overlay-card,.sn-overlay,'
+ '.overlay,.overlay-card,.splash-overlay,.lg-splash-overlay,.modal,'
+ '.st-hint-overlay,.st-hint-modal,#vulk-overlay,#vulk-modal,.ggs-qi-back,'
+ '[id*="Onb"],[id*="onboard"],[id*="overlay" i],[id*="modal" i],'
+ '[class*="onb"],[class*="welcome"],[class*="tutorial"],[class*="intro"],'
+ '[class*="willkommen"],[class*="overlay" i],[class*="ggs-qi"]';
const overlays = [...document.querySelectorAll(overSel)].filter((o) => {
if (!rendered(o)) return false;
const r = o.getBoundingClientRect();
return r.width > 240 && r.height > 150;
});
const proceedRx = /^(weiter|weiter gehts|los gehts|los geht s|auf gehts|verstanden|alles klar|habe verstanden|fertig|los arbeiten|jetzt starten|jetzt loslegen|jetzt spielen|spiel starten|neue partie|partie starten|los|okay|ok|schliessen|start|starten|beginnen)$/;
for (const ov of overlays) {
const btns = [...ov.querySelectorAll('button,a,[role=button],.btn')].filter(rendered);
let el = btns.find((b) => proceedRx.test(norm(b.textContent)));
if (!el) el = btns.find((b) => /^(x|close)$/.test(norm(b.textContent)) || /(^|\s)(close|ggs-modal-close)(\s|$)/i.test(b.className));
if (el) { el.click(); return 'proceed:' + norm(el.textContent).slice(0, 24); }
}
return null;
}).catch(() => null);
}
// Noch ein Onboarding-Overlay sichtbar? (Heuristik für den Report)
async function onboardingStillVisible(page) {
return await page.evaluate(() => {
const rx = /(willkommen|wusstest du|anleitung|was lerne ich|so funktioniert|einf(ü|u)hrung|beobachtungs-?aufgaben|dein kreislauf|uberspringen|überspringen|vulkanologie|observatorium|du erkundest die planeten|was tust du)/i;
const els = [...document.querySelectorAll('.overlay,.ggs-overlay,.ggs-modal-overlay,.sn-overlay,.st-hint-overlay,#vulk-overlay,[class*="onb"],[class*="tutorial"],[class*="intro"],[class*="welcome"],[class*="overlay" i]')];
return els.some((el) => {
const r = el.getBoundingClientRect();
if (r.width < 240 || r.height < 150) return false;
const s = getComputedStyle(el);
if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity < 0.05 || el.classList.contains('hidden')) return false;
return rx.test((el.textContent || '').slice(0, 500));
});
}).catch(() => false);
}
async function clickSel(page, sel) {
return await page.evaluate((sel) => {
const el = [...document.querySelectorAll(sel)].find((e) => {
const r = e.getBoundingClientRect();
const s = getComputedStyle(e);
return r.width > 4 && r.height > 4 && s.visibility !== 'hidden' && s.display !== 'none' && !e.classList.contains('hidden');
});
if (el) { el.click(); return true; }
return false;
}, sel).catch(() => false);
}
async function clickByText(page, source) {
return await page.evaluate((src) => {
const rx = new RegExp(src, 'i');
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().replace(/[^\p{L}\p{N} ]+/gu, '').trim();
const el = [...document.querySelectorAll('button,a,[role=button],.btn')].find((e) => {
const r = e.getBoundingClientRect();
const s = getComputedStyle(e);
if (r.width < 6 || r.height < 6 || s.visibility === 'hidden' || s.display === 'none' || e.classList.contains('hidden')) return false;
return rx.test(norm(e.textContent));
});
if (el) { el.click(); return true; }
return false;
}, source).catch(() => false);
}
async function skipLoop(page, rounds = 12) {
const seq = [];
for (let i = 0; i < rounds; i++) {
const r = await skipStep(page);
if (!r) break;
seq.push(r);
await page.waitForTimeout(420);
}
return seq;
}
// ---- pro-Sim: Auswahl-Screen betreten, dann Tutorial wegklicken ---------
const ENTRY = {
// heli: Flugkarte braucht PHP-injizierte Waypoints (WP), die beim direkten
// Aufruf von game.html fehlen (-> "WP is not defined"). Daher bleibt der
// stabile Gameplay-Screen die Einsatzzentrale (Missionsauswahl nach Onboarding).
'wal': async (page) => {
await clickSel(page, '.era-card-cta'); // "In dieser Welt starten →"
await page.waitForTimeout(1800);
await skipLoop(page);
},
'tourismusregion': async (page) => {
await clickSel(page, '#mapList .map-btn, .map-btn'); // Wegkarte wählen
await page.waitForTimeout(3000); // 3D-Szene
await skipLoop(page); // "Willkommen …" weg
},
'tourismustal': async (page) => {
await clickSel(page, '[data-scene]'); // Szene wählen
await page.waitForTimeout(3000);
await skipLoop(page); // "Die Gäste …" weg
},
'eu-werkstatt': async (page) => {
await clickSel(page, '.scenario-card'); // Szenario betreten
await page.waitForTimeout(1600);
await skipLoop(page, 8); // Konzept-Intro weg
await page.waitForTimeout(900);
await clickByText(page, 'los gehts|los geht|^weiter|verstanden|alles klar');
await page.waitForTimeout(700);
await skipLoop(page, 4);
},
};
// Nach dem Wegklicken des Onboardings noch aufräumen, um die echte Szene zu zeigen.
const POST = {
'vulkan': async (page) => {
// "Los geht's" öffnet direkt das erste Aufgaben-Modal (#vulk-overlay).
// Schließen -> darunter liegt die 3D-Vulkanszene + Steuerpanel (Slider).
await page.evaluate(() => { const o = document.getElementById('vulk-overlay'); if (o) o.classList.remove('show'); });
await page.waitForTimeout(500);
},
};
const PLAY2 = {
'vulkan': async (page) => { await clickSel(page, '#erupt-btn'); await page.waitForTimeout(3200); },
'sonnensystem': async (page) => { await clickSel(page, '#obs-open-btn'); await page.waitForTimeout(1800); await skipLoop(page, 4); await page.waitForTimeout(1200); },
'energiemanager': async (page) => { await clickSel(page, '#btnRunDay'); await page.waitForTimeout(3200); },
};
(async () => {
const onlyArg = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7);
const vpArg = (process.argv.find((a) => a.startsWith('--vp=')) || '').slice(5);
const list = onlyArg ? SIMS.filter((s) => onlyArg.split(',').includes(s.id)) : SIMS;
const vps = vpArg ? VIEWPORTS.filter((v) => v.key === vpArg) : VIEWPORTS;
const browser = await chromium.launch({
args: ['--use-gl=swiftshader', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'],
});
for (const vp of vps) {
console.log(`\n===== ${vp.key} (${vp.w}x${vp.h}) =====`);
for (const sim of list) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
const errs = [];
page.on('pageerror', (e) => { if (errs.length < 3) errs.push('PE:' + e.message.slice(0, 50)); });
const url = `${BASE_URL}/${sim.id}/game.html`;
try {
await page.goto(url, { waitUntil: 'load', timeout: 40000 });
} catch (e) { errs.push('GOTO:' + e.message.slice(0, 40)); }
await page.waitForTimeout(3200); // 3D / CDN
let seq = await skipLoop(page, 14); // sofortiges Onboarding
if (ENTRY[sim.id]) await ENTRY[sim.id](page); // Auswahl-Screen + Tutorial
await page.waitForTimeout(2000);
// Zweiter Durchlauf: 3D-Sims (vulkan/sonnensystem) laden ihr Onboarding
// asynchron erst NACH dem ersten Skip -> hier erneut wegklicken.
seq = seq.concat(await skipLoop(page, 10));
if (POST[sim.id]) await POST[sim.id](page);
await page.waitForTimeout(1000);
const stuck = await onboardingStillVisible(page);
let ok = false;
try {
await page.screenshot({ path: path.join(SHOTS_DIR, `${sim.id}__${vp.key}__play.png`) });
ok = true;
} catch (e) { errs.push('SHOT:' + e.message.slice(0, 40)); }
let ok2 = null;
if (sim.play2 && PLAY2[sim.id]) {
try {
await PLAY2[sim.id](page);
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SHOTS_DIR, `${sim.id}__${vp.key}__play2.png`) });
ok2 = true;
} catch (e) { ok2 = false; errs.push('SHOT2:' + e.message.slice(0, 40)); }
}
console.log(
' ' + sim.id.padEnd(18)
+ (ok ? 'play OK' : 'play FAIL')
+ (ok2 === null ? '' : ok2 ? ' · play2 OK' : ' · play2 FAIL')
+ (stuck ? ' [!! ONBOARDING NOCH SICHTBAR]' : '')
+ ' clicks=' + seq.length + (seq.length ? ' (' + seq.slice(0, 6).join(',') + ')' : '')
+ (errs.length ? ' ' + errs.slice(0, 2).join(' | ') : '')
);
await ctx.close();
}
}
await browser.close();
console.log('\nFertig. Galerie: http://localhost/geograsim/App/.LocalDeveloperTools/display-preview/display_preview.html');
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Test-Checkliste (19.08.2026)</title>
<style>
:root{
--bg:#f4f2ec; --card:#fff; --ink:#242018; --muted:#7a7266; --line:rgba(0,0,0,.08);
--accent:#4a7c8a; --accent-d:#356070; --ok:#4a8a5a; --warn:#c58a2a; --sim:#7a5ca8;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5;padding-bottom:4rem}
header{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);
padding:.9rem 1.2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;box-shadow:0 1px 6px rgba(0,0,0,.04)}
header h1{font-size:1.15rem;font-weight:900;color:var(--accent-d);letter-spacing:-.02em}
header .sub{font-size:.72rem;color:var(--muted)}
.progress{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.8rem;font-weight:700;color:var(--accent-d)}
.bar{width:160px;height:9px;border-radius:99px;background:#e4e0d6;overflow:hidden}
.bar > div{height:100%;background:var(--ok);width:0;transition:width .3s}
.reset{font-size:.66rem;color:var(--muted);border:1px solid var(--line);background:#fff;border-radius:6px;padding:.25rem .5rem;cursor:pointer}
.wrap{max-width:920px;margin:1.4rem auto;padding:0 1.1rem}
.intro{font-size:.86rem;color:var(--muted);margin-bottom:1.4rem;background:#fff;border:1px solid var(--line);border-radius:10px;padding:.8rem 1rem}
.intro code{background:#eee9df;padding:.05rem .35rem;border-radius:4px;font-size:.85em}
section{margin-bottom:1.8rem}
.sec-h{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:var(--accent);
margin:0 0 .6rem .2rem;display:flex;align-items:center;gap:.5rem}
.sec-h .cnt{font-size:.62rem;color:var(--muted);font-weight:600;letter-spacing:.02em}
.item{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.85rem 1rem;margin-bottom:.7rem;
box-shadow:0 1px 3px rgba(0,0,0,.03);transition:opacity .2s}
.item.done{opacity:.55}
.item-top{display:flex;gap:.7rem;align-items:flex-start}
.item-top input[type=checkbox]{width:22px;height:22px;margin-top:.1rem;accent-color:var(--ok);cursor:pointer;flex-shrink:0}
.item-title{font-weight:800;font-size:.98rem}
.item.done .item-title{text-decoration:line-through;text-decoration-color:rgba(0,0,0,.3)}
.tag{display:inline-block;font-size:.58rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;
padding:.12rem .5rem;border-radius:99px;vertical-align:middle;margin-left:.5rem}
.tag.live{background:#dceadd;color:#2f6b3a}
.tag.pending{background:#fdeecd;color:#8a6510}
.body{padding-left:calc(22px + .7rem);margin-top:.4rem;font-size:.86rem;color:#4a463d}
.body .row{margin:.28rem 0}
.body b{color:var(--ink)}
.k{display:inline-block;min-width:74px;font-weight:700;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
a.url{color:var(--accent-d);font-family:ui-monospace,monospace;font-size:.82em;text-decoration:none;border-bottom:1px dotted var(--accent)}
a.url:hover{background:#eaf2f4}
.exp{background:#f0f6f1;border-left:3px solid var(--ok);border-radius:0 6px 6px 0;padding:.35rem .6rem;margin-top:.35rem;font-size:.82rem;color:#33613f}
.note{width:100%;margin-top:.5rem;border:1px solid var(--line);border-radius:7px;padding:.4rem .55rem;font-family:inherit;
font-size:.82rem;resize:vertical;min-height:0;height:2rem;background:#fcfbf8;color:var(--ink)}
.note::placeholder{color:#b3ac9e}
footer{text-align:center;color:var(--muted);font-size:.72rem;margin:2rem 0}
</style>
</head>
<body>
<header>
<div>
<h1>🧪 GeoGraSim — Test-Checkliste</h1>
<div class="sub">Alle Erledigungen seit den Weltküche-Lebensmitteln · Stand 19.08.2026 · lokal testen</div>
</div>
<div class="progress">
<span id="pcount">0 / 0</span>
<div class="bar"><div id="pbar"></div></div>
<button class="reset" onclick="if(confirm('Alle Haken + Notizen zurücksetzen?')){localStorage.clear();location.reload();}">reset</button>
</div>
</header>
<div class="wrap">
<div class="intro">
Basis-URL lokal: <code>http://localhost/geograsim/App/</code> · <b>Haken &amp; Notizen</b> werden im Browser gespeichert.
<span class="tag live">live</span> = auf Prod, <span class="tag pending">bereit</span> = committed, kommt mit dem nächsten Deploy.
Für Admin-Seiten zuerst über <a class="url" href="http://localhost/geograsim/App/admin.html" target="_blank">admin.html</a> anmelden (Username <b>Thomas</b> → PIN ist lokal vorausgefüllt).
</div>
<!-- ================= WELTKÜCHE (SIM) ================= -->
<section data-sec="Weltküche Simulation"></section>
<!-- ================= WELTKÜCHE (LEHRPLAN/ADMIN) ================= -->
<section data-sec="Weltküche Lehrplan &amp; Admin"></section>
<!-- ================= LEHRER-COCKPIT ================= -->
<section data-sec="Lehrer-Cockpit (teacher.html)"></section>
<!-- ================= TOURISMUSREGION ================= -->
<section data-sec="Tourismusregion"></section>
<!-- ================= LOGISTIK & PLATTFORM ================= -->
<section data-sec="Logistik &amp; Plattform"></section>
<!-- ================= ADMIN & AUTH ================= -->
<section data-sec="Admin &amp; Auth-Fundament"></section>
</div>
<footer>GeoGraSim · Test-Checkliste · lokal generiert 19.08.2026</footer>
<script>
// Alle Test-Items als Daten — daraus wird die Liste gebaut.
const BASE = 'http://localhost/geograsim/App/';
function u(path){ return BASE + path; }
const ITEMS = {
'Weltküche Simulation': [
{ id:'wk-artikel', t:'Korrekte Artikel für alle 70 Zutaten', tag:'live',
w:'der/die/das je Zutat („die Kartoffel", „der Pfeffer", „das Olivenöl") statt Platzhalter.',
url:'sims/weltkueche/game.html', steps:'Ein Gericht spielen, Zutat lösen/falsch klicken im Feedback-Satz steht der richtige Artikel.',
exp:'Nie mehr „der/die/das Kartoffel" immer „die Kartoffel" usw.' },
{ id:'wk-ukraine', t:'Verdrehter Herkunfts-Satz korrigiert (Ukraine-Fall)', tag:'live',
w:'Bei fernem Großproduzenten (z. B. Ukraine-Kartoffel im AT-Gericht) sachlich korrektes Feedback.',
url:'sims/weltkueche/game.html', steps:'Tiroler Gröstl → Kartoffel → ein fernes Land wie Ukraine anklicken.',
exp:'„…in unseren Handel kommt davon kaum etwas… nimmt man die Kartoffel aus Österreich oder einem Nachbarland." (nicht mehr „…stammt die Kartoffel von dort").' },
{ id:'wk-heimat', t:'Heimat-Banner neu formuliert', tag:'live',
w:'Erklärt „bevorzugt aus AT, aber AT deckt den Bedarf nicht allein".',
url:'sims/weltkueche/game.html', steps:'AT-Gericht, eine Heimat-Zutat lösen → grünes Banner lesen.',
exp:'„Zutaten werden zwar bevorzugt aus Österreich verwendet, aber Österreich allein deckt den Bedarf nicht ab darum zählt auch ein Nachbarland."' },
{ id:'wk-gravitation', t:'Herkunft per Handels-Gravitation (Nähe × Menge)', tag:'pending',
w:'Zweit-Lieferant = nächster großer Nachbar, nicht ferner Großproduzent.',
url:'sims/weltkueche/game.html', steps:'Ein AT-Gericht mit Eiern/Kartoffel spielen richtige Länder prüfen.',
exp:'AT-Eier → Österreich + Deutschland (NICHT Frankreich), obwohl FR mehr Eier erzeugt.' },
{ id:'wk-punkte', t:'Punktefix: „alle Falschen zuerst = 0"', tag:'pending',
w:'Strafe pro Fehlklick = 100 ÷ Anzahl Distraktoren.',
url:'sims/weltkueche/game.html', steps:'Bei einer Zutat ALLE falschen Länder zuerst anklicken, dann die richtigen.',
exp:'Ergebnis 0 Punkte. Banner zeigt die tatsächliche Strafe pro Fehlklick.' },
{ id:'wk-phase', t:'Live-State zeigt echten Fortschritt statt „forschen"', tag:'live',
w:'GGS_LIVE_STATE-Phase = „Gericht X · Zutat Y/Z".',
url:'teacher.html', steps:'Als Lehrer im Cockpit „Aktuell" ein Weltküche-Kind ansehen (oder Live-State im Sim prüfen).',
exp:'Phase/Info zeigt „Gericht 2 · Zutat 3/5" nicht dauerhaft „forschen".' },
{ id:'wk-submit', t:'Backend-Submit + Persistenz geschaffter Gerichte', tag:'live',
w:'Pro fertigem Gericht ein assessments-Eintrag; geschaffte Gerichte bleiben nach Reload.',
url:'sims/weltkueche/game.html', steps:'Ein Gericht abschließen, Seite neu laden.',
exp:'Bereits geschaffte Gerichte bleiben erkennbar; im Lehrer-Cockpit taucht das Ergebnis auf.' },
],
'Weltküche Lehrplan &amp; Admin': [
{ id:'wk-lehrplan', t:'Lehrplan-Bezug auf der Weltküche-Modulseite', tag:'live',
w:'Kompetenz „Welternährung, Konsum und globale Disparitäten" + Anker.',
url:'modul-weltkueche', steps:'Seite öffnen, zu „Lehrplan-Bezug" scrollen.',
exp:'Kompetenzen + Anker sichtbar (nicht mehr „Noch keine Kompetenzen zugeordnet").' },
{ id:'wk-filter', t:'Länder-Filter im Lehrplan-Bezug wirkt', tag:'live',
w:'Filter-Chips (Alle/AT/DE/CH/FL) blenden Fach-Anker wirklich um.',
url:'modul-weltkueche', steps:'Bei „Lehrplan-Bezug" auf Deutschland, dann Österreich klicken.',
exp:'Sichtbare Fach-Anker ändern sich je Land (vorher passierte nichts).' },
{ id:'wk-admin', t:'Admin-QA: Weltküche-Herkunftsübersicht (NEU)', tag:'pending',
w:'Pro Gericht × Zutat richtige Länder + Distraktoren; „nur Auffällige"-Filter.',
url:'admin-weltkueche.html', steps:'Als Admin einloggen, Seite öffnen. Suche/Kategorie testen, „nur Auffällige" aktivieren.',
exp:'30 Gerichte; Kartoffel/Eier → 🟢 AT+DE. „Nur Auffällige" zeigt ~5 Kandidaten zum Prüfen.' },
],
'Lehrer-Cockpit (teacher.html)': [
{ id:'tc-menu', t:'Menü „Live" → „Ergebnisse" mit Tag · Aktuell · Vergleich · Gesamt', tag:'pending',
w:'Umbenannt + 4. Unteransicht „Gesamt" (alte Ergebnis-Ansicht).',
url:'teacher.html', steps:'Als Lehrer einloggen, Klasse wählen, Tab „Ergebnisse".',
exp:'Vier Unteransichten; „Gesamt" zeigt die frühere Ergebnis-/Auswertungsansicht.' },
{ id:'tc-quali', t:'Qualitäts-Kennzahl (richtig ↔ falsch als Balken)', tag:'pending',
w:'Grafischer Balken (grün/rot) je Kind in Aktuell + Vergleich.',
url:'teacher.html', steps:'Ergebnisse → Aktuell/Vergleich bei aktivem Weltküche-Kind.',
exp:'Zweifarbiger Balken + %-Wert („blödeln vs. gut" sichtbar).' },
{ id:'tc-labels', t:'Klartext-Labels statt Jargon', tag:'pending',
w:'„Stark/Solide/Viele Fehler" statt „Kritisch/OK"; „Gerade dabei" statt „Phase".',
url:'teacher.html', steps:'Aktuell/Vergleich ansehen.',
exp:'Keine „Kritisch"-Bezeichnung mehr.' },
{ id:'tc-simlabels', t:'Sim-Bezeichner: Wort + 3-Buchstaben-Kürzel', tag:'pending',
w:'Kein „Log L1" mehr — enger Balken zeigt Kürzel (LOG), breiter das Wort.',
url:'teacher.html', steps:'Ergebnisse → Tag, Session-Balken ansehen.',
exp:'Balken tragen LOG/Logistik statt „Log L1".' },
{ id:'tc-logistik', t:'Logistik hat kuratierte Spalten (statt Roh-Dump)', tag:'pending',
w:'Stufe · Budget · Geliefert · Unterwegs · Offen · Verspätet · Fortschritt.',
url:'teacher.html', steps:'Ergebnisse → Aktuell mit aktivem Logistik-Kind.',
exp:'Sinnvolle Spalten keine simId/__activeMs/timestamp-Spalten mehr.' },
{ id:'tc-streifen', t:'Aktivitätsstreifen: Farbe je Sim, Höhe=Erfolg, Rahmen=Zustand', tag:'pending',
w:'Fertig = geschlossener Rahmen · läuft = rechts offen · Abbruch = gestrichelt + ✖.',
url:'teacher.html', steps:'Ergebnisse → Tag, mit Sessions eines Tages.',
exp:'Balken je Sim gefärbt, höher = besser; Rahmen/✖ zeigen den Zustand; Legende erklärt es.' },
{ id:'tc-assess', t:'assessment-Skip-Fix (keine leeren Ping-Zeilen mehr)', tag:'pending',
w:'„started/running"-Pings landen nicht mehr als leere Zeilen in Ergebnisse/Tag.',
url:'teacher.html', steps:'Nach etwas Spielzeit Ergebnisse/Tag ansehen.',
exp:'Nur echte Abschlüsse als Balken kein leeres Rauschen.' },
],
'Tourismusregion': [
{ id:'tr-start', t:'Startseite: keine Name/Klasscode-Felder, Klick auf Route startet', tag:'live',
w:'Felder entfernt; Klick auf eine Wegkarte startet sofort (kein „Partie starten").',
url:'sims/tourismusregion/game.html', steps:'Sim öffnen, eine Wegkarte anklicken.',
exp:'Keine Eingabefelder; Partie startet direkt bei Kartenklick.' },
{ id:'tr-ipad', t:'iPad-Responsive (Panels decken die Karte nicht mehr zu)', tag:'live',
w:'Panels mit Höhen-Deckel + eigene iPad-Landscape-Regeln. AM IPAD TESTEN!',
url:'sims/tourismusregion/game.html', steps:'Am echten iPad (Landscape) öffnen und ein Gebäude antippen.',
exp:'KPIs/Baumenü/Info-Panel überdecken die Karte nicht; Info-Panel scrollt statt zu überziehen.' },
{ id:'tr-lift', t:'Seilbahn hat eine Talstation (nicht mehr „transparent")', tag:'live',
w:'Talstation mit Dach + Umlenkrad unten am Lift.',
url:'sims/tourismusregion/game.html', steps:'Seilbahn bauen (Berg erschließen), auf die Talstation schauen.',
exp:'Solider Baukörper unten kein durchsichtiger Lift-Anfang.' },
{ id:'tr-save', t:'Abschluss: kein „Ergebnis speichern"-Button (Auto-Save)', tag:'live',
w:'Automatisches Speichern am Partie-Ende; Button entfernt.',
url:'sims/tourismusregion/game.html', steps:'Eine Partie zu Ende bringen.',
exp:'Hinweis „automatisch gespeichert", kein Speichern-Button.' },
{ id:'tr-pass', t:'Neue Karte „Passstraße"', tag:'live',
w:'Route bergauf (136 Punkte) + 41 Bauslots + Hintergrundbild.',
url:'sims/tourismusregion/game.html', steps:'Im Startbildschirm „Passstraße" wählen.',
exp:'Karte lädt, Route + Bauslots passen aufs Bild (optisch prüfen).' },
{ id:'tr-balance', t:'Balance: Umwelt beißt jetzt (Übertourismus)', tag:'pending',
w:'Zu viel Bauen + Verkehr senken die Umwelt spürbar; Umwelt-Malus wirkt.',
url:'sims/tourismusregion/game.html', steps:'Viel/dicht bauen und laufen lassen; Umwelt-KPI beobachten.',
exp:'Umwelt fällt deutlich (Richtung <45), umweltbewusste Gäste werden unzufriedener.' },
{ id:'tr-erklaer', t:'Schüler-Erklärung: Umwelt & Naturschutzgebiet', tag:'pending',
w:'Willkommenskarte + Warnkarte bei Umwelt<50 + klarere Naturschutz-Beschreibung.',
url:'sims/tourismusregion/game.html', steps:'Neu starten → Willkommenskarte lesen; Umwelt absacken lassen → Warnkarte.',
exp:'Karte erklärt Umwelt-Mechanik + Naturschutzgebiet; Warnkarte erscheint bei <50.' },
],
'Logistik &amp; Plattform': [
{ id:'lg-pause', t:'Logistik-Pause friert die Aktivzeit ein', tag:'pending',
w:'Bei Pause zählt die „Seit"-Zeit im Cockpit nicht weiter.',
url:'sims/logistik/game.html', steps:'Logistik spielen, ⏸ drücken, im Lehrer-Cockpit die Aktivzeit beobachten.',
exp:'Aktivzeit steht still, solange pausiert; läuft nach dem Fortsetzen weiter.' },
{ id:'pf-scripts', t:'Wrapper-Bug behoben (/teacher lud kein Script mehr)', tag:'live',
w:'Favicon wurde ins letzte </head> injiziert zerschoss ein JS-Popup-Script.',
url:'teacher', steps:'Die /teacher-Route (Wrapper) öffnen.',
exp:'Seite lädt sauber, keine „Invalid token"-Konsolenfehler. (teacher.html direkt lief immer.)' },
],
'Admin &amp; Auth-Fundament': [
{ id:'ad-login', t:'Lokaler Admin-Login ohne Mailversand (Dev-PIN)', tag:'pending',
w:'PIN wird lokal vorausgefüllt; Mailer-Fehler sperrt nicht mehr aus. Auf Prod bleibt 2FA-Mail Pflicht.',
url:'admin.html', steps:'Username Thomas + Passwort → „Anmelden".',
exp:'PIN-Feld ist vorausgefüllt („Dev: … vorausgefüllt"), „Bestätigen" → eingeloggt.' },
{ id:'auth-found', t:'Auth-Fundament JWT + Redis (schlafend, non-breaking)', tag:'pending',
w:'firebase/php-jwt + predis + Jwt/SessionStore + Session-Kanalisierung. AUTH_BACKEND=session.',
url:'', steps:'Kein direkter Test nötig — nur sicherstellen, dass Login/Admin normal funktionieren.',
exp:'Alles läuft wie bisher (das Fundament ist inaktiv, bis der JWT-Cutover kommt).' },
],
};
const listWrap = document.querySelector('.wrap');
document.querySelectorAll('section[data-sec]').forEach(sec => {
const name = sec.getAttribute('data-sec');
const items = ITEMS[name] || [];
let html = '<div class="sec-h">' + name + ' <span class="cnt">' + items.length + ' Punkte</span></div>';
items.forEach(it => {
const urlHtml = it.url ? '<a class="url" href="' + u(it.url) + '" target="_blank" rel="noopener">' + u(it.url) + '</a>' : '<span style="color:#b3ac9e">—</span>';
html += ''
+ '<div class="item" data-id="' + it.id + '">'
+ '<div class="item-top">'
+ '<input type="checkbox" data-check="' + it.id + '">'
+ '<div style="flex:1"><span class="item-title">' + it.t + '</span>'
+ '<span class="tag ' + it.tag + '">' + (it.tag==='live'?'live':'bereit') + '</span></div>'
+ '</div>'
+ '<div class="body">'
+ '<div class="row"><span class="k">Was</span> ' + it.w + '</div>'
+ '<div class="row"><span class="k">Öffnen</span> ' + urlHtml + '</div>'
+ '<div class="row"><span class="k">Testen</span> ' + it.steps + '</div>'
+ '<div class="exp">✓ Erwartet: ' + it.exp + '</div>'
+ '<textarea class="note" data-note="' + it.id + '" placeholder="Notiz / gefundene Abweichung…"></textarea>'
+ '</div>'
+ '</div>';
});
sec.innerHTML = html;
});
// Persistenz + Fortschritt
const checks = document.querySelectorAll('input[data-check]');
function refresh(){
let done = 0;
checks.forEach(c => { if(c.checked){ done++; c.closest('.item').classList.add('done'); } else c.closest('.item').classList.remove('done'); });
document.getElementById('pcount').textContent = done + ' / ' + checks.length;
document.getElementById('pbar').style.width = (checks.length? (done/checks.length*100):0) + '%';
}
checks.forEach(c => {
const key = 'chk_' + c.getAttribute('data-check');
c.checked = localStorage.getItem(key) === '1';
c.addEventListener('change', () => { localStorage.setItem(key, c.checked?'1':'0'); refresh(); });
});
document.querySelectorAll('textarea[data-note]').forEach(t => {
const key = 'note_' + t.getAttribute('data-note');
t.value = localStorage.getItem(key) || '';
t.addEventListener('input', () => localStorage.setItem(key, t.value));
});
refresh();
</script>
</body>
</html>
@@ -0,0 +1,8 @@
-- =====================================================================
-- Lizenz-Vertrieb — Nachtrag 2 (2026-08-26)
-- * bestellte vs. gelieferte Menge (Zugabe +10 %)
-- Additiv & non-breaking. (Mail-Config: siehe Nachtrag 3, app_settings.)
-- =====================================================================
ALTER TABLE license_batches
ADD COLUMN qty_ordered INT UNSIGNED NULL AFTER qty; -- bestellt (verrechnet); qty = geliefert inkl. Zugabe
@@ -0,0 +1,19 @@
-- =====================================================================
-- Lizenz-Vertrieb — Nachtrag 3 (2026-08-26): portable App-Settings
-- Eigene Key/Value-Tabelle fuer die Mail-Konfiguration (Provider/Absender/URL).
-- Ersetzt die Nutzung der lokalen Orphan-Tabelle platform_config_v2 (existiert
-- auf v3/Prod nicht). Additiv & non-breaking. MariaDB + MySQL 8 kompatibel.
-- =====================================================================
CREATE TABLE IF NOT EXISTS app_settings (
skey VARCHAR(64) NOT NULL PRIMARY KEY,
sval TEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
updated_by INT UNSIGNED NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO app_settings (skey, sval) VALUES
('mail_provider', 'server'),
('mail_from_email', 'support@geograsim.at'),
('mail_from_name', 'GeoGraSim'),
('mail_public_url', 'https://geograsim.at');
@@ -0,0 +1,103 @@
-- =====================================================================
-- Lizenz-Vergabe / Vertrieb (2026-08-26)
-- Additiv & non-breaking: bestehende `licenses`-Zeilen (batch_id NULL) und
-- die Flows generate/redeem/assign bleiben unveraendert funktionsfaehig.
-- Neu: Kunden, Chargen (Bestellungen), Mail-Archiv, Storno, Verlaengerung,
-- Brute-Force-Schutz + Audit-Log, laengere Codes, Charge-Einloeselink.
-- =====================================================================
-- 1) Kunden (Besteller) ------------------------------------------------
CREATE TABLE IF NOT EXISTS license_customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
customer_no VARCHAR(40) NULL, -- INTERNE Kundennummer (optional)
email VARCHAR(190) NULL,
name VARCHAR(190) NULL, -- Besteller / Verlag / Schule
note VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_customer_no (customer_no),
KEY idx_email (email),
KEY idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) Chargen (Bestellungen) -------------------------------------------
CREATE TABLE IF NOT EXISTS license_batches (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
customer_id INT UNSIGNED NULL,
order_no VARCHAR(120) NULL, -- EXTERNE Bestellnummer (Marktplatz/Ministerium), Freitext -> geht in die Mail
sales_type VARCHAR(30) NOT NULL DEFAULT 'privat', -- marktplatz | direktvertrieb | privat (spaeter erweiterbar)
qty INT UNSIGNED NOT NULL DEFAULT 0,
default_valid_from DATE NULL,
default_valid_until DATE NULL,
note VARCHAR(255) NULL,
redeem_token CHAR(40) NULL, -- Sammel-Einloeselink der ganzen Charge
created_by INT UNSIGNED NULL, -- admin_users.id
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
canceled_at TIMESTAMP NULL,
canceled_reason VARCHAR(255) NULL,
UNIQUE KEY uq_redeem_token (redeem_token),
KEY idx_customer (customer_id),
KEY idx_order (order_no),
KEY idx_type (sales_type),
KEY idx_created (created_at),
CONSTRAINT fk_batch_customer FOREIGN KEY (customer_id) REFERENCES license_customers(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3) licenses erweitern ------------------------------------------------
-- code auf 32 Zeichen verbreitern (laengere Codes), Vertriebsfelder ergaenzen.
ALTER TABLE licenses
MODIFY COLUMN code VARCHAR(32) NOT NULL,
ADD COLUMN batch_id INT UNSIGNED NULL AFTER school_year,
ADD COLUMN issued_at TIMESTAMP NULL AFTER redeemed_at,
ADD COLUMN valid_from DATE NULL AFTER issued_at,
ADD COLUMN valid_until DATE NULL AFTER valid_from,
ADD COLUMN canceled_at TIMESTAMP NULL AFTER valid_until,
ADD COLUMN canceled_reason VARCHAR(255) NULL AFTER canceled_at,
ADD KEY idx_batch (batch_id),
ADD KEY idx_valid_until (valid_until),
ADD KEY idx_issued (issued_at),
ADD CONSTRAINT fk_license_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL;
-- 4) Mail-Archiv / Outbox (unabhaengig vom IMAP-Postausgang) -----------
CREATE TABLE IF NOT EXISTS license_mails (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
batch_id INT UNSIGNED NULL,
to_email VARCHAR(190) NOT NULL,
subject VARCHAR(255) NOT NULL,
body_text MEDIUMTEXT NULL, -- gerenderter Klartext (kopierbar + Archiv)
body_html MEDIUMTEXT NULL,
provider VARCHAR(20) NOT NULL DEFAULT 'server', -- server | ses
status VARCHAR(12) NOT NULL DEFAULT 'queued', -- queued | sent | failed
error VARCHAR(255) NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP NULL,
KEY idx_batch (batch_id),
KEY idx_status (status),
CONSTRAINT fk_mail_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5) Brute-Force-Schutz + Audit beim Einloesen -------------------------
CREATE TABLE IF NOT EXISTS license_redeem_attempts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip VARBINARY(16) NULL, -- INET6_ATON(...)
teacher_id INT UNSIGNED NULL,
code_tried VARCHAR(32) NULL,
ok TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_ip_time (ip, created_at),
KEY idx_teacher_time (teacher_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6) Audit-Log fuer Chargen/Codes -------------------------------------
CREATE TABLE IF NOT EXISTS license_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
batch_id INT UNSIGNED NULL,
license_id INT UNSIGNED NULL,
event VARCHAR(30) NOT NULL, -- issued|extended|canceled|mailed|redeemed
detail VARCHAR(255) NULL,
admin_id INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_batch (batch_id),
KEY idx_license (license_id),
KEY idx_event (event)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,36 @@
-- ============================================================
-- Fehlende Heli-Wegpunkte in geo_waypoints
-- ------------------------------------------------------------
-- Gefunden 2026-08-23: 5 Missions-Wegpunkte fehlten in der DB.
-- Folge: Missionen hingen bei der Routenplanung nach dem vorletzten
-- Wegpunkt (Route zeigte auf undefined). Betroffen u.a.:
-- M5 "Seerettung Bregenz" -> bodensee_bregenz
-- M7 (Kufstein) -> kaiser_kufstein
-- Weitere: felswand_raetikon, schlucht_mellau, maria_saal.
--
-- Auf v3 (geograsim_v3) bereits eingespielt.
-- Die LIVE-DB `geograsim` ist ein Klon und hat DIESELBE Luecke ->
-- vor/mit dem naechsten Heli-Prod-Deploy hier ausfuehren:
--
-- docker exec -i webstack-mysql mysql -u root -p'...' geograsim < heli_waypoints_fix_PROD.sql
-- ============================================================
INSERT IGNORE INTO geo_waypoints (wp_key,name,lat,lon,region,wp_type) VALUES
('bodensee_bregenz','Schwimmer Bodensee bei Bregenz',47.51000,9.72000,'vorarlberg','target'),
('felswand_raetikon','Kletterunfall Raetikon-Felswand',47.10000,9.70000,'vorarlberg','target'),
('kaiser_kufstein','Wanderunfall Kaisergebirge',47.56000,12.25000,'tirol','target'),
('schlucht_mellau','Canyoning-Unfall Schlucht Mellau',47.34000,9.86000,'vorarlberg','target'),
('maria_saal','Maria Saal',46.67972,14.34361,'kaernten','city');
-- M10 "Verkehrsunfall A2 Baden": DB-Wert lag falsch (16.2445 = zwischen Stadt und
-- Autobahn). Zurueck auf die A2-Linie (Original-Snapshot-Wert, oestlich von Baden).
UPDATE geo_waypoints SET lat=47.99200, lon=16.28550, name='Unfall A2 Baden' WHERE wp_key='verkehr_a2_baden';
-- M17 "LKW-Unfall A10 Tauern": Zielpunkt lag ~7 km noerdlich im Gebirge (47.265/13.477)
-- statt am Tauerntunnel-Nordportal (Flachauwinkl-Seite).
UPDATE geo_waypoints SET lat=47.22500, lon=13.42400, name='Unfall A10 Tauerntunnel (Nordportal)' WHERE wp_key='verkehr_a10_tauern';
-- ============================================================
-- Weitere v3-DB-Aenderungen fuer den Prod-Cutover
-- ============================================================
-- "Stadt & Raumplanung" vorlaeufig in Vorbereitung (nicht spielbar, coming_soon).
UPDATE module_info SET status='geplant' WHERE module_id='stadt';
+825
View File
@@ -0,0 +1,825 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Lehrer View Parameter-Überblick Schülerleistung</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh;color:#2a2a2a}
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem;min-width:220px}
.topbar h1 img{height:26px}
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;white-space:nowrap}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
.wrap{max-width:1240px;margin:1rem auto 4rem;padding:0 1rem}
.intro{background:#fff;border-radius:12px;padding:1.1rem 1.3rem;box-shadow:0 1px 4px rgba(0,0,0,.04);margin-bottom:1.2rem}
.intro h2{font-size:1.05rem;color:#1f4b37;font-weight:800;margin-bottom:.4rem}
.intro p{font-size:.82rem;line-height:1.5;color:#4a4a4a;margin-bottom:.5rem}
.intro .wiring{display:flex;gap:.5rem;flex-wrap:wrap;margin:.6rem 0 .2rem}
.wire-chip{background:#eef3f5;border:1px solid #d5e2e7;border-radius:8px;padding:.35rem .7rem;font-size:.72rem;color:#3a5a66;font-weight:600}
.wire-chip b{color:#1f4b37}
.legend{display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.7rem;padding-top:.7rem;border-top:1px solid rgba(0,0,0,.06);font-size:.76rem;color:#4a4a4a}
.legend span{display:inline-flex;align-items:center;gap:.4rem}
.sw{width:16px;height:16px;border-radius:4px;display:inline-block;border:1px solid rgba(0,0,0,.12)}
.toc{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1.4rem}
.toc a{font-size:.72rem;text-decoration:none;background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:20px;padding:.3rem .7rem;color:#3a5a66;font-weight:600}
.toc a:hover{background:#dae8ec}
.sim{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);margin-bottom:1.6rem;overflow:hidden;scroll-margin-top:70px}
.sim-head{display:flex;align-items:baseline;gap:.6rem;padding:.9rem 1.1rem .5rem;flex-wrap:wrap}
.sim-head .emoji{font-size:1.5rem;line-height:1}
.sim-head h3{font-size:1.1rem;font-weight:800;color:#1f4b37}
.sim-head .sid{font-size:.68rem;color:#8a8a8a;font-family:'Courier New',monospace}
.sim-head .count{margin-left:auto;font-size:.7rem;color:#5a8a5e;font-weight:700}
.tbl-scroll{overflow-x:auto;padding:0 1.1rem}
table.live{width:100%;border-collapse:collapse;font-size:.74rem}
table.live th{padding:.4rem .5rem;white-space:nowrap;vertical-align:bottom;border-bottom:2px solid rgba(0,0,0,.08)}
table.live th .key{display:block;font-size:.58rem;color:#9a9a9a;font-weight:500;font-family:'Courier New',monospace;margin-top:2px}
table.live th.green{background:#d9eddc}
table.live th.grey{background:#f7f6f3}
table.live td{padding:.4rem .5rem;font-variant-numeric:tabular-nums;vertical-align:top;border-bottom:1px solid rgba(0,0,0,.04)}
table.live td.green{background:#eef7f0}
.tag{display:inline-block;font-size:.55rem;font-weight:700;border-radius:4px;padding:1px 5px;margin-top:3px;letter-spacing:.03em}
.tag.g{background:#5a8a5e;color:#fff}
.tag.l{background:#e3e0d8;color:#7a7568}
.note{margin:.6rem 1.1rem 1rem;background:#faf7f0;border:1px solid #ece3cf;border-left:4px solid #d8b04a;border-radius:8px;padding:.6rem .85rem;font-size:.76rem;color:#6a5a2a}
.note b{color:#8a6a1a}
.note ul{margin:.35rem 0 0 1.1rem;padding:0}
.note li{margin:.15rem 0;line-height:1.4}
.note code{background:#efe8d5;border-radius:3px;padding:0 4px;font-size:.72rem;color:#7a5a1a}
.allgreen{margin:.6rem 1.1rem 1rem;background:#f0f7f1;border:1px solid #cfe6d2;border-left:4px solid #5a8a5e;border-radius:8px;padding:.55rem .85rem;font-size:.76rem;color:#3a6b3e}
.scorebox{margin:.2rem 1.1rem 1rem;background:#eef3f7;border:1px solid #d3e0e8;border-left:4px solid #4a7c8a;border-radius:8px;padding:.7rem .9rem;font-size:.78rem;color:#2f4a58}
.scorebox h4{font-size:.8rem;color:#2f5866;margin-bottom:.45rem;display:flex;align-items:center;gap:.4rem}
.scorebox .srow{display:grid;grid-template-columns:78px 1fr;gap:.5rem;margin:.28rem 0;align-items:start}
.scorebox .srow .lab{font-weight:700;color:#3a6072}
.scorebox code{background:#dde8ee;border-radius:3px;padding:0 4px;font-size:.72rem;color:#294a58;font-family:'Courier New',monospace}
.scorebox .flag{display:block;margin-top:.5rem;font-size:.73rem;color:#8a3a2a;background:#fbeee9;border:1px solid #ecc9bd;border-radius:6px;padding:.35rem .55rem}
.scorebox .flag b{color:#a5341f}
.gbox{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);padding:1.1rem 1.3rem;margin-bottom:1.2rem;border-left:4px solid #4a7c8a}
.gbox h2{font-size:1rem;color:#1f4b37;font-weight:800;margin-bottom:.5rem}
.gbox p{font-size:.82rem;line-height:1.5;color:#4a4a4a;margin-bottom:.5rem}
.gbox code{background:#eef3f5;border-radius:3px;padding:0 4px;font-size:.75rem;color:#294a58;font-family:'Courier New',monospace}
.gbox .rec{background:#f0f7f1;border:1px solid #cfe6d2;border-radius:8px;padding:.6rem .8rem;font-size:.8rem;color:#2f5e39;margin-top:.5rem}
.foot{font-size:.72rem;color:#8a8a8a;text-align:center;margin-top:2rem}
</style>
</head>
<body>
<div class="topbar">
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Lehrer View · Parameter-Überblick Schülerleistung</h1>
<a class="btn-sm" href="admin-sitemap.html">← Admin-Übersicht</a>
<a class="btn-sm" href="admin-modules.html">Module</a>
<a class="btn-sm" href="teacher.html">Lehreransicht</a>
</div>
<div class="wrap">
<div class="intro">
<h2>Wozu diese Seite?</h2>
<p>Referenz für die <b>Verkabelung</b> jeder Simulation. Pro Sim siehst du die Tabelle aus dem Lehrer-Menüpunkt
<b>Ergebnisse&nbsp;/&nbsp;Aktuell</b> — dort erscheint jede Schüler:in mit den relevanten Leistungsdaten der laufenden Sim.
Die Tabellen hier sind <b>1:1 dieselbe Render-Logik</b> (<code>LIVE_PRIMARY_FIELDS</code> + Formatter aus <code>teacher.html</code>), nur mit <b>Testdaten</b>, damit du sie vergleichen kannst.</p>
<div class="wiring">
<span class="wire-chip"><b>Spiel</b><code>window.GGS_LIVE_STATE()</code> liefert die State-Keys</span>
<span class="wire-chip"><b>Lehrer-Backend</b> → Spalte + Formatter je Key</span>
<span class="wire-chip"><b>Schülerinfo</b> → Name · Avatar · Spielzeit</span>
</div>
<p style="font-size:.78rem;margin-top:.5rem">Unter jedem Spaltentitel steht der <b>State-Key</b> (der Parameter, den das Spiel senden muss). So ist ablesbar, welcher Wert aus dem Spiel welche Spalte speist.</p>
<div class="legend">
<span><span class="sw" style="background:#d9eddc"></span> <b>Grün = nach Abschluss noch relevant</b> — Ergebnis, Fortschritt (auch bei Abbruch) oder Identität der Aufgabe. Sollte in die Ergebnis-/Verlaufsansicht übernommen werden.</span>
<span><span class="sw" style="background:#f7f6f3"></span> <b>Grau = nur live</b> — momentaner Zustand, der sich laufend ändert oder zurücksetzt. Nach Abschluss ohne Aussagekraft.</span>
</div>
</div>
<div class="gbox">
<h2>So entstehen Prozent &amp; Sterne — und warum das heute uneinheitlich ist</h2>
<p><b>Der Zeilen-Prozentwert</b> in „Ergebnisse/Aktuell" entsteht in <code>live.php · extractScore</code>: liefert die Sim einen eigenen <b>0100-Score</b>, wird der genommen; sonst aus den Sternen: <code>% = Sterne / 5 × 100</code>.</p>
<p><b>Die Sterne</b> berechnet jede App am Ende selbst — aus unterschiedlichen Kennzahlen und Schwellen (je Sim unten dokumentiert). Deshalb bedeuten 4★ heute nicht überall dasselbe.</p>
<p><b>Uneinheitlichkeiten:</b> Busfahrt · Fluggesellschaft · Kofferdetektiv liefern nur <b>03 Sterne</b> statt 05. Busfahrt/Fluggesellschaft senden als <code>score</code> rohe Punkte (nicht 0100 → Zeilen-% verzerrt). Weltküche wertet <b>pro Gericht</b> statt pro Durchgang. <b>Logistik überträgt weder Sterne noch Score</b>. Ein echter 0100-Live-Score existiert nur bei Klima, Fluss, Heli, Kofferdetektiv, Tourismusregion (abgeleitet Tourismustal).</p>
<div class="rec"><b>Beschlossen:</b> <b>Sterne entfallen.</b> Jede App liefert genau <b>eine Funktion → Leistungs-% (0100)</b> als Wahrheit, überall gezeigt als Prozent + Balken in der 5-stufigen Farbskala. Auch Fördern / Begleiten / Läuft gut / Fordern leiten sich aus diesem % ab.</div>
</div>
<div id="preview"></div>
<div class="toc" id="toc"></div>
<div id="content"></div>
<p class="foot">Alle Werte sind Beispieldaten zur Ansicht. Render-Logik gespiegelt aus <code>teacher.html · renderLive()</code>. Grün/Grau ist Atlas' Vorschlag — bitte prüfen &amp; korrigieren.</p>
</div>
<script>
/* ============================================================
1) Formatter & Helfer — VERBATIM aus teacher.html gespiegelt,
damit die Zellen exakt wie in der echten Ansicht rendern.
============================================================ */
function esc(s){ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
function _fmtSeconds(s){ s=Math.max(0,Math.round(s||0)); var mm=Math.floor(s/60), ss=s%60; return mm+':'+(ss<10?'0':'')+ss; }
function _fmtSince(sec){ return sec<60 ? sec+' s' : Math.floor(sec/60)+' min'; }
var LIVE_VALUE_LABELS = {
screen:{ start:'Auswahl', play:'Spielt', end:'Endscreen' },
difficulty:{ easy:'Einfach', expert:'Profi' },
stepType:{ pick:'Karte wählen', sentence:'Lückentext', multichoice:'4 Antworten', animation:'Animation' },
health:{
good:'<span style="color:#5a8a5e">● gut</span>',
ok:'<span style="color:#c9913a">● ok</span>',
struggling:'<span style="color:#c07a6b">● stockt</span>',
struggle:'<span style="color:#c07a6b">● stockt</span>',
},
};
function _liveBar(val, min, max, goodWhen, dec){
if (val==null || isNaN(val)) return '<span style="color:#9a9a9a">—</span>';
var span=(max-min)||1;
var pct=Math.max(0,Math.min(100,((val-min)/span)*100));
var c;
if (goodWhen==='low'){ c=pct<=33?'#5a8a5e':pct<=66?'#c9913a':'#c07a6b'; }
else if (goodWhen==='mid'){ c='#7a7a7a'; }
else { c=pct>=60?'#5a8a5e':pct>=30?'#c9913a':'#c07a6b'; }
var disp=(dec!=null)?Number(val).toFixed(dec):Math.round(val);
return '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden">'
+ '<span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span></span>'
+ '<span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+disp+'</span></span>';
}
function _liveBarColor(pct, goodWhen){
pct=Math.max(0,Math.min(100,pct||0));
if (goodWhen==='low') return pct<=33?'#5a8a5e':pct<=66?'#c9913a':'#c07a6b';
if (goodWhen==='mid') return '#9a9a9a';
return pct>=60?'#5a8a5e':pct>=30?'#c9913a':'#c07a6b';
}
function _liveMiniBar(pct, color){
pct=Math.max(0,Math.min(100,pct||0));
return '<div style="margin-top:3px;height:3px;border-radius:99px;background:rgba(0,0,0,.06);overflow:hidden">'
+ '<div style="height:100%;width:'+pct.toFixed(0)+'%;background:'+color+'"></div></div>';
}
function _liveNeedsHelp(simId, s){
if (!s || typeof s!=='object') return false;
switch (simId){
case 'heli': return (s.planAttempts||0) >= 10;
case 'klima': case 'klima-3d': return (s.budget||0) < 0;
case 'weltkueche': return (s.mistakesInDish||0) >= 12;
case 'farmer': return (s.cash==null?1000:s.cash) < 0 || ((s.attempts||0) >= 6 && s.hit_rate != null && s.hit_rate < 30);
case 'logistik': return (s.balance==null?1000:s.balance) < 0;
case 'eu-werkstatt': return (s.attemptsThisStep||0) >= 6 || (s.reshufflesThisStep||0) >= 5;
case 'energiemanager': return (s.blackouts||0) >= 2 || (s.phase==='done' && (s.balanced||0) < 4);
case 'tourismustal': return (s.money==null?1:s.money) < 0 || (s.lossStreak||0) >= 8;
case 'tourismusregion': return (s.budget==null?1:s.budget) < -400 || (s.env==null?100:s.env) < 30;
}
return false;
}
function _liveMood(simId, s){
if (!s) return null;
if (s.health) return ({good:'good',ok:'good',warning:'mid',bad:'bad',critical:'bad'})[s.health] || 'mid';
if (_liveNeedsHelp(simId, s)) return 'bad';
switch (simId){
case 'weltkueche': return (s.mistakesInDish||0)<=2?'good':(s.mistakesInDish>=8?'bad':'mid');
case 'farmer': return (s.hit_rate==null)?'mid':(s.hit_rate>=60?'good':s.hit_rate>=30?'mid':'bad');
case 'klima': case 'klima-3d': return (typeof s.score==='number')?(s.score>=65?'good':s.score>=40?'mid':'bad'):(((s.floodedPct==null?100:s.floodedPct)<25 && (s.budget||0)>=0)?'good':'mid');
case 'busfahrt': case 'fluggesellschaft': return (s.budget==null?0:s.budget)>=5000?'good':(s.budget<0?'bad':'mid');
case 'tourismustal': return (s.money==null?0:s.money)>=4000?'good':(s.money<0?'bad':'mid');
case 'heli': return (s.totalStars||0)>=4?'good':'mid';
case 'fluss': case 'kofferdetektiv': case 'sonnensystem': return s.score!=null?(s.score>=70?'good':s.score>=40?'mid':'bad'):'mid';
}
return 'mid';
}
function _liveMoodBadge(simId, s){
var m=_liveMood(simId,s);
if (!m) return '<span style="color:#8a8a8a">—</span>';
var map={ good:['#5a8a5e','Stark'], mid:['#c9913a','Solide'], bad:['#c07a6b','Viele Fehler'] };
var x=map[m]||map.mid;
return '<span style="display:inline-flex;align-items:center;gap:.3rem">'
+ '<span style="width:8px;height:8px;border-radius:50%;background:'+x[0]+';display:inline-block"></span>'
+ '<span style="font-size:.66rem;color:'+x[0]+';font-weight:700">'+x[1]+'</span></span>';
}
function _liveClicks(simId, s){
if (!s) return null;
if (typeof s.correctClicks==='number' || typeof s.wrongClicks==='number')
return { correct:s.correctClicks||0, wrong:s.wrongClicks||0 };
switch (simId){
case 'heli': if (s.planTotal) return { correct:s.planCorrect||0, wrong:Math.max(0,s.planTotal-(s.planCorrect||0)) }; break;
}
return null;
}
function _liveQualityBar(correct, wrong){
correct=Math.max(0,correct||0); wrong=Math.max(0,wrong||0);
var tot=correct+wrong;
if (!tot) return '<span style="color:#8a8a8a">—</span>';
var gp=Math.round(correct/tot*100), rp=100-gp;
var c=gp>=80?'#5a8a5e':gp>=55?'#c9913a':'#c07a6b';
return '<span title="'+correct+' richtig · '+wrong+' Fehler ('+gp+' % richtig)" style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-flex;width:56px;height:9px;border-radius:99px;overflow:hidden;background:#ece8dc">'
+ '<span style="width:'+gp+'%;background:#5a8a5e"></span><span style="width:'+rp+'%;background:#d98b7a"></span></span>'
+ '<span style="font-size:.72rem;font-weight:700;color:'+c+';min-width:2.3em;text-align:right">'+gp+' %</span></span>';
}
function _liveQualityFromState(simId, s){
var cw=_liveClicks(simId,s);
return cw ? _liveQualityBar(cw.correct,cw.wrong) : '<span style="color:#8a8a8a">—</span>';
}
function _liveFmt(v, unit, fieldDef, row){
if (fieldDef && typeof fieldDef.fmt==='function'){
try { var f=fieldDef.fmt(v,row); return (f===undefined||f===null||f==='')?'—':f; } catch(_){}
}
if (v===undefined||v===null||v==='') return '—';
if (typeof v==='object') return JSON.stringify(v).slice(0,30);
if (typeof v==='boolean') return v?'✓':'';
return v + (unit?' <span style="color:#8a8a8a;font-weight:400">'+unit+'</span>':'');
}
/* ============================================================
2) Spaltenschema — VERBATIM aus teacher.html (LIVE_PRIMARY_FIELDS)
============================================================ */
var LIVE_PRIMARY_FIELDS = {
farmer: [
{k:'year', label:'Jahr', bar:{kind:'abs',max:10,good:'mid'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
{k:'market_de', label:'Markt', align:'left', fmt:function(v){ return v ? '🌍 '+esc(v) : '<span style="color:#8a8a8a">—</span>'; }},
{k:'cash', label:'Konto', bar:{kind:'rel',good:'signed'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; var color=v<0?'#c0392b':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
{k:'hit_rate', label:'Trefferquote', unit:'%', bar:{kind:'abs',max:100,good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'crops_used', label:'Sorten', bar:{kind:'abs',max:10,good:'high'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
{k:'countries', label:'Länder', bar:{kind:'rel',good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'fields', label:'Felder', bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'attempts', label:'Versuche',bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'co2_kg', label:'CO₂', unit:'kg', bar:{kind:'rel',good:'low'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; return '<span style="font-variant-numeric:tabular-nums">'+Number(Math.round(v)).toLocaleString('de-AT')+'</span>'; }},
{k:'diseases', label:'Krankheiten', align:'center', bar:{kind:'abs',max:5,good:'low'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; var color=(v>0)?'#c07a6b':'#8a8a8a'; return '<span style="color:'+color+';font-weight:'+(v>0?'700':'400')+'">'+v+'</span>'; }},
],
'tourismusregion': [
{k:'mapId', label:'Karte', align:'left', fmt:function(v){ return {talschleife:'🏞 Talschleife', seerunde:'🚣 Seerunde', serpentinen:'⛰ Serpentinen', kreuzung:'✚ Kreuzung', passstrasse:'🛣 Passstraße'}[v] || v || '—'; }},
{k:'wave', label:'Welle', fmt:function(v, row){ var s=row.state||{}; if (v===undefined) return '—'; var status=s.waveStatus==='pause'?' <span style="color:#8a8a8a">⏸ Baupause</span>':''; return v+'<span style="color:#8a8a8a"> / '+(s.waveTotal||8)+'</span>'+status; }},
{k:'season', label:'Saison', align:'center'},
{k:'budget', label:'Budget', fmt:function(v){ if (v===undefined) return '—'; var color=v<0?'#c0392b':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
{k:'revenue', label:'Wertsch.', unit:'€'},
{k:'avgStars', label:'Ø ★', fmt:function(v){ if (v===undefined) return '—'; var color=v>=3.5?'#5a8a5e':v>=2.5?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v.toFixed(2)+'</span>'; }},
{k:'env', label:'Umwelt', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'visitors', label:'Gäste'},
{k:'goalsDone', label:'Ziele', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||!s.goalsTotal) return '—'; var color=v===s.goalsTotal?'#5a8a5e':v>=2?'#c9913a':'#8a8a8a'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>'; }},
],
'tourismustal': [
{k:'scene', label:'Szene', align:'left', fmt:function(v){ return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—'; }},
{k:'season', label:'Saison', align:'center'},
{k:'year', label:'Jahr'},
{k:'money', label:'Konto', fmt:function(v){ if (v===undefined) return '—'; var color=v<0?'#c0392b':v<4000?'#c9913a':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
{k:'guestsToday', label:'Gäste'},
{k:'satisfaction', label:'Zufr.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'nature', label:'Natur', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'goalsDone', label:'Ziele', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||!s.goalsTotal) return '—'; var color=v===s.goalsTotal?'#5a8a5e':v>=2?'#c9913a':'#8a8a8a'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>'; }},
{k:'buildings', label:'Geb.'},
{k:'bottleneck', label:'Engpass', align:'left', fmt:function(v){ return v ? '<span style="color:#c07a6b">⚠️ '+v+'</span>' : '<span style="color:#8a8a8a">—</span>'; }},
],
klima: [
{k:'level', label:'Stufe'},
{k:'year', label:'Jahr'},
{k:'co2', label:'CO₂', unit:'ppm', fmt:function(v){ return _liveBar(v,280,700,'low'); }},
{k:'temp', label:'Temp', unit:'°C', fmt:function(v){ return _liveBar(v,14,20,'low',1); }},
{k:'floodedPct', label:'Geflutet', unit:'%', fmt:function(v){ if (v==null||isNaN(v)) return '<span style="color:#9a9a9a">—</span>'; var c=v<25?'#5a8a5e':v<=40?'#c9913a':'#c07a6b'; var pct=Math.max(0,Math.min(100,v)); return '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums"><span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden"><span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span></span><span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+Math.round(v)+'</span></span>'; }},
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ return _liveBar(v,-500,3000,'high'); }},
{k:'population', label:'Bev.', fmt:function(v, row){ var sp=(((row&&row.state)||{}).level>=3)?7000:6000; return _liveBar(v,sp*0.3,sp*1.6,'high'); }},
{k:'measures', label:'Maßn.'},
{k:'speed', label:'Speed', unit:'×'},
],
'sonnensystem': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'screen', label:'Modus', fmt:function(v){ return v==='play'?'📚 Aufgabe':'🚀 Erkunden'; }},
{k:'taskTitle', label:'Aufgabe', align:'left', fmt:function(v, row){ var s=row.state||{}; if (s.screen!=='play'||!v) return '<span style="color:#8a8a8a">—</span>'; return v+' <span style="color:#8a8a8a;font-weight:400">('+s.taskIndex+'/'+s.taskTotal+')</span>'; }},
{k:'completed', label:'Gelöst', fmt:function(v, row){ var s=row.state||{}; return s.taskTotal ? v+' <span style="color:#8a8a8a">/ '+s.taskTotal+'</span>' : '—'; }},
{k:'progressPct', label:'Quote', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'simDate', label:'Sim-Datum', align:'center'},
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){ var s=row.state||{}; if (v===undefined) return '—'; return v+' <span style="color:#8a8a8a">/ '+(s.achievementsTotal||6)+'</span>'; }},
],
'energiemanager': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'setTitle', label:'Set', align:'left', fmt:function(v, row){ var s=row.state||{}; var icon=s.setIcon?'<span style="margin-right:.25rem">'+s.setIcon+'</span>':''; var idxBadge=(s.setIndex&&s.setTotal)?' <span style="color:#8a8a8a;font-weight:400">'+s.setIndex+'/'+s.setTotal+'</span>':''; return v ? icon+v+idxBadge : '—'; }},
{k:'day', label:'Tag', fmt:function(v, row){ var s=row.state||{}; return s.dayTotal ? v+' <span style="color:#8a8a8a">/ '+s.dayTotal+'</span>' : '—'; }},
{k:'block', label:'Block', fmt:function(v, row){ var s=row.state||{}; if (s.phase==='blackout') return '<span style="color:#d8453a;font-weight:700">🔌 neu planen</span>'; if (s.phase==='plan') return '<span style="color:#8a8a8a">plant</span>'; return s.blockTotal ? (v+1)+' <span style="color:#8a8a8a">/ '+s.blockTotal+'</span>' : '—'; }},
{k:'blackouts', label:'🔌', align:'center', fmt:function(v){ if (!v) return '<span style="color:#8a8a8a"></span>'; var c=v>=2?'#d8453a':'#c9913a'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
{k:'balanced', label:'Tag ausg.', fmt:function(v, row){ var s=row.state||{}; if (s.phase!=='done') return '<span style="color:#8a8a8a">—</span>'; var color=v===s.blockTotal?'#5a8a5e':v>=4?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.blockTotal+'</span>'; }},
{k:'setBlocksBalanced', label:'Set ausg.', fmt:function(v, row){ var s=row.state||{}; var max=(s.setTotal&&s.dayTotal&&s.blockTotal)?s.dayTotal*s.blockTotal:0; if (!max) return '—'; var color=v>=max*0.87?'#5a8a5e':v>=max*0.5?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+max+'</span>'; }},
{k:'upperLake', label:'⬆ See', unit:'GWh'},
{k:'lowerLake', label:'⬇ See', unit:'GWh'},
],
'eu-werkstatt': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'scenarioTitle', label:'Szenario', align:'left', fmt:function(v, row){ var s=row.state||{}; var icon=s.scenarioIcon?'<span style="margin-right:.25rem">'+s.scenarioIcon+'</span>':''; return v ? icon+v : '<span style="color:#8a8a8a">Szenario-Auswahl</span>'; }},
{k:'step', label:'Schritt', fmt:function(v, row){ var s=row.state||{}; if (!s.stepsTotal) return '—'; return v+' <span style="color:#8a8a8a">/ '+s.stepsTotal+'</span>'; }},
{k:'difficulty', label:'Modus', fmt:function(v){ return LIVE_VALUE_LABELS.difficulty[v] || '—'; }},
{k:'firstTryHits', label:'Treffer'},
{k:'muelleimerCount', label:'Mülleimer', fmt:function(v){ if (v===undefined||v===null) return '—'; var color=v===0?'#5a8a5e':v>=3?'#c07a6b':'#c9913a'; return '<span style="color:'+color+';font-weight:700">'+v+'</span>'; }},
{k:'reshuffles', label:'🔄 Probier', align:'center', fmt:function(v){ if (!v) return '<span style="color:#8a8a8a"></span>'; var color=v>=5?'#c07a6b':v>=2?'#c9913a':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+v+'</span>'; }},
{k:'secondsElapsed', label:'Dauer', fmt:function(v){ return v ? _fmtSeconds(v) : '—'; }},
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||v===null) return '—'; return v+' <span style="color:#8a8a8a">/ '+(s.achievementsTotal||6)+'</span>'; }},
],
'weltkueche': [
{k:'dishName', label:'Gericht', align:'left', fmt:function(v){ return v ? '<b>'+esc(v)+'</b>' : '<span style="color:#8a8a8a">Auswahl</span>'; }},
{k:'completed', label:'Gerichte', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.completedTotal||0; if (!t) return '<span style="color:#8a8a8a">—</span>'; var done=v||0; var c=done>=t?'#5a8a5e':'#3d5165'; return '<b style="color:'+c+'">'+done+'</b><span style="color:#8a8a8a"> / '+t+'</span>'; }},
{k:'ingSolved', label:'Zutaten akt.', fmt:function(v, row){ var s=row.state||{}; var t=s.ingTotal||0; if (!s.dishName||!t) return '<span style="color:#8a8a8a">—</span>'; var done=v||0; var pct=done/t; var c=pct>=1?'#5a8a5e':pct>=.5?'#c9913a':'#8a7a4a'; return '<span style="color:'+c+';font-weight:700">'+done+' <span style="color:#8a8a8a;font-weight:400">/ '+t+'</span></span>'; }},
{k:'__quality', label:'Qualität', align:'left', fmt:function(v, row){ return _liveQualityFromState(row.module_id, row.state); }},
{k:'mistakesInDish', label:'Fehler (akt.)', align:'center', fmt:function(v){ var n=v||0; var c=n>=12?'#c07a6b':n>=5?'#c9913a':'#5a8a5e'; return '<span style="color:'+c+';font-weight:700">'+n+'</span>'; }},
{k:'__lage', label:'Lage', align:'center', fmt:function(v, row){ return _liveMoodBadge(row.module_id, row.state); }},
],
'busfahrt': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'orderIndex', label:'Auftrag', fmt:function(v, row){ var s=row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c=v>=5000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
{k:'wonOrders', label:'Aufträge ✓'},
{k:'lastCityTitle', label:'Letzte Stadt', align:'left'},
{k:'lastMargin', label:'Letzte Marge', unit:'€'},
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t=s.orderIndex||0; return t ? v+' / '+t : '—'; }},
],
'fluggesellschaft': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'orderIndex', label:'Flug', fmt:function(v, row){ var s=row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c=v>=8000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
{k:'wonOrders', label:'Flüge ✓'},
{k:'co2Kg', label:'🌍 CO₂', fmt:function(v){ return v==null ? '—' : (v/1000).toFixed(1)+' t'; }},
{k:'lastCityTitle', label:'Letztes Ziel', align:'left'},
{k:'lastTzDiff', label:'Zeitzone', fmt:function(v){ if (v==null) return '—'; return (v>0?'+':'')+v+' h'; }},
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t=s.orderIndex||0; return t ? v+' / '+t : '—'; }},
],
'heli': [
{k:'phase', label:'Phase', align:'center', fmt:function(v){ var m={mission:'📋 Auftragswahl', plan:'🗺️ Routenplan', briefing:'📋 Briefing', start:'🛫 Start', flight:'✈️ Flug', approach:'🎯 Anflug', landing:'🛬 Landung', results:'🏁 Ende'}; return m[v] || (v ? v : '—'); }},
{k:'missionId', label:'Auftrag', align:'left', fmt:function(v, row){ var s=row.state||{}; var title=s.missionTitle||''; return v ? '<b>'+v+'</b>'+(title?' <span style="color:#8a8a8a;font-weight:400">'+title+'</span>':'') : '—'; }},
{k:'planIndex', label:'🗺️ Wegpunkt', fmt:function(v, row){ var s=row.state||{}; var t=s.planTotal||0; if (!t) return '—'; var done=(v||0); var pct=done/t; var c=pct>=1?'#5a8a5e':pct>=.5?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+done+' / '+t+'</span>'; }},
{k:'planCorrect', label:'🎯 Treffer', fmt:function(v, row){ var s=row.state||{}; var t=s.planTotal||0; if (!t) return '—'; var done=(v||0); var pct=done/t; var c=pct>=.9?'#5a8a5e':pct>=.5?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+done+' / '+t+'</span>'; }},
{k:'planAttempts', label:'❌ Fehlkl.', align:'center', fmt:function(v){ var n=v||0; var c=n<=2?'#5a8a5e':n<=8?'#c9913a':'#c07a6b'; return n===0 ? '<span style="color:#5a8a5e;font-weight:700">0</span>' : '<span style="color:'+c+';font-weight:700">'+n+'</span>'; }},
{k:'startScore', label:'🛫 Start', fmt:function(v){ var n=Math.max(0,Math.min(5,v||0)); var c=n>=4?'#5a8a5e':n>=2?'#c9913a':'#c07a6b'; return v==null ? '—' : '<span style="color:'+c+';font-weight:700">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>'; }},
{k:'landingScore', label:'🛬 Landung', fmt:function(v){ var n=Math.max(0,Math.min(5,v||0)); var c=n>=4?'#5a8a5e':n>=2?'#c9913a':'#c07a6b'; return v==null ? '—' : '<span style="color:'+c+';font-weight:700">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>'; }},
{k:'totalStars', label:'⭐ Gesamt', fmt:function(v){ var n=Math.max(0,Math.min(5,v||0)); var c=n>=4?'#5a8a5e':n>=2?'#c9913a':'#c07a6b'; return v==null ? '—' : '<span style="color:'+c+';font-weight:800;font-size:.82rem">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>'; }},
{k:'failed', label:'Status', align:'center', fmt:function(v, row){ var s=row.state||{}; if (v===true) return '<span style="color:#c07a6b;font-weight:700">⚠ Chefpilot</span>'; if (s.phase==='results') return '<span style="color:#5a8a5e;font-weight:700">✓ Erfolg</span>'; if (s.phase==='mission'||!s.phase){ if (s.lastResult) return s.lastFailed ? '<span style="color:#c07a6b;font-weight:600" title="Werte = letzter Einsatz">⚠ letzter Einsatz</span>' : '<span style="color:#5a8a5e;font-weight:600" title="Werte = letzter Einsatz">✓ letzter Einsatz</span>'; return '<span style="color:#8a8a8a">wählt Auftrag</span>'; } return '<span style="color:#4a7c8a;font-weight:600">🟢 unterwegs</span>'; }},
],
'fluss': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'levelId', label:'Stufe', align:'center'},
{k:'year', label:'Jahr', fmt:function(v, row){ var s=row.state||{}; var max=100; return s.progressPct!=null ? (v||0)+' <span style="color:#8a8a8a">/ '+max+'</span>' : (v||'—'); }},
{k:'score', label:'🏅 Score', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v,0,100,'mid'); }},
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ if (v==null) return '—'; var c=v>=20?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
{k:'biodiv', label:'🌳 Biodiv.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'economy', label:'💰 Wirtsch.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'flood', label:'🛡️ Hochw.-Schutz', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'settlementsLost', label:'🏚️ Verlust', align:'center', fmt:function(v){ if (!v) return '<span style="color:#5a8a5e">0</span>'; var c=v>=3?'#c07a6b':'#c9913a'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
],
'kofferdetektiv': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'phase', label:'Phase'},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v,0,100,'mid'); }},
{k:'score', label:'🏅 Score', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
{k:'cases_solved', label:'Gelöst', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.cases_total||0; return t ? v+' <span style="color:#8a8a8a">/ '+t+'</span>' : (v||'—'); }},
{k:'case_iso2', label:'🎯 Ziel', align:'left', fmt:function(v, row){ var s=row.state||{}; if (!v) return '—'; var iso=String(v).toUpperCase(); if (s.case_country) return '<span style="font-weight:600;color:#5a8a5e">'+esc(s.case_country)+'</span>'; return '<span style="font-weight:600;color:#b04a3e" title="Antwort verdeckt — Schüler:in sucht noch">'+iso+' (suchend)</span>'; }},
{k:'hints_revealed', label:'Hinweise', align:'center'},
{k:'wrong_guesses_total', label:'❌ Fehler', fmt:function(v){ return _liveBar(v,0,10,'low'); }},
{k:'time_left_s', label:'⏱ Rest', align:'center', fmt:function(v){ if (v==null) return '<span style="color:#8a8a8a">∞</span>'; var c=v<=30?'#c07a6b':v<=120?'#c9913a':'#5a8a5e'; return '<span style="color:'+c+';font-weight:700">'+_fmtSeconds(v)+'</span>'; }},
{k:'airport', label:'✈ Flughafen', align:'left'},
],
'logistik': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'balance', label:'Budget', fmt:function(v){ if (v==null) return '—'; var c=v>=5000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
{k:'ordersDelivered', label:'Geliefert', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.totalOrders||0; var done=v||0; var c=done>0?'#5a8a5e':'#8a8a8a'; return '<b style="color:'+c+'">'+done+'</b>'+(t?'<span style="color:#8a8a8a"> / '+t+'</span>':''); }},
{k:'ordersInFlight', label:'Unterwegs', align:'center'},
{k:'ordersOpen', label:'Offen', align:'center'},
{k:'ordersLate', label:'Verspätet', align:'center', fmt:function(v){ var n=v||0; return n>0 ? '<span style="color:#c07a6b;font-weight:700">'+n+'</span>' : '<span style="color:#5a8a5e">0</span>'; }},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
],
};
/* ============================================================
3) Reihenfolge, Titel/Icons + Grün-Bewertung je Sim
GREEN = Keys, die NACH Abschluss noch relevant sind
(Ergebnis · Fortschritt/Abbruch · Aufgaben-Identität).
Alles andere = nur live.
============================================================ */
var SIM_META = [
{id:'farmer', icon:'🌾', title:'Farmer'},
{id:'klima', icon:'🌍', title:'Klimawächter'},
{id:'fluss', icon:'🌊', title:'Flussmanagement'},
{id:'energiemanager', icon:'⚡', title:'Energiemanager'},
{id:'eu-werkstatt', icon:'🇪🇺', title:'EU-Werkstatt'},
{id:'weltkueche', icon:'🍽️', title:'Weltküche'},
{id:'busfahrt', icon:'🚌', title:'Busfahrt'},
{id:'fluggesellschaft', icon:'✈️', title:'Fluggesellschaft'},
{id:'heli', icon:'🚁', title:'Hubschrauber-Rettung'},
{id:'kofferdetektiv', icon:'🧳', title:'Kofferdetektiv'},
{id:'sonnensystem', icon:'🪐', title:'Sonnensystem'},
{id:'tourismustal', icon:'🏔️', title:'Tourismustal'},
{id:'tourismusregion', icon:'🗺️', title:'Tourismusregion'},
{id:'logistik', icon:'📦', title:'Logistik'},
];
var GREEN = {
farmer: ['year','cash','hit_rate','crops_used','countries','fields','attempts','co2_kg','diseases'],
klima: ['level','year','co2','temp','floodedPct','budget','population','measures'],
fluss: ['levelId','year','score','progressPct','budget','biodiv','economy','flood','settlementsLost'],
energiemanager: ['setTitle','day','blackouts','balanced','setBlocksBalanced'],
'eu-werkstatt': ['scenarioTitle','step','difficulty','firstTryHits','muelleimerCount','reshuffles','secondsElapsed','achievementsUnlocked'],
weltkueche: ['completed','__quality'],
busfahrt: ['levelId','orderIndex','budget','wonOrders','quizFirstTry'],
fluggesellschaft: ['levelId','orderIndex','budget','wonOrders','co2Kg','quizFirstTry'],
heli: ['missionId','planIndex','planCorrect','planAttempts','startScore','landingScore','totalStars','failed'],
kofferdetektiv: ['progressPct','score','cases_solved','hints_revealed','wrong_guesses_total'],
sonnensystem: ['completed','progressPct','achievementsUnlocked'],
tourismustal: ['scene','year','money','guestsToday','satisfaction','nature','goalsDone','buildings'],
tourismusregion: ['mapId','wave','budget','revenue','avgStars','env','goalsDone'],
logistik: ['levelId','balance','ordersDelivered','ordersLate','progressPct'],
};
// Kurzbegründung, warum ein Grau-Key nur live relevant ist.
var GREY_REASON = {
market_de:'aktueller Handelsmarkt — wechselt ständig',
season:'momentane Jahreszeit im Spiel',
visitors:'Gäste im aktuellen Moment',
guestsToday:'Gäste nur am heutigen Sim-Tag',
bottleneck:'aktueller Engpass — ändert sich laufend',
speed:'Simulationstempo (Bedien-Einstellung, keine Leistung)',
health:'Live-Ampel des Moments — das Ergebnis steckt in Score/Sternen',
screen:'was gerade offen ist (Aufgabe/Erkunden)',
taskTitle:'aktuell offene Aufgabe',
simDate:'gerade beobachtetes Datum',
block:'aktueller Zeitblock im Tag',
upperLake:'momentaner Speicherstand (oberer See)',
lowerLake:'momentaner Speicherstand (unterer See)',
dishName:'aktuell gekochtes Gericht',
ingSolved:'Zutaten im aktuellen Gericht (setzt sich je Gericht zurück)',
mistakesInDish:'Fehler nur im aktuellen Gericht (setzt sich zurück)',
__lage:'Live-Stimmung — Ergebnis steckt in Qualität/Gerichte',
lastCityTitle:'zuletzt angefahrenes Ziel',
lastMargin:'Marge des letzten Auftrags',
lastTzDiff:'Zeitzonendifferenz des letzten Ziels',
phase:'aktueller Spielabschnitt',
case_iso2:'aktuelles Zielland — bewusst verdeckt, nur live',
time_left_s:'Restzeit im laufenden Fall',
airport:'aktueller Standort',
ordersInFlight:'Aufträge gerade unterwegs',
ordersOpen:'offene Aufträge im Moment',
};
/* ============================================================
3b) Bewertung je Sim — wie Sterne UND Prozent zustandekommen.
Quelle: Sterne-Audit der Game-Files (Formeln/Schwellen 1:1).
============================================================ */
var SCORING = {
farmer: {
stars:'<b>05</b> aus <code>totalScore</code> (0100, gewichtet: Konto 25% · Trefferquote 20% · CO₂ 15% · Krankheiten 10% · Sorten 10% · Länder 10% · Klimazonen 10%). Schwellen: <code>≥85→5 · ≥70→4 · ≥50→3 · ≥30→2 · sonst 1</code>.',
pct:'Kein 0100-Live-Score → Zeilen-% = <code>Sterne/5×100</code> (im Ergebnis liegt zusätzlich <code>data.score</code>).',
},
klima: {
stars:'<b>05</b>: Basis <code>2★</code> bei Sieg, dann <code>+1</code> je Temp&lt;17° · Temp&lt;16,3° · Budget&gt;200 · (Flut&lt;20% &amp; Bev.&gt;90% Start). Deckel: Temp≥17°→max 3★, ≥18,5°→max 1★.',
pct:'Eigener 0100-Score = <code>TempScore(035)+FlutScore(025)+BudgetScore(020)+BevScore(020)</code>. Live-Score identisch → Zeilen-% = dieser Score.',
},
fluss: {
stars:'<b>05</b> aus Score: <code>≥85→5 · ≥70→4 · ≥55→3 · ≥35→2 · sonst 1</code>.',
pct:'Score 0100 = <code>Ø(Biodiv, Wirtschaft, Hochwasserschutz) 5×Siedlungsverluste</code>. Live-Score vorhanden → Zeilen-% = Score.',
},
energiemanager: {
stars:'<b>05</b> aus <code>Quote = ausgeglichene Blöcke / gesamt</code>: <code>≥1,0→5 · ≥0,87→4 · ≥0,66→3 · ≥0,50→2 · sonst 1</code>. Blackout-Deckel: 1×→max 3★, ≥2×→max 2★.',
pct:'Kein 0100-Live-Score → Zeilen-% = <code>Sterne/5×100</code>.',
},
'eu-werkstatt': {
stars:'<b>05</b> aus <code>firstTryRate = saubere Erstversuche / Schritte</code>: <code>≥1,0→5 · ≥0,85→4 · ≥0,65→3 · ≥0,40→2 · sonst 1</code>.',
pct:'Kein 0100-Live-Score → Zeilen-% = <code>Sterne/5×100</code>.',
},
weltkueche: {
stars:'<b>05 pro Gericht</b> aus Fehlern: <code>0→5 · ≤1→4 · ≤3→3 · ≤6→2 · sonst 1</code>.',
pct:'Live-<code>score</code> ist eine <b>rohe Punktezahl</b> (nicht 0100) → Zeilen-% aktuell unklar.',
flags:['Wird <b>pro Gericht</b> gesendet, nicht pro Durchgang.','„Fehler in %" (Fehler ÷ Aktionen) gehört genau hierher — vergleichbar statt roher, gericht-abhängiger Zahl.'],
},
busfahrt: {
stars:'<b>nur 03</b> aus <code>Ratio = Topo-Punkte / (Aufträge×3)</code>: <code>≥0,85→3 · ≥0,55→2 · ≥0,30→1 · sonst 0</code>.',
pct:'Gesendeter <code>score</code> = rohe Topo-Punkte (nicht 0100).',
flags:['03 statt 05 Sterne.','score ist keine 0100-Zahl → Zeilen-% verzerrt.'],
},
fluggesellschaft: {
stars:'<b>nur 03</b> aus <code>Ratio = Topo-Punkte / (Flüge×3)</code>: <code>≥0,85→3 · ≥0,55→2 · ≥0,30→1 · sonst 0</code>.',
pct:'Gesendeter <code>score</code> = rohe Topo-Punkte (nicht 0100).',
flags:['03 statt 05 Sterne.','score ist keine 0100-Zahl → Zeilen-% verzerrt.'],
},
heli: {
stars:'<b>05</b> = <code>round((PlanSterne + Start + Landung) / 3)</code>. PlanSterne aus Timing (Ø≥1,6→5 …) bzw. Klickgenauigkeit; Chefpilot-Übernahme → 0.',
pct:'Live-Score 0100 = <code>Sterne/5×100</code> (submit <code>score = Sterne×20</code>) → Zeilen-% = das.',
},
kofferdetektiv: {
stars:'<b>nur 03</b> aus <code>q = score100/100</code>: <code>≥0,7→3 · ≥0,45→2 · ≥0,2→1 · sonst 0</code>.',
pct:'<code>score100 = Punkte / (Fälle×100) × 100</code> (je Fall 100 P, 7/Hinweis, 15/Fehltipp, 20/Timeout). Live-Score = score100 → Zeilen-% = das.',
flags:['03 statt 05 Sterne.'],
},
sonnensystem: {
stars:'<b>15</b> aus <code>Quote = Erstversuch-richtig / gesamt</code>: <code>≥0,85→5 · ≥0,68→4 · ≥0,50→3 · ≥0,30→2 · sonst 1</code> (Minimum 1, nie 0).',
pct:'Kein 0100-Live-Score (nur <code>levelWon</code>+Fortschritt; <code>scoreQuote</code> im data) → Zeilen-% = <code>Sterne/5×100</code>.',
},
tourismustal: {
stars:'<b>05</b> = <code>min(5, erreichte Ziele + (Sieg?1:0))</code>.',
pct:'Score 0100 = <code>erreichte Ziele / gesamt × 100</code>; kein Live-0100 → Zeilen-% = data.score bzw. Sterne/5×100.',
},
tourismusregion: {
stars:'<b>05</b> = <code>round(Ø Besucher-Bewertung)</code> (avgStars 05).',
pct:'Score 0100 = gewichtet: <code>Bewertung 35% · Wertschöpfung 25% · Nachhaltigkeit 20% · Lenkung 10% · Finanzen 10%</code>. Live <code>avgStars</code> → Zeilen-% = Score.',
},
logistik: {
stars:'<b>05</b> aus Endkontostand: <code>≤0→0 · ≥16000→5 · ≥13000→4 · ≥10500→3 · ≥8500→2 · sonst 1</code>.',
pct:'Kein Score/Stern wird ans Backend gesendet.',
flags:['Sterne &amp; Score werden <b>NICHT übertragen</b> — im Lehrer-Cockpit erscheint für Logistik kein Stern/Prozent. Eigene Endpoints <code>/api/assessment</code> + <code>/api/logistik-sessions</code>.'],
},
};
/* ============================================================
3c) VORSCHLAG — einheitliches Leistungs-% (0100) je Sim,
5-stufige Farbskala, %+Balken-Komponente, Triage (%×Fleiß).
============================================================ */
var PROPOSED_PCT = {
farmer:'gewichteter 0100-Score (wie heute intern berechnet) — künftig auch LIVE senden.',
klima:'vorhandener 0100-Score (Temp+Flut+Budget+Bev.) übernehmen.',
fluss:'vorhandener Score Ø(Biodiv,Wirtschaft,Schutz) 5×Verluste übernehmen.',
energiemanager:'ausgeglichene Blöcke / (Tage×8) ×100 15 je Blackout.',
'eu-werkstatt':'saubere Erstversuche / Schritte ×100 5 je Mülleimer.',
weltkueche:'richtige / (richtige+falsche) ×100 (= 100 Fehlerquote).',
busfahrt:'Topo-Punkte / (Aufträge×3) ×100.',
fluggesellschaft:'Topo-Punkte / (Flüge×3) ×100.',
heli:'kontinuierlich aus PlanPct + Start + Landung (0100), nicht gerundete Sterne.',
kofferdetektiv:'vorhandener score100 (0100) übernehmen.',
sonnensystem:'Erstversuch-Quote ×100.',
tourismustal:'Ziele/gesamt ×100, gewichtet mit Zufriedenheit/Natur.',
tourismusregion:'vorhandener gewichteter 0100-Score übernehmen.',
logistik:'Kontostand→% (0 €→0, Zielbudget→100) — UND ans Backend senden.',
};
// 5-stufige Farbskala (= _tagTraffic).
// ≥90 Dunkelgrün · ≥80 Hellgrün · ≥60 Gelb · ≥50 Orange · <50 Rot
function pctColor(v){ v=Math.max(0,Math.min(100,v||0)); return v>=90?'#1f7a3d':v>=80?'#7cb342':v>=60?'#f9c33b':v>=50?'#ef8b2c':'#d8453a'; }
// Standard-Anzeige überall: Prozent in Ampelfarbe + schmaler Balken darunter (kompakt).
function pctBar(pct){
if (pct==null) return '<span style="color:#9a9a9a">—</span>';
var c=pctColor(pct);
return '<div style="width:40px">'
+ '<div style="font-weight:800;color:'+c+';font-size:.78rem;font-variant-numeric:tabular-nums;line-height:1">'+pct+'<span style="font-size:.6rem"> %</span></div>'
+ '<div style="margin-top:2px;height:4px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:'+pct+'%;background:'+c+'"></div></div>'
+ '</div>';
}
// Triage aus Leistungs-% × Aktivität — Grenzen = Farbgrenzen, damit Feld ↔ Farbe stimmen.
// <50 Rot→Fördern · 5079 Orange+Gelb→Begleiten · 8089 Hellgrün→Läuft gut · ≥90 Dunkelgrün→Fordern.
function triage(pct, active){
if (pct==null || !active) return {lab:'inaktiv', col:'#8a8a8a', bg:'#efeee8'};
if (pct<50) return {lab:'Fördern', col:'#b04a3e', bg:'#f7e2dc'};
if (pct<80) return {lab:'Begleiten', col:'#9a6a12', bg:'#f7edd6'};
if (pct<90) return {lab:'Läuft gut', col:'#3a6b3e', bg:'#dceadd'};
return {lab:'Fordern', col:'#155e2a', bg:'#c8e6cd'};
}
function triageChip(pct, active){ var t=triage(pct,active); return '<span style="display:inline-block;font-size:.62rem;font-weight:700;color:'+t.col+';background:'+t.bg+';padding:1px 7px;border-radius:10px">'+t.lab+'</span>'; }
// Vorschlags-Leistungs-% auf die Testdaten gerechnet (Vorschau).
function _cl(v,a,b){ return Math.max(a,Math.min(b,v)); }
var PERF = {
klima:function(s){ var sp=(s.level>=3)?7000:6000; return Math.round(_cl(35-(s.temp-15.5)*20,0,35)+Math.max(0,25*(1-(s.floodedPct||0)/50))+_cl((s.budget||0)/10,0,20)+_cl((s.population||0)/sp*20,0,20)); },
fluss:function(s){ return _cl(Math.round((s.biodiv+s.economy+s.flood)/3)-(s.settlementsLost||0)*5,0,100); },
kofferdetektiv:function(s){ return s.score!=null?s.score:null; },
tourismusregion:function(s){ return Math.round(0.6*((s.avgStars||0)/5*100)+0.4*(s.env||0)); },
tourismustal:function(s){ if(!s.goalsTotal) return null; return Math.round(0.5*(s.goalsDone/s.goalsTotal*100)+0.3*(s.satisfaction||0)+0.2*(s.nature||0)); },
heli:function(s){ if(s.totalStars!=null) return Math.round(s.totalStars/5*100); if(s.planTotal) return Math.round((s.planCorrect||0)/s.planTotal*100); return null; },
farmer:function(s){ var money=_cl(50+(s.cash||0)/300,0,100),co2=_cl(100-(s.co2_kg||0)/80,0,100),dis=_cl(100-(s.diseases||0)*20,0,100),crop=_cl((s.crops_used||0)/10*100,0,100),country=_cl((s.countries||0)/6*100,0,100); return Math.round(0.25*money+0.20*(s.hit_rate||0)+0.15*co2+0.10*dis+0.10*crop+0.10*country+0.10*70); },
energiemanager:function(s){ var d=(s.day||1)*(s.blockTotal||8); return _cl(Math.round((s.setBlocksBalanced||0)/d*100)-15*(s.blackouts||0),0,100); },
'eu-werkstatt':function(s){ if(!s.stepsTotal) return null; return _cl(Math.round((s.firstTryHits||0)/s.stepsTotal*100)-5*(s.muelleimerCount||0),0,100); },
weltkueche:function(s){ var t=(s.correctClicks||0)+(s.wrongClicks||0); return t?Math.round((s.correctClicks||0)/t*100):null; },
busfahrt:function(s){ return s.orderIndex?Math.round((s.quizFirstTry||0)/s.orderIndex*100):null; },
fluggesellschaft:function(s){ return s.orderIndex?Math.round((s.quizFirstTry||0)/s.orderIndex*100):null; },
sonnensystem:function(s){ return s.taskTotal?Math.round((s.completed||0)/s.taskTotal*100):null; },
logistik:function(s){ return s.balance==null?null:_cl(Math.round(s.balance/16000*100),0,100); },
};
/* ============================================================
4) Testdaten je Sim (3 Schüler:innen: stark · schwach · mittel)
============================================================ */
var NOW = Date.now();
function R(name, emoji, state, o){
o = o || {};
return {
displayName:name, emoji:emoji, module_id:state.__mod, state:state,
started_at_ms: NOW - (o.sinceMin||10)*60000,
last_seen_ms: NOW - (o.seenSec!=null?o.seenSec:4)*1000,
};
}
// missionStartedAt in ms setzen (für die „Seit"-Zeile)
function withMission(state, missMin){ if (missMin!=null) state.missionStartedAt = NOW - missMin*60000; return state; }
var TEST = {
farmer: [
R('Anna','🦊', {__mod:'farmer', year:8, market_de:'Ägypten', cash:14200, hit_rate:78, crops_used:7, countries:5, fields:9, attempts:12, co2_kg:3400, diseases:1}, {sinceMin:24, seenSec:3}),
R('Ben','🐼', {__mod:'farmer', year:4, market_de:'Indien', cash:-800, hit_rate:22, crops_used:3, countries:2, fields:5, attempts:14, co2_kg:5200, diseases:3}, {sinceMin:16, seenSec:6}),
R('Clara','🦉',{__mod:'farmer', year:6, market_de:'Brasilien', cash:5100, hit_rate:54, crops_used:5, countries:4, fields:7, attempts:9, co2_kg:4100, diseases:2}, {sinceMin:19, seenSec:140}),
],
klima: [
R('Anna','🦊', {__mod:'klima', level:2, year:2068, co2:520, temp:16.8, floodedPct:18, budget:1200, population:6400, measures:11, speed:2}, {sinceMin:14}),
R('Ben','🐼', {__mod:'klima', level:1, year:2041, co2:610, temp:17.9, floodedPct:47, budget:-220, population:5200, measures:5, speed:1}, {sinceMin:9, seenSec:5}),
R('Clara','🦉',{__mod:'klima', level:3, year:2085, co2:430, temp:15.9, floodedPct:31, budget:2600, population:8100, measures:16, speed:3}, {sinceMin:22, seenSec:130}),
],
fluss: [
R('Anna','🦊', {__mod:'fluss', health:'good', levelId:'L2', year:64, progressPct:64, score:78, budget:32, biodiv:72, economy:66, flood:81, settlementsLost:0}, {sinceMin:17}),
R('Ben','🐼', {__mod:'fluss', health:'struggling', levelId:'L1', year:20, progressPct:20, score:34, budget:-4, biodiv:41, economy:38, flood:52, settlementsLost:3}, {sinceMin:8, seenSec:7}),
R('Clara','🦉',{__mod:'fluss', health:'ok', levelId:'L2', year:100, progressPct:100, score:88, budget:18, biodiv:80, economy:74, flood:90, settlementsLost:1}, {sinceMin:26, seenSec:150}),
],
energiemanager: [
R('Anna','🦊', {__mod:'energiemanager', health:'good', setTitle:'Frühling', setIcon:'🌱', setIndex:1, setTotal:6, day:3, dayTotal:3, phase:'done', block:7, blockTotal:8, blackouts:0, balanced:8, setBlocksBalanced:21, upperLake:32, lowerLake:28}, {sinceMin:15}),
R('Ben','🐼', {__mod:'energiemanager', health:'struggling', setTitle:'Sommer', setIcon:'🌞', setIndex:2, setTotal:6, day:2, dayTotal:3, phase:'blackout', block:4, blockTotal:8, blackouts:2, balanced:0, setBlocksBalanced:9, upperLake:5, lowerLake:55}, {sinceMin:11, seenSec:6}),
R('Clara','🦉',{__mod:'energiemanager', health:'ok', setTitle:'Herbst', setIcon:'🍂', setIndex:3, setTotal:6, day:1, dayTotal:3, phase:'done', block:5, blockTotal:8, blackouts:1, balanced:6, setBlocksBalanced:6, upperLake:40, lowerLake:20}, {sinceMin:20, seenSec:135}),
],
'eu-werkstatt': [
R('Anna','🦊', {__mod:'eu-werkstatt', health:'good', scenarioTitle:'Käfige abschaffen', scenarioIcon:'🐔', step:6, stepsTotal:6, difficulty:'easy', firstTryHits:6, muelleimerCount:0, reshuffles:0, secondsElapsed:214, achievementsUnlocked:4, achievementsTotal:6}, {sinceMin:6}),
R('Ben','🐼', {__mod:'eu-werkstatt', health:'struggling', scenarioTitle:'Käfige abschaffen', scenarioIcon:'🐔', step:2, stepsTotal:6, difficulty:'easy', firstTryHits:1, muelleimerCount:3, reshuffles:2, secondsElapsed:150, achievementsUnlocked:1, achievementsTotal:6, attemptsThisStep:6}, {sinceMin:5, seenSec:6}),
R('Clara','🦉',{__mod:'eu-werkstatt', health:'ok', scenarioTitle:'Ein Kabel für alle', scenarioIcon:'🔌', step:3, stepsTotal:5, difficulty:'expert', firstTryHits:2, muelleimerCount:1, reshuffles:6, secondsElapsed:330, achievementsUnlocked:2, achievementsTotal:6}, {sinceMin:9, seenSec:150}),
],
weltkueche: [
R('Anna','🦊', {__mod:'weltkueche', dishName:'Tiroler Gröstl', completed:4, completedTotal:6, ingSolved:5, ingTotal:5, correctClicks:38, wrongClicks:4, mistakesInDish:1}, {sinceMin:13}),
R('Ben','🐼', {__mod:'weltkueche', dishName:'Moussaka', completed:1, completedTotal:6, ingSolved:2, ingTotal:6, correctClicks:12, wrongClicks:9, mistakesInDish:7}, {sinceMin:7, seenSec:5}),
R('Clara','🦉',{__mod:'weltkueche', dishName:'Sushi', completed:2, completedTotal:6, ingSolved:6, ingTotal:6, correctClicks:22, wrongClicks:14, mistakesInDish:12}, {sinceMin:10, seenSec:145}),
],
busfahrt: [
R('Anna','🦊', {__mod:'busfahrt', levelId:'L2', orderIndex:8, totalOrders:12, budget:9200, wonOrders:8, lastCityTitle:'Salzburg', lastMargin:420, quizFirstTry:6}, {sinceMin:18}),
R('Ben','🐼', {__mod:'busfahrt', levelId:'L1', orderIndex:3, totalOrders:10, budget:-300, wonOrders:2, lastCityTitle:'Graz', lastMargin:-120, quizFirstTry:1}, {sinceMin:9, seenSec:6}),
R('Clara','🦉',{__mod:'busfahrt', levelId:'L2', orderIndex:5, totalOrders:12, budget:5400, wonOrders:5, lastCityTitle:'Wien', lastMargin:260, quizFirstTry:4}, {sinceMin:14, seenSec:150}),
],
fluggesellschaft: [
R('Anna','🦊', {__mod:'fluggesellschaft', levelId:'L4', orderIndex:6, totalOrders:9, budget:14200, wonOrders:6, co2Kg:8400, lastCityTitle:'Tokio', lastTzDiff:8, quizFirstTry:5}, {sinceMin:21}),
R('Ben','🐼', {__mod:'fluggesellschaft', levelId:'L4', orderIndex:2, totalOrders:9, budget:-500, wonOrders:1, co2Kg:3200, lastCityTitle:'Kairo', lastTzDiff:1, quizFirstTry:1}, {sinceMin:8, seenSec:5}),
R('Clara','🦉',{__mod:'fluggesellschaft', levelId:'L4', orderIndex:4, totalOrders:9, budget:7600, wonOrders:4, co2Kg:5600, lastCityTitle:'New York', lastTzDiff:-6, quizFirstTry:3}, {sinceMin:12, seenSec:150}),
],
heli: [
R('Anna','🦊', withMission({__mod:'heli', phase:'results', missionId:'M19', missionTitle:'Seerettung', planIndex:6, planTotal:6, planCorrect:6, planAttempts:1, startScore:5, landingScore:4, totalStars:5, failed:false}, 5), {sinceMin:12}),
R('Ben','🐼', withMission({__mod:'heli', phase:'flight', missionId:'M7', missionTitle:'Kufstein', planIndex:3, planTotal:5, planCorrect:2, planAttempts:11, startScore:3, landingScore:null, totalStars:null, failed:false}, 3), {sinceMin:8, seenSec:6}),
R('Clara','🦉',withMission({__mod:'heli', phase:'mission', missionId:'M5', missionTitle:'Bregenz', planIndex:0, planTotal:5, planCorrect:0, planAttempts:0, startScore:null, landingScore:null, totalStars:null, failed:false, lastResult:true, lastFailed:false}, 1), {sinceMin:14, seenSec:150}),
],
kofferdetektiv: [
R('Anna','🦊', {__mod:'kofferdetektiv', health:'good', phase:'suche', progressPct:60, score:82, cases_solved:6, cases_total:10, case_iso2:'IT', hints_revealed:1, wrong_guesses_total:2, time_left_s:180, airport:'Rom-Fiumicino'}, {sinceMin:16}),
R('Ben','🐼', {__mod:'kofferdetektiv', health:'ok', phase:'suche', progressPct:30, score:55, cases_solved:3, cases_total:10, case_iso2:'BR', hints_revealed:4, wrong_guesses_total:7, time_left_s:45, airport:'São Paulo-Guarulhos'}, {sinceMin:11, seenSec:6}),
R('Clara','🦉',{__mod:'kofferdetektiv', health:'struggling', phase:'briefing', progressPct:10, score:28, cases_solved:1, cases_total:10, case_iso2:'JP', hints_revealed:6, wrong_guesses_total:9, time_left_s:20, airport:'Tokio-Narita'}, {sinceMin:6, seenSec:150}),
],
sonnensystem: [
R('Anna','🦊', {__mod:'sonnensystem', health:'good', screen:'play', taskTitle:'Mondphasen', taskIndex:8, taskTotal:20, completed:8, progressPct:40, simDate:'14.03.', achievementsUnlocked:3, achievementsTotal:8}, {sinceMin:13}),
R('Ben','🐼', {__mod:'sonnensystem', health:'ok', screen:'explore', taskTitle:'', taskIndex:0, taskTotal:20, completed:14, progressPct:70, simDate:'22.09.', achievementsUnlocked:5, achievementsTotal:8}, {sinceMin:19, seenSec:5}),
R('Clara','🦉',{__mod:'sonnensystem', health:'struggling', screen:'play', taskTitle:'Sonnenfinsternis', taskIndex:3, taskTotal:20, completed:2, progressPct:10, simDate:'02.07.', achievementsUnlocked:1, achievementsTotal:8}, {sinceMin:7, seenSec:150}),
],
tourismustal: [
R('Anna','🦊', {__mod:'tourismustal', scene:'boom', season:'Sommer', year:2004, money:8200, guestsToday:180, satisfaction:74, nature:52, goalsDone:2, goalsTotal:3, buildings:6, bottleneck:''}, {sinceMin:20}),
R('Ben','🐼', {__mod:'tourismustal', scene:'klimawandel', season:'Winter', year:2037, money:-600, guestsToday:60, satisfaction:38, nature:41, goalsDone:1, goalsTotal:3, buildings:9, bottleneck:'Schnee'}, {sinceMin:10, seenSec:6}),
R('Clara','🦉',{__mod:'tourismustal', scene:'pionierzeit', season:'Sommer', year:1978, money:4300, guestsToday:120, satisfaction:63, nature:78, goalsDone:1, goalsTotal:3, buildings:3, bottleneck:''}, {sinceMin:15, seenSec:150}),
],
tourismusregion: [
R('Anna','🦊', {__mod:'tourismusregion', mapId:'talschleife', wave:8, waveTotal:8, budget:22000, revenue:180000, avgStars:3.8, env:72, visitors:540, goalsDone:3, goalsTotal:3}, {sinceMin:23}),
R('Ben','🐼', {__mod:'tourismusregion', mapId:'seerunde', wave:3, waveTotal:8, waveStatus:'pause', budget:-400, revenue:42000, avgStars:2.1, env:34, visitors:210, goalsDone:1, goalsTotal:3}, {sinceMin:9, seenSec:6}),
R('Clara','🦉',{__mod:'tourismusregion', mapId:'serpentinen', wave:5, waveTotal:8, budget:8600, revenue:96000, avgStars:2.9, env:58, visitors:330, goalsDone:2, goalsTotal:3}, {sinceMin:16, seenSec:150}),
],
logistik: [
R('Anna','🦊', {__mod:'logistik', levelId:'L1', balance:9200, ordersDelivered:8, totalOrders:12, ordersInFlight:2, ordersOpen:2, ordersLate:0, progressPct:66}, {sinceMin:18}),
R('Ben','🐼', {__mod:'logistik', levelId:'L2', balance:-300, ordersDelivered:3, totalOrders:14, ordersInFlight:3, ordersOpen:8, ordersLate:2, progressPct:21}, {sinceMin:9, seenSec:6}),
R('Clara','🦉',{__mod:'logistik', levelId:'L1', balance:5400, ordersDelivered:6, totalOrders:12, ordersInFlight:1, ordersOpen:5, ordersLate:1, progressPct:50}, {sinceMin:13, seenSec:150}),
],
};
/* ============================================================
5) Rendern — Tabelle wie renderLive(), plus Grün/Grau + Keys
============================================================ */
var _LIVE_PAUSE_SEC = 90;
function _liveIsActive(ls){ return ls.last_seen_ms && (NOW - ls.last_seen_ms) <= _LIVE_PAUSE_SEC*1000; }
function renderScoring(id){
var sc = SCORING[id];
if (!sc) return '';
var h = '<div class="scorebox"><h4>🎯 So entsteht die Bewertung</h4>';
h += '<div class="srow"><span class="lab">Sterne</span><span>'+sc.stars+'</span></div>';
h += '<div class="srow"><span class="lab">Prozent</span><span>'+sc.pct+'</span></div>';
if (PROPOSED_PCT[id]) h += '<div class="srow" style="background:#eef7f0;border-radius:6px;padding:.25rem .4rem;margin-top:.35rem"><span class="lab" style="color:#3a6b3e">Vorschlag %</span><span style="color:#2f5e39">'+PROPOSED_PCT[id]+'</span></div>';
(sc.flags||[]).forEach(function(f){ h += '<div class="flag">⚠ '+f+'</div>'; });
h += '</div>';
return h;
}
function renderSim(meta){
var fields = LIVE_PRIMARY_FIELDS[meta.id] || [];
var rows = TEST[meta.id] || [];
var greenSet = {}; (GREEN[meta.id]||[]).forEach(function(k){ greenSet[k]=true; });
var greyKeys = fields.filter(function(f){ return !greenSet[f.k]; }).map(function(f){ return f.k; });
var greenCount = fields.length - greyKeys.length;
// Spalten-Maxima für relative Mini-Balken
var colMax = {};
fields.forEach(function(f){ var mx=0; rows.forEach(function(r){ var val=(r.state||{})[f.k]; if (typeof val==='number' && !isNaN(val)) mx=Math.max(mx,Math.abs(val)); }); colMax[f.k]=mx; });
var h = '<div class="sim" id="sim-'+meta.id+'">';
h += '<div class="sim-head"><span class="emoji">'+meta.icon+'</span><h3>'+esc(meta.title)+'</h3>'
+ '<span class="sid">'+meta.id+'</span>'
+ '<span class="count">'+greenCount+' grün · '+greyKeys.length+' nur live</span></div>';
h += '<div class="tbl-scroll"><table class="live"><thead><tr>';
h += '<th style="text-align:left">Schüler*in<span class="key">displayName</span></th>';
h += '<th style="text-align:left;color:#8a8a8a">Seit<span class="key">missionStartedAt / started_at</span></th>';
h += '<th style="text-align:left;background:#e7f4ea;border-bottom-color:#bcd9c2">🎯 Leistung<span class="key">leistung_pct (Vorschlag)</span><span class="tag g">EIN Wert</span></th>';
fields.forEach(function(f){
var align=f.align||'right';
var isG=!!greenSet[f.k];
h += '<th class="'+(isG?'green':'grey')+'" style="text-align:'+align+'">'
+ esc(f.label)+(f.unit?' <span style="color:#8a8a8a;font-weight:400;font-size:.66rem">'+f.unit+'</span>':'')
+ '<span class="key">'+esc(f.k)+'</span>'
+ '<span class="tag '+(isG?'g':'l')+'">'+(isG?'nach Abschluss':'nur live')+'</span>'
+ '</th>';
});
h += '<th style="width:1.2em"></th></tr></thead><tbody>';
rows.forEach(function(ls){
var s = ls.state||{};
var avatarHtml = '<span style="font-size:1.1rem">'+(ls.emoji||'🧑‍🎓')+'</span>';
var sinceSimBase = ls.started_at_ms||0;
var sinceMissionBase = (s.missionStartedAt)||(s.levelStartedAt)||(s.scenarioStartedAt)||0;
var sinceSim = sinceSimBase ? Math.max(0,Math.round((NOW-sinceSimBase)/1000)) : 0;
var sinceMission = sinceMissionBase ? Math.max(0,Math.round((NOW-sinceMissionBase)/1000)) : 0;
var simStr = sinceSimBase ? _fmtSince(sinceSim) : '—';
var missionStr = sinceMissionBase ? _fmtSince(sinceMission) : '—';
var lastSeen = ls.last_seen_ms ? Math.max(0,Math.round((NOW-ls.last_seen_ms)/1000)) : 0;
var paused = !_liveIsActive(ls) || !!(s.__idlePaused||s.__paused);
var dotColor = lastSeen<8?'#5a8a5e':lastSeen<20?'#e8833a':'#c07a6b';
var help = !paused && _liveNeedsHelp(ls.module_id, s);
var restBg = help ? '#fdf3ee' : (paused ? '#f6f5f2' : '');
var accent = help ? 'inset 3px 0 0 #c0574a' : (paused ? 'inset 3px 0 0 #cfcabb' : 'none');
h += '<tr style="background:'+restBg+';'+(paused?'color:#9a9a9a;':'')+'box-shadow:'+accent+'">';
h += '<td style="white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:.4rem">'+(help?'<span title="stockt · braucht evtl. Hilfe">🆘</span>':(paused?'<span title="pausiert">⏸</span>':''))+avatarHtml+'<strong>'+esc(ls.displayName)+'</strong></span></td>';
h += '<td style="white-space:nowrap;line-height:1.15"><div style="font-size:.78rem;color:#3a3a3a;font-weight:600">'+missionStr+'</div><div style="font-size:.62rem;color:#8a8a8a">'+simStr+' ges.</div></td>';
var perfPct = (PERF[meta.id]) ? PERF[meta.id](s) : null;
h += '<td style="background:#f2faf4;vertical-align:top">'+pctBar(perfPct)+'<div style="margin-top:3px">'+triageChip(perfPct, perfPct!=null)+'</div></td>';
fields.forEach(function(f){
var align=f.align||'right';
var isG=!!greenSet[f.k];
var cell=_liveFmt(s[f.k], f.unit, f, ls);
if (f.bar){
var raw=s[f.k];
if (typeof raw==='number' && !isNaN(raw)){
var pct=0, bcol;
if (f.bar.kind==='abs'){ pct=(raw/(f.bar.max||100))*100; bcol=_liveBarColor(pct,f.bar.good); }
else { var mx=colMax[f.k]||0; pct=mx>0?(Math.abs(raw)/mx)*100:0; bcol=(f.bar.good==='signed')?(raw<0?'#c0392b':'#5a8a5e'):_liveBarColor(pct,f.bar.good); }
cell='<div style="line-height:1.1">'+cell+'</div>'+_liveMiniBar(pct,bcol);
}
} else if (typeof s[f.k]==='number' && !isNaN(s[f.k]) && (colMax[f.k]||0)>0 && cell.indexOf('border-radius:99px')<0){
var apct=(Math.abs(s[f.k])/colMax[f.k])*100;
cell='<div style="line-height:1.1">'+cell+'</div>'+_liveMiniBar(apct,'#9aa7b0');
}
h += '<td class="'+(isG?'green':'')+'" style="text-align:'+align+'">'+cell+'</td>';
});
h += '<td style="text-align:center">'+(paused
? '<span style="font-size:.6rem;color:#9a9a9a;white-space:nowrap">⏸ '+_fmtSince(lastSeen)+'</span>'
: '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+dotColor+'"></span>')+'</td>';
h += '</tr>';
});
h += '</tbody></table></div>';
// Bewertung: wie Sterne + Prozent entstehen
h += renderScoring(meta.id);
// Nach-Abschluss-Hinweis
if (greyKeys.length){
h += '<div class="note"><b>Nach Abschluss NICHT relevant (nur live):</b><ul>';
greyKeys.forEach(function(k){
var lbl=(fields.filter(function(f){return f.k===k;})[0]||{}).label||k;
h += '<li><code>'+esc(k)+'</code> · '+esc(lbl)+' — '+esc(GREY_REASON[k]||'momentaner Zustand, ändert sich laufend')+'</li>';
});
h += '</ul></div>';
} else {
h += '<div class="allgreen">✓ Alle Spalten dieser Sim bleiben auch nach Abschluss relevant.</div>';
}
h += '</div>';
return h;
}
// Vorschau-Karte: neues Bewertungssystem (Skala · %+Balken · Triage)
function renderPreviewCard(){
// EINE Skala. Jede Zeile: Farbe · Bereich · Label · Beispiel-Balken.
var scale=[
{c:'#1f7a3d', r:'≥ 90 %', l:'sehr gut', v:90},
{c:'#7cb342', r:'8089 %', l:'gut', v:80},
{c:'#f9c33b', r:'6079 %', l:'solide', v:60},
{c:'#ef8b2c', r:'5059 %', l:'wackelig', v:50},
{c:'#d8453a', r:'< 50 %', l:'kritisch', v:40},
];
var h='<div class="gbox" style="border-left-color:#5a8a5e">';
h+='<h2>🎯 Bewertung — EINE Skala</h2>';
h+='<p><b>Standard überall im Programm:</b> ein Leistungs-% (0100) in der Ampelfarbe + schmaler Balken. Sterne fallen weg. Es gibt nur <b>diese eine</b> Skala mit 5 Stufen:</p>';
h+='<div style="display:grid;grid-template-columns:24px auto 1fr 48px;gap:.4rem .9rem;align-items:center;max-width:460px;margin:.5rem 0 1rem;font-size:.82rem">';
scale.forEach(function(b){
h+='<span style="width:20px;height:20px;border-radius:5px;background:'+b.c+'"></span>';
h+='<b style="font-variant-numeric:tabular-nums">'+b.r+'</b>';
h+='<span style="color:#5a5a5a">'+b.l+'</span>';
h+='<span>'+pctBar(b.v)+'</span>';
});
h+='</div>';
h+='<p><b>Triage</b> — dieselbe Skala, zu Handlungs-Feldern zusammengefasst (Grenzen = Farbgrenzen 50 / 80 / 90):</p>';
h+='<div style="display:flex;gap:1.3rem;flex-wrap:wrap;align-items:center;margin:.3rem 0 .2rem;font-size:.78rem">'
+'<span>'+triageChip(30,true)+' &nbsp;<b>&lt; 50 %</b> <span style="color:#8a8a8a">(rot)</span></span>'
+'<span>'+triageChip(65,true)+' &nbsp;<b>5079 %</b> <span style="color:#8a8a8a">(orange + gelb)</span></span>'
+'<span>'+triageChip(85,true)+' &nbsp;<b>8089 %</b> <span style="color:#8a8a8a">(hellgrün)</span></span>'
+'<span>'+triageChip(95,true)+' &nbsp;<b>≥ 90 %</b> <span style="color:#8a8a8a">(dunkelgrün)</span></span>'
+'<span>'+triageChip(null,false)+' &nbsp;<span style="color:#8a8a8a">nicht aktiv</span></span>'
+'</div>';
h+='<p style="font-size:.72rem;color:#8a8a8a;margin-top:.8rem">Unten hat jede Tabelle die Spalte <b>🎯 Leistung</b> — die Vorschlags-Formel je Sim, auf die Testdaten gerechnet. Bei Busfahrt · Fluggesellschaft · Sonnensystem ist der Wert eine Näherung (Topo-/Erstversuch-Rohwerte fehlen in den Testdaten).</p>';
h+='</div>';
return h;
}
// TOC + Inhalt aufbauen
var toc = SIM_META.map(function(m){ return '<a href="#sim-'+m.id+'">'+m.icon+' '+esc(m.title)+'</a>'; }).join('');
document.getElementById('preview').innerHTML = renderPreviewCard();
document.getElementById('toc').innerHTML = toc;
document.getElementById('content').innerHTML = SIM_META.map(renderSim).join('');
</script>
</body>
</html>
+1 -1
View File
@@ -173,7 +173,7 @@ var SIMS = [
start_population:{min:1000,max:20000,def:6000,label:'Start-Bevölkerung'},
income_per_10k:{min:50,max:500,def:220,label:'Einnahmen pro 10.000 Bürger'},
budget_loss:{min:-2000,max:0,def:-500,label:'Pleite-Grenze (Mio €, negativ)'},
event_frequency:{min:0,max:1,def:0.3,step:0.1,label:'Ereignis-Häufigkeit (01)'},
event_frequency:{min:0,max:1,def:0.3,step:0.1,label:'Ereignis-Häufigkeit (0 1)'},
time_limit:{min:30,max:150,def:75,label:'Simulationsdauer (Runden)'}
}},
{id:'sim-05', name:'Klimawächter 2D (V1)', icon:'🌡️', img:'assets/img/card-climate.webp',
+506
View File
@@ -35,6 +35,58 @@
.toolbar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap}
.msg{font-size:.72rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem}
.msg.ok{background:#dceadd;color:#5a8a5e}
/* ---- Datenmodell-Sektion ---- */
.dm-lead{font-size:.74rem;color:#4a4a4a;line-height:1.55;margin-bottom:.6rem}
.dm-sub{font-size:.6rem;font-weight:800;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin:1.2rem 0 .5rem}
.dm-note{font-size:.66rem;color:#8a6a2a;background:#fdf6e3;border:1px solid #efe0b8;border-radius:6px;padding:.45rem .6rem;margin-top:.5rem;line-height:1.5}
.dm-flow{display:flex;gap:.4rem;align-items:stretch;flex-wrap:wrap}
.dm-step{flex:1;min-width:190px;background:#f7f6f3;border-radius:8px;border-left:4px solid #c4a97a;padding:.65rem .75rem}
.dm-step.s-pool{border-left-color:#b8b2a6}
.dm-step.s-redeem{border-left-color:#4a7c8a}
.dm-step.s-assign{border-left-color:#5a8a5e}
.dm-step .s-top{display:flex;align-items:center;gap:.4rem;margin-bottom:.35rem}
.dm-step .s-n{width:18px;height:18px;border-radius:50%;background:#4a7c8a;color:#fff;font-size:.62rem;font-weight:800;display:flex;align-items:center;justify-content:center;flex:none}
.dm-step.s-pool .s-n{background:#b8b2a6}
.dm-step.s-assign .s-n{background:#5a8a5e}
.dm-step .s-t{font-size:.74rem;font-weight:800;color:#2f3b3f}
.dm-step .s-who{font-size:.6rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.03em}
.dm-step .s-d{font-size:.68rem;color:#4a4a4a;line-height:1.45;margin-top:.15rem}
.dm-step code{font-family:'Courier New',monospace;font-size:.66rem;background:rgba(0,0,0,.05);padding:.05rem .3rem;border-radius:4px;color:#c85c4a}
.dm-arrow{display:flex;align-items:center;justify-content:center;color:#c4a97a;font-size:1.4rem;font-weight:900;padding:0 .1rem}
.dm-er{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;font-size:.68rem}
.dm-ent{background:#fff;border:1.5px solid #4a7c8a;color:#4a7c8a;border-radius:6px;padding:.3rem .55rem;font-weight:800;font-family:'Courier New',monospace}
.dm-ent.e-lic{background:#4a7c8a;color:#fff}
.dm-rel{color:#8a8a8a;display:flex;flex-direction:column;align-items:center;line-height:1;font-family:'Courier New',monospace;font-size:.58rem}
.dm-rel b{color:#c4a97a;font-size:1.1rem}
.dm-derive{background:#f7f6f3;border-radius:8px;padding:.7rem .8rem;font-size:.7rem;line-height:1.7}
.dm-derive .d-code{font-family:'Courier New',monospace;font-weight:800;color:#4a7c8a;letter-spacing:.04em}
.dm-derive .d-hi{background:#e8c547;color:#4a4a4a;padding:.02rem .28rem;border-radius:4px;font-weight:800}
td.mono{font-family:'Courier New',monospace;font-size:.68rem;color:#c85c4a}
/* ---- Vertrieb / Ausgabe ---- */
.frm{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.6rem}
.frm label{font-size:.6rem;font-weight:700;color:#6a6a6a;text-transform:uppercase;letter-spacing:.03em;display:block;margin-bottom:.2rem}
.frm input,.frm select,.frm textarea{width:100%;padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit;background:#fff}
.frm textarea{resize:vertical;line-height:1.5}
.hint-s{font-size:.62rem;color:#8a8a8a;margin-top:.2rem}
.badge{display:inline-block;padding:.1rem .45rem;border-radius:20px;font-size:.58rem;font-weight:800;letter-spacing:.02em;color:#fff}
.b-issued{background:#4a7c8a}.b-redeemed{background:#5a8a5e}.b-assigned{background:#3f7a45}
.b-canceled{background:#b0453a}.b-expired{background:#c98a2a}.b-free{background:#9a9a9a}
.kpi{display:inline-flex;flex-direction:column;align-items:center;min-width:52px}
.kpi b{font-size:.9rem;font-weight:900;color:#3a4a4f}.kpi span{font-size:.52rem;color:#8a8a8a;text-transform:uppercase}
.out{background:#f2f8f9;border:1px solid #cfe3e8;border-radius:8px;padding:.8rem;margin-top:.8rem}
.out h4{font-size:.66rem;color:#2f6373;margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.04em}
.row-act{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center}
.lnk{font-family:'Courier New',monospace;font-size:.66rem;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:.35rem .5rem;flex:1;min-width:180px;color:#3a4a4f}
.ovl{position:fixed;inset:0;background:rgba(20,30,35,.55);display:none;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;padding:2rem 1rem}
.ovl.open{display:flex}
.modal{background:#fff;border-radius:12px;max-width:820px;width:100%;padding:1.1rem;box-shadow:0 12px 40px rgba(0,0,0,.25)}
.modal-h{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem}
.modal-h h3{font-size:.9rem;font-weight:800;color:#2f3b3f;flex:1}
.x{cursor:pointer;font-size:1.1rem;color:#8a8a8a;background:none;border:none;padding:.2rem .5rem}
.mini{font-size:.66rem;color:#8a8a8a}
.chip-copy{cursor:pointer}
details.batchdet{border:1px solid rgba(0,0,0,.06);border-radius:8px;margin-bottom:.4rem;padding:.2rem .5rem}
details.batchdet summary{cursor:pointer;font-size:.72rem;font-weight:600;padding:.3rem 0}
</style>
</head>
<body>
@@ -45,6 +97,7 @@
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
<a href="admin-todo.html" class="btn-sm">📋 ToDo / Review</a>
<a href="admin.html" class="btn-sm">Logout</a>
</div>
@@ -57,6 +110,7 @@
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
<a class="btn btn-outline" href="admin-todo.html">📋 ToDo / Review-Liste</a>
</div>
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
</div>
@@ -69,6 +123,111 @@
</div>
</div>
<!-- ============ Vertrieb: Charge ausgeben ============ -->
<div class="card">
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
<div class="frm">
<div>
<label>Anzahl (bestellt)</label>
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
</div>
<div>
<label>Typ</label>
<select id="iss-type">
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat" selected>Privat</option>
</select>
</div>
<div>
<label>Externe Bestellnummer</label>
<input type="text" id="iss-order" placeholder="z. B. Marktplatz/Ministerium" list="order-hints">
<div class="hint-s">Geht in die Mail (für die Rechnung des Kunden).</div>
</div>
<div>
<label>Kunde — Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="z. B. Verlag XY · VS Musterstadt">
</div>
<div>
<label>E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at">
</div>
<div>
<label>Gültig von</label>
<input type="date" id="iss-from">
</div>
<div>
<label>Gültig bis</label>
<input type="date" id="iss-until">
<div class="hint-s">Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).</div>
</div>
</div>
<datalist id="order-hints"></datalist>
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
</label>
<span id="iss-preview" class="mini"></span>
</div>
<div style="margin-top:.7rem" class="row-act">
<button class="btn" onclick="issueBatch()">Codes erzeugen &amp; ausgeben</button>
<span id="iss-msg" class="mini"></span>
</div>
<div id="iss-result"></div>
</div>
<!-- ============ Bestellungen / Chargen-Übersicht ============ -->
<div class="card">
<div class="card-h">🧾 Bestellungen &amp; Gültigkeit</div>
<div class="filter-bar">
<select id="bf-type" onchange="loadBatches()">
<option value="">Alle Typen</option>
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat">Privat</option>
</select>
<input type="text" id="bf-customer" placeholder="Kunde (E-Mail/Name/Nr.)" oninput="debouncedBatches()">
<input type="text" id="bf-order" placeholder="Bestellnummer" oninput="debouncedBatches()">
<button class="btn btn-outline" onclick="loadBatches()">Aktualisieren</button>
</div>
<div id="batch-table" style="overflow-x:auto"></div>
</div>
<!-- ============ Mail-Einstellungen ============ -->
<div class="card">
<div class="card-h">✉️ Mail-Einstellungen</div>
<div class="frm">
<div>
<label>Versand-Treiber</label>
<select id="mc-provider">
<option value="server">Eigener Server (SMTP)</option>
<option value="aws">Amazon SES (später)</option>
</select>
<div class="hint-s">AWS wird aktiv, sobald konfiguriert.</div>
</div>
<div>
<label>Absender-Name</label>
<input type="text" id="mc-fromname" placeholder="GeoGraSim">
</div>
<div>
<label>Absender-Adresse</label>
<input type="email" id="mc-fromemail" placeholder="support@geograsim.at">
<div class="hint-s">SMTP-Zugang bleibt geheim in der .env.</div>
</div>
<div>
<label>Öffentliche Basis-URL</label>
<input type="text" id="mc-publicurl" placeholder="https://geograsim.at">
<div class="hint-s">Für Links in Mails (nicht v3/localhost).</div>
</div>
</div>
<div class="row-act" style="margin-top:.6rem">
<button class="btn" onclick="saveMailConfig()">Speichern</button>
<input type="email" id="mc-testto" placeholder="Test an…" style="padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem">
<button class="btn btn-outline" onclick="testMail()">Test senden</button>
<span id="mc-msg" class="mini"></span>
</div>
</div>
<div class="card">
<div class="card-h">Neue Lizenzen generieren</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
@@ -102,6 +261,84 @@
<button class="btn btn-outline" id="btn-next" onclick="nextPage()">Weiter →</button>
</div>
</div>
<!-- ============ Datenmodell-Referenz ============ -->
<div class="card">
<div class="card-h">📐 Datenmodell — Lizenznummern</div>
<p class="dm-lead">Eine Lizenz = <b>ein Sitzplatz für eine:n Schüler:in in einem Schuljahr</b>. Der Admin erzeugt einen Vorrat an Codes, eine Lehrperson <b>löst</b> Codes ein (sie werden ihrem Konto zugeordnet) und <b>weist</b> sie dann einzelnen Schüler:innen zu. Alles steht in <b>einer</b> Tabelle <span class="code">licenses</span> — die drei Zustände ergeben sich allein aus zwei Feldern: <span class="code">teacher_id</span> und <span class="code">student_id</span>.</p>
<div class="dm-sub">Tabelle „licenses"</div>
<table>
<tr><th>Spalte</th><th>Typ</th><th>Bedeutung</th></tr>
<tr><td class="mono">id</td><td>INT · PK</td><td>Interne ID</td></tr>
<tr><td class="mono">code</td><td>VARCHAR · UNIQUE</td><td>Der Lizenzcode, Format <span class="code">XXXX-XXXX-XXXX-JAHR</span></td></tr>
<tr><td class="mono">school_year</td><td>VARCHAR</td><td>Schuljahr, z.&nbsp;B. „26/27" — aus dem Jahr abgeleitet</td></tr>
<tr><td class="mono">teacher_id</td><td>FK → teachers</td><td>Einlösende Lehrperson · <b>NULL = noch im Pool</b></td></tr>
<tr><td class="mono">student_id</td><td>FK → students</td><td>Platz-Inhaber:in · <b>NULL = noch frei</b></td></tr>
<tr><td class="mono">redeemed_at</td><td>TIMESTAMP · NULL</td><td>Zeitpunkt des Einlösens durch die Lehrperson</td></tr>
<tr><td class="mono">created_at</td><td>TIMESTAMP</td><td>Erstellung (Admin-Generierung)</td></tr>
</table>
<div class="dm-sub">Lebenszyklus &amp; Einlösen</div>
<div class="dm-flow">
<div class="dm-step s-pool">
<div class="s-top"><span class="s-n">1</span><span class="s-t">Generiert</span></div>
<div class="s-who">Admin</div>
<div class="s-d"><code>teacher_id</code> = NULL · <code>student_id</code> = NULL. Frisch erzeugter Code im Pool.<br>Aktion: <code>generate</code></div>
</div>
<div class="dm-arrow"></div>
<div class="dm-step s-redeem">
<div class="s-top"><span class="s-n">2</span><span class="s-t">Eingelöst</span></div>
<div class="s-who">Lehrperson</div>
<div class="s-d"><code>teacher_id</code> gesetzt · <code>redeemed_at</code> = jetzt · <code>student_id</code> noch NULL. Code gehört nun der Lehrperson.<br>Aktion: <code>redeem</code> / <code>redeem_batch</code></div>
</div>
<div class="dm-arrow"></div>
<div class="dm-step s-assign">
<div class="s-top"><span class="s-n">3</span><span class="s-t">Zugewiesen</span></div>
<div class="s-who">Lehrperson</div>
<div class="s-d"><code>student_id</code> gesetzt. Der Platz ist von einer konkreten Person belegt.<br>Aktion: <code>assign</code> / <code>auto_assign</code></div>
</div>
</div>
<div class="dm-note">⚠️ In der Statistik oben zählt <b>„Frei"</b> = <span class="code">student_id IS NULL</span> — also auch schon <b>eingelöste</b>, aber noch nicht zugewiesene Codes. <b>„Zugewiesen"</b> = <span class="code">student_id</span> gesetzt. Ob ein freier Code schon einer Lehrperson gehört, sieht man an <span class="code">teacher_id</span> / <span class="code">redeemed_at</span>.</div>
<div class="dm-sub">Beziehungen</div>
<div class="dm-er" style="margin-bottom:.5rem">
<span class="dm-ent e-lic">licenses</span>
<span class="dm-rel"><b></b>teacher_id</span>
<span class="dm-ent">teachers</span>
</div>
<div class="dm-er">
<span class="dm-ent e-lic">licenses</span>
<span class="dm-rel"><b></b>student_id</span>
<span class="dm-ent">students</span>
<span class="dm-rel"><b></b>class_id</span>
<span class="dm-ent">classes</span>
<span class="dm-rel"><b></b>teacher_id</span>
<span class="dm-ent">teachers</span>
</div>
<p class="dm-lead" style="margin-top:.5rem"><span class="code">licenses.teacher_id</span> merkt sich die <b>einlösende</b> Lehrperson, <span class="code">licenses.student_id</span> den <b>Platz-Inhaber</b>. Über <span class="code">students.class_id → classes.teacher_id</span> hängt jede:r Schüler:in ohnehin an einer Klasse einer Lehrperson — der direkte <span class="code">teacher_id</span>-Eintrag macht aber auch <b>eingelöste, noch nicht zugewiesene</b> Codes eindeutig einer Lehrperson zuordenbar.</p>
<div class="dm-sub">Jahr → Schuljahr → Code</div>
<div class="dm-derive">
Eingabe beim Generieren: <b>Jahr</b> = <span class="d-code">2026</span> (Kalenderjahr, in dem das Schuljahr <b>beginnt</b>).<br>
<b>school_year</b> = „letzte 2 Ziffern" &nbsp;<span class="d-code">26</span>&nbsp; / &nbsp;<span class="d-code">26+1</span>&nbsp; = <span class="d-hi">26/27</span><br>
<b>Code-Suffix</b> = das volle Jahr: <span class="d-code">-2026</span><br>
<b>Zeichen-Alphabet</b> (ohne Verwechsler): <span class="code">AZ ohne I,O</span> + <span class="code">29</span> (kein 0, kein 1)<br>
→ 3 Blöcke à 4 Zeichen + Jahr &nbsp;&nbsp; Beispiel: <span class="d-code">K7QF-3M9P-RT2W-2026</span> &nbsp;·&nbsp; Schuljahr <span class="d-hi">26/27</span>
</div>
<div class="dm-note" style="background:#eef4f6;border-color:#cfe0e6;color:#3a5b64">Das <b>Jahr</b> steckt fest im Code (Suffix) und bestimmt <b>school_year</b>. Für ein neues Schuljahr wird ein neuer Vorrat generiert; Filter/Export laufen über <span class="code">school_year</span>.</div>
</div>
</div>
<!-- Detail-Modal einer Bestellung -->
<div class="ovl" id="modal-ovl">
<div class="modal">
<div class="modal-h">
<h3 id="m-title">Bestellung</h3>
<button class="x" onclick="closeModal()"></button>
</div>
<div id="m-body"></div>
</div>
</div>
<script>
@@ -172,12 +409,281 @@ function exportFree() {
});
}
// ═══════════════ Vertrieb: Ausgeben ═══════════════
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function today(){ return new Date().toISOString().slice(0,10); }
function fmtDate(d){ if(!d) return '—'; var p=String(d).slice(0,10).split('-'); return p.length===3 ? p[2]+'.'+p[1]+'.'+p[0] : d; }
function absUrl(path){ return location.origin + BASE + path; }
var lastIssue = null;
async function prefillDefaults(){
var r = await api('licenses?admin=1&sales_default=1');
if (r && r.valid_from) {
if(!document.getElementById('iss-from').value) document.getElementById('iss-from').value = r.valid_from;
if(!document.getElementById('iss-until').value) document.getElementById('iss-until').value = r.valid_until;
}
}
var custTimer=null;
function custSuggest(v){
if(custTimer) clearTimeout(custTimer);
if(!v || v.length<2) return;
custTimer = setTimeout(async function(){
var r = await api('licenses?admin=1&customers='+encodeURIComponent(v));
if(!r || !r.customers) return;
var dl = document.getElementById('cust-hints');
dl.innerHTML = r.customers.map(function(c){ return '<option value="'+esc(c.email||'')+'">'+esc((c.name||'')+(c.customer_no?' · '+c.customer_no:''))+'</option>'; }).join('');
}, 250);
}
async function issueBatch(){
var count = parseInt(document.getElementById('iss-count').value)||0;
var type = document.getElementById('iss-type').value;
var order = document.getElementById('iss-order').value.trim();
var email = document.getElementById('iss-email').value.trim();
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
var bonus = document.getElementById('iss-bonus').checked;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var delivered = calcDelivered(count, bonus);
var who = name||email||'(kein Kunde)';
if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
msg.textContent = 'Erzeuge …';
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
customer:{email:email, name:name}, valid_from:vf, valid_until:vu});
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</span>';
lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
renderIssueResult();
loadBatches();
loadLicenses();
}
function renderIssueResult(){
var m = lastIssue; if(!m) return;
var codesText = m.codes.join('\n');
var mailText = buildMailText(m);
var h = '<div class="out">'
+ '<div class="mini" style="margin-bottom:.5rem">Bestellung <b>#'+m.internal_no+'</b> · bestellt <b>'+m.ordered+'</b> · geliefert <b>'+m.count+'</b>'+(m.count>m.ordered?' <span style="color:#5a8a5e">(+'+(m.count-m.ordered)+' gratis)</span>':'')+' · gültig bis '+fmtDate(m.valid_until)+'</div>'
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
+ '<div class="row-act" style="margin-top:.4rem">'
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</button>'
+ '</div>'
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</h4>'
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
+ '</div>'
+ '<label style="margin-top:.5rem;display:block">Mailtext (editierbar)</label>'
+ '<textarea id="mail-body" rows="10">'+esc(mailText)+'</textarea>'
+ '<div class="hint-s">'+(m.count>40?'Große Charge: die Codes gehen automatisch als .txt- und .csv-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.</div>'
+ '<div class="row-act" style="margin-top:.4rem"><button class="btn" onclick="sendMail('+m.batch_id+')">✉ Absenden</button><span id="mail-msg" class="mini"></span></div>'
+ '</div>';
document.getElementById('iss-result').innerHTML = h;
}
function buildMailText(m){
var codesPart = (m.count>40) ? 'Die '+m.count+' Codes finden Sie in der angehängten Datei „lizenzcodes.txt".' : m.codes.join('\n');
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
+ codesPart + '\n\n'
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
+ 'Freundliche Grüße\nGeoGraSim';
}
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
// Nur die Codes (eine pro Zeile), KEINE Kopfzeile — direkt per Copy-Paste einlösbar.
function downloadCodes(fmt){
var m = lastIssue; if(!m) return;
var content = m.codes.join('\r\n')+'\r\n';
var mime = fmt==='csv' ? 'text/csv' : 'text/plain';
var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
}
function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
function updatePreview(){
var o=parseInt(document.getElementById('iss-count').value)||0;
var b=document.getElementById('iss-bonus').checked;
var el=document.getElementById('iss-preview'); if(!el) return;
if(o<1){ el.textContent=''; return; }
var d=calcDelivered(o,b);
el.innerHTML = b ? ('→ im Paket <b>'+d+'</b> Codes (+'+(d-o)+' gratis)') : ('→ <b>'+d+'</b> Codes (keine Zugabe)');
}
// ── Mail-Einstellungen ──
var PUBLIC_URL='https://geograsim.at';
async function loadMailConfig(){
var r=await api('licenses?admin=1&mail_config=1');
if(!r || r.error) return;
PUBLIC_URL=r.public_url||PUBLIC_URL;
document.getElementById('mc-provider').value=r.provider||'server';
document.getElementById('mc-fromname').value=r.from_name||'';
document.getElementById('mc-fromemail').value=r.from_email||'';
document.getElementById('mc-publicurl').value=r.public_url||'';
}
async function saveMailConfig(){
var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
var r=await api('licenses',{action:'set_mail_config',
provider:document.getElementById('mc-provider').value,
from_name:document.getElementById('mc-fromname').value.trim(),
from_email:document.getElementById('mc-fromemail').value.trim(),
public_url:document.getElementById('mc-publicurl').value.trim()});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gespeichert.</span>'; loadMailConfig();
}
async function testMail(){
var to=document.getElementById('mc-testto').value.trim();
var mm=document.getElementById('mc-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Test-Adresse fehlt.</span>'; return; }
mm.textContent='Sende Test …';
var r=await api('licenses',{action:'test_mail', to_email:to});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Test gesendet.</span>';
}
async function sendMail(batchId){
var to=document.getElementById('mail-to').value.trim();
var subj=document.getElementById('mail-subj').value.trim();
var body=document.getElementById('mail-body').value;
var mm=document.getElementById('mail-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Empfänger fehlt.</span>'; return; }
mm.textContent='Sende …';
var r=await api('licenses',{action:'send_batch_mail', batch_id:batchId, to_email:to, subject:subj, body_text:body});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gesendet &amp; archiviert.</span>';
}
// ═══════════════ Bestellungen / Übersicht ═══════════════
var batchTimer=null;
function debouncedBatches(){ if(batchTimer)clearTimeout(batchTimer); batchTimer=setTimeout(loadBatches,300); }
async function loadBatches(){
var qs = 'licenses?admin=1&view=batches';
var t=document.getElementById('bf-type').value; if(t) qs+='&type='+encodeURIComponent(t);
var c=document.getElementById('bf-customer').value.trim(); if(c) qs+='&customer='+encodeURIComponent(c);
var o=document.getElementById('bf-order').value.trim(); if(o) qs+='&order_no='+encodeURIComponent(o);
var r=await api(qs);
if(!r || !r.batches){ document.getElementById('batch-table').innerHTML='<div class="mini">—</div>'; return; }
var typeLabel={marktplatz:'Marktplatz',direktvertrieb:'Direktvertrieb',privat:'Privat'};
var html='<table><tr><th>#</th><th>Bestellnr.</th><th>Kunde</th><th>Typ</th><th>Erstellt</th><th>Gültig bis</th>'
+ '<th>Ausgeg.</th><th>Storno</th><th>Netto</th><th>Eingelöst</th><th>Abgel.</th><th></th></tr>';
r.batches.forEach(function(b){
var net=(b.n_total|0)-(b.n_canceled|0);
var cust=esc(b.customer_name||b.customer_email||b.customer_no||'—');
var canceled=b.canceled_at?' <span class="badge b-canceled">storniert</span>':'';
html+='<tr>'
+'<td>'+b.id+'</td>'
+'<td class="mono">'+esc(b.order_no||'—')+'</td>'
+'<td>'+cust+canceled+'</td>'
+'<td>'+esc(typeLabel[b.sales_type]||b.sales_type)+'</td>'
+'<td>'+fmtDate(b.created_at)+'</td>'
+'<td>'+fmtDate(b.default_valid_until)+'</td>'
+'<td>'+(b.n_total|0)+'</td>'
+'<td>'+((b.n_canceled|0)?'<b style="color:#b0453a">'+b.n_canceled+'</b>':'0')+'</td>'
+'<td><b>'+net+'</b></td>'
+'<td>'+(b.n_redeemed|0)+'</td>'
+'<td>'+((b.n_expired|0)?'<b style="color:#c98a2a">'+b.n_expired+'</b>':'0')+'</td>'
+'<td><button class="btn-sm" onclick="openBatch('+b.id+')">Details</button></td>'
+'</tr>';
});
html+='</table>';
document.getElementById('batch-table').innerHTML=html;
}
function codeStatus(l){
if(l.canceled_at) return '<span class="badge b-canceled">storniert</span>';
var base = l.student_id ? '<span class="badge b-assigned">zugewiesen</span>'
: l.redeemed_at ? '<span class="badge b-redeemed">eingelöst</span>'
: l.issued_at ? '<span class="badge b-issued">vergeben</span>'
: '<span class="badge b-free">frei</span>';
var exp = l.valid_until && String(l.valid_until).slice(0,10) < today();
return base + (exp?' <span class="badge b-expired">abgelaufen</span>':'');
}
async function openBatch(id){
var r=await api('licenses?admin=1&view=batch_detail&batch_id='+id);
if(r.error){ alert(r.error); return; }
var b=r.batch;
var cust=esc(b.customer_name||'')+(b.customer_email?' · '+esc(b.customer_email):'')+(b.customer_no?' · Nr. '+esc(b.customer_no):'');
var h='<div class="mini" style="margin-bottom:.6rem">'
+ 'Typ <b>'+esc(b.sales_type)+'</b> · Bestellnr. <b>'+esc(b.order_no||'—')+'</b> · Kunde '+(cust||'—')
+ ' · gültig '+fmtDate(b.default_valid_from)+''+fmtDate(b.default_valid_until)
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
h+='<div class="row-act" style="margin-bottom:.6rem">'
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
r.codes.forEach(function(l){
h+='<tr><td class="code">'+esc(l.code)+'</td><td>'+codeStatus(l)+'</td><td>'+fmtDate(l.valid_until)+'</td>'
+'<td class="mini">'+esc(l.teacher_name||'—')+'</td>'
+'<td class="row-act">'
+ (l.canceled_at?'':'<button class="btn-sm" onclick="extendLicense('+l.id+',\''+esc(l.valid_until||'')+'\')">📅</button>'
+ '<button class="btn-sm" onclick="cancelLicense('+l.id+')">✖</button>')
+'</td></tr>';
});
h+='</table></div>';
if(r.mails && r.mails.length){
h+='<div class="dm-sub">Mail-Verlauf</div><table>';
r.mails.forEach(function(mm){ h+='<tr><td class="mini">'+fmtDate(mm.created_at)+'</td><td class="mini">'+esc(mm.to_email)+'</td><td><span class="badge '+(mm.status==='sent'?'b-redeemed':mm.status==='failed'?'b-canceled':'b-free')+'">'+esc(mm.status)+'</span></td><td class="mini">'+esc(mm.error||'')+'</td></tr>'; });
h+='</table>';
}
if(r.events && r.events.length){
h+='<div class="dm-sub">Verlauf</div><div class="mini">';
r.events.forEach(function(e){ h+=fmtDate(e.created_at)+' · <b>'+esc(e.event)+'</b> '+esc(e.detail||'')+'<br>'; });
h+='</div>';
}
document.getElementById('m-title').textContent='Bestellung #'+b.id;
document.getElementById('m-body').innerHTML=h;
document.getElementById('modal-ovl').classList.add('open');
}
function closeModal(){ document.getElementById('modal-ovl').classList.remove('open'); }
function copyEl2(btn){ var inp=btn.previousElementSibling; inp.select(); navigator.clipboard.writeText(inp.value).catch(function(){document.execCommand('copy');}); }
async function cancelBatch(id){
var reason=prompt('Ganze Charge stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_batch', batch_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function cancelLicense(id){
var reason=prompt('Diesen Code stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_license', license_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
async function extendBatch(id,cur){
var vu=prompt('Charge verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_batch', batch_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function extendLicense(id,cur){
var vu=prompt('Code verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_license', license_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
var _curBatch=null;
function reopenCurrent(){ var t=document.getElementById('m-title').textContent.match(/#(\d+)/); if(t) openBatch(parseInt(t[1])); }
// Admin auth check (2FA required)
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
.then(function(r){return r.json()})
.then(function(r) {
if (!r.authenticated) { location.href = 'admin.html'; return; }
loadLicenses();
prefillDefaults();
loadBatches();
loadMailConfig();
updatePreview();
});
</script>
</body>
+2
View File
@@ -90,6 +90,7 @@
<a href="admin-levels.html" class="btn-sm">Level</a>
<a href="admin-licenses.html" class="btn-sm">Lizenzen</a>
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
<a href="admin-lehrer-view.html" class="btn-sm">📊 Lehrer-View Parameter</a>
<a href="index.html" class="btn-sm">Startseite</a>
</div>
@@ -249,6 +250,7 @@
<div class="pageitem"><a href="admin-modules.html">Module</a><span class="url">/admin-modules.html</span></div>
<div class="pageitem"><a href="admin-levels.html">Level / Waypoints</a><span class="url">/admin-levels.html</span></div>
<div class="pageitem"><a href="admin-styleguide.html">Styleguide</a><span class="url">/admin-styleguide.html</span></div>
<div class="pageitem"><a href="admin-lehrer-view.html">Lehrer-View Parameter-Überblick</a><span class="url">/admin-lehrer-view.html</span></div>
</div>
</div>
</details>
+157
View File
@@ -64,6 +64,8 @@
<a href="#bildstil">🎨 Bildstil</a>
<a href="#farben-bild">🌿 Bild-Farbpalette</a>
<a href="#farben-ui">🧩 UI-Farbtokens</a>
<a href="#leistung">📊 Leistungs-Skala</a>
<a href="#kopfzeile">🎩 Kopfzeile</a>
<a href="#layout">📐 Layout &amp; Spacing</a>
<a href="#typo">🔤 Typografie</a>
<a href="#bilder">🖼️ Bildformate</a>
@@ -195,6 +197,161 @@
</div>
</div>
<!-- ============================================================== -->
<div class="card" id="leistung">
<div class="card-h">Leistungs-Skala — 5 Stufen (Standard für ALLE Sims)</div>
<p class="lead">EINE Skala für Schülerleistung überall im Programm: Prozentwert (0100) + dünner Balken in der Ampelfarbe. <strong>Sterne entfallen.</strong> Die Grenzen <strong>50 / 80 / 90</strong> sind zugleich die Triage-Grenzen (ab 90 % „Fordern", ab 80 % „Läuft gut", ab 50 % „Begleiten", darunter „Fördern"), damit Balkenfarbe und Feld immer übereinstimmen. Quelle in Code: <code>pctColor()</code> = <code>_tagTraffic()</code>.</p>
<div style="display:grid;grid-template-columns:26px auto 1fr 90px;gap:.55rem 1rem;align-items:center;max-width:520px;margin:.7rem 0 1.3rem;font-size:.85rem">
<span style="width:22px;height:22px;border-radius:6px;background:#1f7a3d"></span><b>≥ 90 %</b><span style="color:#5a5a5a">sehr gut &nbsp;<code style="font-size:.72rem">#1f7a3d</code></span><div><div style="font-weight:800;color:#1f7a3d;font-size:.82rem">90 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:90%;background:#1f7a3d"></div></div></div>
<span style="width:22px;height:22px;border-radius:6px;background:#7cb342"></span><b>8089 %</b><span style="color:#5a5a5a">gut &nbsp;<code style="font-size:.72rem">#7cb342</code></span><div><div style="font-weight:800;color:#7cb342;font-size:.82rem">80 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:80%;background:#7cb342"></div></div></div>
<span style="width:22px;height:22px;border-radius:6px;background:#f9c33b"></span><b>6079 %</b><span style="color:#5a5a5a">solide &nbsp;<code style="font-size:.72rem">#f9c33b</code></span><div><div style="font-weight:800;color:#f9c33b;font-size:.82rem">60 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:60%;background:#f9c33b"></div></div></div>
<span style="width:22px;height:22px;border-radius:6px;background:#ef8b2c"></span><b>5059 %</b><span style="color:#5a5a5a">wackelig &nbsp;<code style="font-size:.72rem">#ef8b2c</code></span><div><div style="font-weight:800;color:#ef8b2c;font-size:.82rem">50 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:50%;background:#ef8b2c"></div></div></div>
<span style="width:22px;height:22px;border-radius:6px;background:#d8453a"></span><b>&lt; 50 %</b><span style="color:#5a5a5a">kritisch &nbsp;<code style="font-size:.72rem">#d8453a</code></span><div><div style="font-weight:800;color:#d8453a;font-size:.82rem">40 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:40%;background:#d8453a"></div></div></div>
</div>
<h3>Triage-Felder (aus % × Fleiß)</h3>
<p><strong>&lt; 50 % → Fördern</strong> (Hilfe) · <strong>5079 % → Begleiten</strong> · <strong>8089 % → Läuft gut</strong> · <strong>≥ 90 % → Fordern</strong> (mehr herausfordern) — jeweils nur, wenn aktiv gearbeitet wurde, sonst „inaktiv".</p>
<div style="display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 1rem">
<span style="font-size:.8rem;font-weight:700;color:#b04a3e;background:#f7e2dc;padding:.25rem .7rem;border-radius:12px">Fördern</span>
<span style="font-size:.8rem;font-weight:700;color:#9a6a12;background:#f7edd6;padding:.25rem .7rem;border-radius:12px">Begleiten</span>
<span style="font-size:.8rem;font-weight:700;color:#3a6b3e;background:#dceadd;padding:.25rem .7rem;border-radius:12px">Läuft gut</span>
<span style="font-size:.8rem;font-weight:700;color:#155e2a;background:#c8e6cd;padding:.25rem .7rem;border-radius:12px">Fordern</span>
<span style="font-size:.8rem;font-weight:700;color:#8a8a8a;background:#efeee8;padding:.25rem .7rem;border-radius:12px">inaktiv</span>
</div>
<h3>CSS-Variablen</h3>
<div style="background:#1f2733;border-radius:8px;padding:1rem;overflow:auto"><pre style="margin:0;color:#e8eef4;font-size:.78rem;font-family:'Courier New',monospace">--ggs-perf-5: #1f7a3d; /* ≥ 90 % sehr gut */
--ggs-perf-4: #7cb342; /* 8089 % gut */
--ggs-perf-3: #f9c33b; /* 6079 % solide */
--ggs-perf-2: #ef8b2c; /* 5059 % wackelig */
--ggs-perf-1: #d8453a; /* &lt; 50 % kritisch */
/* Triage */
--ggs-triage-foerdern: #b04a3e;
--ggs-triage-begleiten: #9a6a12;
--ggs-triage-gut: #3a6b3e;
--ggs-triage-inaktiv: #8a8a8a;</pre></div>
<p style="font-size:.78rem;color:#8a8a8a;margin-top:.6rem">Vorschau der Zuordnung je Sim: <a href="admin-lehrer-view.html">Lehrer-View Parameter-Überblick</a>.</p>
</div>
<!-- ============================================================== -->
<div class="card" id="kopfzeile">
<div class="card-h">Kopfzeile — Entwurf B (schwebend)</div>
<p class="lead">Standard-Kopfzeile für ALLE Sims: <strong>Kreis-Logo · Titel · Level</strong> als eine schwebende Pille — Home und rechts die Steuerung: <strong>Tempo</strong> (nur Echtzeit-Sims) · Musik · Ton · Neustart · Hilfe · Glossar (<code>target="_blank"</code>). <strong>Eigene Icons im App-Stil</strong> statt Emojis, alle Elemente auf <strong>gleicher Höhe</strong>. Lehrplan entfällt. Musik überall (Klima-Track als Platzhalter).</p>
<style>
.khz-scene{position:relative;border-radius:12px;overflow:hidden;background:linear-gradient(135deg,#a7cbe0 0%,#8bb98d 55%,#e6dca6 100%);border:1px solid #cfd6cf}
.khz-scene .khz-sky{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.5);font-size:1.5rem;font-weight:800;letter-spacing:.08em;pointer-events:none}
.khz-scene .khz-content{position:absolute;left:14px;bottom:12px;display:flex;gap:8px;pointer-events:none}
.khz-scene .khz-content i{width:70px;height:44px;border-radius:8px;background:rgba(255,255,255,.35);display:block}
/* Alle Header-Elemente 38px hoch, vertikal zentriert (Logo·Titel·Level = eine Pille) */
.ghdr{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:.5rem;padding:.55rem .7rem;z-index:3}
.ghdr .sp{flex:1}
.ghdr .grp{display:flex;align-items:center;gap:.5rem;height:38px;padding:0 .8rem 0 .3rem;background:rgba(255,255,255,.96);border-radius:19px;box-shadow:0 2px 9px rgba(0,0,0,.2)}
.ghdr .grp .logo{width:30px;height:30px;border-radius:50%;background:#1f4e5a;flex:none;overflow:hidden;display:flex;align-items:center;justify-content:center}
.ghdr .grp .logo img{width:100%;height:100%;object-fit:cover}
.ghdr .grp .title{font-weight:800;color:#1f4e5a;font-size:.92rem;white-space:nowrap;line-height:1}
.ghdr .grp .ctx{font-size:.6rem;font-weight:800;color:#fff;background:#5a8a5e;border-radius:9px;padding:.2rem .5rem;line-height:1;white-space:nowrap}
.ghdr .btn{width:38px;height:38px;border-radius:12px;border:none;background:rgba(255,255,255,.96);box-shadow:0 2px 9px rgba(0,0,0,.2);display:flex;align-items:center;justify-content:center;cursor:pointer;color:#1f4e5a;padding:0}
.ghdr .btn .ic{width:20px;height:20px}
.khz-variant{margin:1rem 0}
.khz-frame-lab{font-size:.66rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.04em;margin:.9rem 0 .3rem}
.khz-ipad{height:150px}
.khz-fhd{height:120px}
.khz-scroll{overflow-x:auto}
/* Aufgeklappte Steuerung */
.khz-pop{display:inline-flex;align-items:center;gap:.25rem;height:38px;padding:0 .3rem;background:rgba(255,255,255,.7);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);border-radius:12px;box-shadow:0 2px 9px rgba(0,0,0,.14);border:1px solid rgba(255,255,255,.5);vertical-align:middle}
.khz-pop .ic{width:16px;height:16px;color:#1f4e5a}
.khz-seg{height:30px;min-width:36px;padding:0 .6rem;border:none;background:transparent;border-radius:8px;font-weight:800;font-size:.82rem;color:#4a4a4a;cursor:pointer;display:flex;align-items:center;justify-content:center}
.khz-seg.on{background:#4a7c8a;color:#fff}
.khz-seg.on .ic{color:#fff}
.khz-music{gap:.5rem;padding:0 .55rem}
.khz-select{height:30px;border:1px solid rgba(0,0,0,.12);border-radius:8px;padding:0 .55rem;font-size:.78rem;color:#1f4e5a;font-weight:700;background:#f7f6f3;display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap}
.khz-range{width:96px;height:6px;border-radius:99px;background:linear-gradient(90deg,#4a7c8a 0 62%,#dfe6e9 62% 100%);position:relative}
.khz-range::after{content:'';position:absolute;left:62%;top:50%;transform:translate(-50%,-50%);width:14px;height:14px;border-radius:50%;background:#4a7c8a;box-shadow:0 1px 3px rgba(0,0,0,.3)}
/* Tempo-Button zeigt IMMER den aktuellen Stand */
.khz-spd{width:38px;height:38px;border-radius:12px;border:none;background:#fff;box-shadow:0 2px 9px rgba(0,0,0,.2);display:inline-flex;flex-direction:column;align-items:center;justify-content:center;color:#1f4e5a;font-weight:800;line-height:1;cursor:pointer;vertical-align:middle}
.khz-spd b{font-size:.82rem}
.khz-spd small{font-size:.5rem;font-weight:700;margin-top:1px;color:#4a7c8a;letter-spacing:.02em}
.khz-spd.act{background:#4a7c8a;color:#fff}
.khz-spd.act small{color:#dbe9ee}
.khz-spd .ic{width:18px;height:18px;color:inherit}
</style>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11.4 12 4l8 7.4"/><path d="M6 10.3V19a1 1 0 0 0 1 1h3v-5h4v5h3a1 1 0 0 0 1-1v-8.7"/></symbol>
<symbol id="ic-tempo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.6 16.5a7.5 7.5 0 0 1 14.8 0"/><path d="M12 16 15.2 12"/><circle cx="12" cy="16.3" r="1.3" fill="currentColor" stroke="none"/></symbol>
<symbol id="ic-music" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17V6.4l9-1.8V15"/><circle cx="6.5" cy="17" r="2.4"/><circle cx="15.5" cy="15" r="2.4"/></symbol>
<symbol id="ic-sound" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5h3.2L11 6v12l-3.8-3.5H4z"/><path d="M14.6 9.6a4 4 0 0 1 0 4.8"/><path d="M17 7.4a7 7 0 0 1 0 9.2"/></symbol>
<symbol id="ic-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12a7 7 0 1 1-2-4.9"/><path d="M19 4.5V9h-4.5"/></symbol>
<symbol id="ic-help" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.4"/><path d="M9.7 9.3a2.4 2.4 0 0 1 4.6.8c0 1.6-2.3 1.8-2.3 3.2"/><circle cx="12" cy="16.4" r=".7" fill="currentColor" stroke="none"/></symbol>
<symbol id="ic-glossar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.6C10 5.2 7 5.2 5 6.2v11c2-1 5-1 7 .4"/><path d="M12 6.6c2-1.4 5-1.4 7-.4v11c-2-1-5-1-7 .4"/><path d="M12 6.6v11.4"/></symbol>
<symbol id="ic-pause" viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1.2" fill="currentColor"/><rect x="14" y="5" width="4" height="14" rx="1.2" fill="currentColor"/></symbol>
<symbol id="ic-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10l5 5 5-5"/></symbol>
</defs></svg>
<p style="font-size:.78rem;color:#4a4a4a;margin:.2rem 0 .3rem">Die Stufen hinter der Zeile deuten den Sim-Inhalt an — so sieht man, wie wenig die schwebende Zeile verdeckt. Logo, Titel und Level sitzen in <b>einer</b> Pille auf gleicher Höhe wie die Buttons.</p>
<div class="khz-variant">
<div class="khz-frame-lab">📱 iPad-Landscape (~1024 px)</div>
<div class="khz-scene khz-ipad" style="max-width:1024px">
<div class="khz-sky">SIM-INHALT</div><div class="khz-content"><i></i><i></i><i></i></div>
<div class="ghdr">
<div class="grp"><span class="logo"><img src="assets/img/bildLogo.png" alt=""></span><span class="title">Klimawächter</span><span class="ctx">Level 2</span></div>
<button class="btn" title="Startseite"><svg class="ic"><use href="#ic-home"/></svg></button>
<span class="sp"></span>
<button class="btn" title="Tempo"><svg class="ic"><use href="#ic-tempo"/></svg></button>
<button class="btn" title="Musik"><svg class="ic"><use href="#ic-music"/></svg></button>
<button class="btn" title="Ton"><svg class="ic"><use href="#ic-sound"/></svg></button>
<button class="btn" title="Neustart"><svg class="ic"><use href="#ic-restart"/></svg></button>
<button class="btn" title="Hilfe"><svg class="ic"><use href="#ic-help"/></svg></button>
<button class="btn" title="Glossar (neuer Tab)"><svg class="ic"><use href="#ic-glossar"/></svg></button>
</div>
</div>
<div class="khz-frame-lab">🖥️ Full-HD PC (1920 px — mehr Platz zwischen den Elementen)</div>
<div class="khz-scroll"><div class="khz-scene khz-fhd" style="width:1600px">
<div class="khz-sky">SIM-INHALT</div><div class="khz-content"><i></i><i></i><i></i><i></i><i></i></div>
<div class="ghdr">
<div class="grp"><span class="logo"><img src="assets/img/bildLogo.png" alt=""></span><span class="title">Klimawächter</span><span class="ctx">Level 2</span></div>
<button class="btn" title="Startseite"><svg class="ic"><use href="#ic-home"/></svg></button>
<span class="sp"></span>
<button class="btn" title="Tempo"><svg class="ic"><use href="#ic-tempo"/></svg></button>
<button class="btn" title="Musik"><svg class="ic"><use href="#ic-music"/></svg></button>
<button class="btn" title="Ton"><svg class="ic"><use href="#ic-sound"/></svg></button>
<button class="btn" title="Neustart"><svg class="ic"><use href="#ic-restart"/></svg></button>
<button class="btn" title="Hilfe"><svg class="ic"><use href="#ic-help"/></svg></button>
<button class="btn" title="Glossar (neuer Tab)"><svg class="ic"><use href="#ic-glossar"/></svg></button>
</div>
</div></div>
</div>
<div class="khz-frame-lab">Aufgeklappt · Tempo — öffnet LINKS vom Button, bleibt offen (nur Echtzeit-Sims) · 30 % transparent</div>
<div class="khz-pop">
<button class="khz-seg" title="Pause"><svg class="ic"><use href="#ic-pause"/></svg></button>
<button class="khz-seg on">1×</button>
<button class="khz-seg">2×</button>
<button class="khz-seg">4×</button>
</div>
<div class="khz-frame-lab">Tempo-Button im Header — zeigt IMMER den aktuellen Stand</div>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<button class="khz-spd" title="Normal"><b>1×</b></button>
<button class="khz-spd" title="Doppelt"><b>2×</b></button>
<button class="khz-spd act" title="Logistik: Auto-Tempo"><b>8×</b><small>Auto</small></button>
<button class="khz-spd" title="Pausiert"><svg class="ic"><use href="#ic-pause"/></svg></button>
</div>
<p style="font-size:.76rem;color:#6a6a6a;margin:.5rem 0 0">Klick öffnet das Tempo-Panel und lässt es offen bis zum erneuten Klick (Toggle) — genauso beim Musik-Player. Der Button selbst zeigt jederzeit den Speed: <b>1×</b>, <b>2×</b>, <b>8× Auto</b> (z. B. Logistik) oder <b>⏸ Pause</b>. So bleibt der Zustand sichtbar, ohne dass das Panel offen bleiben muss.</p>
<div class="khz-frame-lab">Aufgeklappt · Musikplayer — klappt nach UNTEN auf, schließt nach Song-Auswahl · 30 % transparent</div>
<div class="khz-pop khz-music">
<button class="khz-seg on" title="Pause"><svg class="ic"><use href="#ic-pause"/></svg></button>
<span class="khz-select"><svg class="ic"><use href="#ic-music"/></svg> Ruhige Klänge <svg class="ic"><use href="#ic-caret"/></svg></span>
<svg class="ic" style="width:18px;height:18px;color:#1f4e5a"><use href="#ic-sound"/></svg>
<span class="khz-range"></span>
</div>
<div style="background:#f0f7f1;border:1px solid #cfe6d2;border-radius:8px;padding:.7rem .9rem;font-size:.8rem;color:#2f5e39;margin-top:1rem">
<b>Aufbau:</b> links die Pille Kreis-Logo · Titel · Level (gleiche Höhe) + Home — rechts Tempo (nur Echtzeit-Sims: Klima, Fluss, Tourismus, Logistik) · Musik · Ton · Neustart · Hilfe · Glossar (neuer Tab). Eigene SVG-Icons im App-Stil. <b>Untermenüs:</b> Tempo öffnet <b>links</b> vom Button und bleibt offen (Zustand steht ohnehin im Button); Musik klappt <b>nach unten</b> auf und schließt nach der Song-Auswahl; beide 30 % transparent hinterlegt als Untermenü-Signal, Toggle per Klick. Sim-eigene KPIs (Budget, Jahr …) bleiben in einer eigenen Unterzeile, nicht in der Markenzeile.
</div>
</div>
<!-- ============================================================== -->
<div class="card" id="layout">
<div class="card-h">Layout &amp; Spacing</div>
+178
View File
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>GeoGraSim — ToDo / Review (Admin)</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#f7f6f3;min-height:100vh;color:#2a2a2a;-webkit-text-size-adjust:100%}
.topbar{background:#fff;padding:.6rem .9rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.topbar h1{font-size:.9rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.45rem;min-width:0}
.topbar h1 img{height:26px}
.btn-sm{padding:.35rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.7rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;white-space:nowrap}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
.btn-sm.primary{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
.wrap{max-width:820px;margin:0 auto;padding:.9rem}
.lead{font-size:.8rem;color:#6a6a6a;margin-bottom:.7rem;line-height:1.5}
.lead b{color:#3a3a3a}
.bar{height:8px;border-radius:99px;background:#e6e3dc;overflow:hidden;margin:.5rem 0 1rem}
.bar>i{display:block;height:100%;background:linear-gradient(90deg,#4a7c8a,#6aa );background:#4a7c8a;width:0;transition:width .3s}
.toolbar{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem}
.sec{margin-bottom:1.4rem}
.sec-h{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:#8a8a8a;margin:0 .2rem .5rem;display:flex;align-items:center;gap:.5rem}
.sec-h .pill{font-size:.6rem;padding:.12rem .5rem;border-radius:99px;font-weight:800;letter-spacing:.02em}
.p1 .pill{background:#f6dcd6;color:#a33f28}
.p2 .pill{background:#fbeccd;color:#8a6510}
.p3 .pill{background:#dceadd;color:#2f6b3a}
.p4 .pill{background:#e2e8ee;color:#42607a}
.item{background:#fff;border-radius:11px;box-shadow:0 1px 3px rgba(0,0,0,.04);border:1px solid rgba(0,0,0,.05);padding:.7rem .8rem;margin-bottom:.6rem;border-left:4px solid #ccc}
.p1 .item{border-left-color:#c9503a}.p2 .item{border-left-color:#e0a020}.p3 .item{border-left-color:#4e9c5e}.p4 .item{border-left-color:#6a8db0}
.item.done{opacity:.55}
.item-top{display:flex;gap:.6rem;align-items:flex-start}
.chk{appearance:none;-webkit-appearance:none;flex:0 0 auto;width:22px;height:22px;border:2px solid #bcbcbc;border-radius:6px;cursor:pointer;position:relative;margin-top:.05rem;background:#fff}
.chk:checked{background:#4a7c8a;border-color:#4a7c8a}
.chk:checked::after{content:"✓";position:absolute;inset:0;color:#fff;font-size:.85rem;font-weight:800;display:flex;align-items:center;justify-content:center}
.item .ttl{font-size:.88rem;font-weight:700;color:#2a2a2a;line-height:1.3}
.item.done .ttl{text-decoration:line-through}
.item .desc{font-size:.76rem;color:#6a6a6a;line-height:1.5;margin-top:.25rem}
.item .desc code{background:#f0efe9;padding:.05rem .3rem;border-radius:4px;font-size:.72rem}
.note{width:100%;margin-top:.5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;padding:.45rem .55rem;font-family:inherit;font-size:.8rem;resize:vertical;min-height:2.2rem;color:#2a2a2a;background:#fcfcfa}
.note:focus{outline:none;border-color:#4a7c8a;background:#fff}
.note.has{border-color:#e0a020;background:#fffdf6}
#status{padding:2rem;text-align:center;color:#8a8a8a}
dialog{border:none;border-radius:12px;padding:0;max-width:640px;width:92%;box-shadow:0 20px 60px rgba(0,0,0,.3)}
dialog::backdrop{background:rgba(0,0,0,.45)}
.dlg-h{padding:.8rem 1rem;font-weight:800;color:#4a7c8a;border-bottom:1px solid rgba(0,0,0,.07);display:flex;align-items:center;justify-content:space-between}
.dlg-b{padding:1rem;max-height:60vh;overflow:auto}
.dlg-b textarea{width:100%;min-height:40vh;border:1px solid rgba(0,0,0,.12);border-radius:8px;padding:.6rem;font-family:ui-monospace,monospace;font-size:.75rem}
.foot{font-size:.68rem;color:#a0a0a0;text-align:center;margin:1.5rem 0 2rem}
@media(max-width:520px){ .wrap{padding:.7rem} .topbar h1{font-size:.82rem} }
</style>
</head>
<body>
<div class="topbar">
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> 📋 ToDo / Review</h1>
<button class="btn-sm primary" onclick="showNotes()">📝 Notizen</button>
<a href="admin-licenses.html" class="btn-sm">← Admin</a>
<a href="admin.html" class="btn-sm">Logout</a>
</div>
<div id="status">Prüfe Zugang…</div>
<div class="wrap" id="app" style="display:none">
<p class="lead">Lebende ToDo-/Review-Liste. Häkchen = <b>erledigt/gesichtet</b>, Notizfeld je Punkt für deine Review-Gedanken. Alles wird <b>lokal in diesem Browser</b> gespeichert (kein Server) — ideal für unterwegs. Mit <b>📝 Notizen</b> holst du dir alle Notizen als Text zum Kopieren.</p>
<div class="bar"><i id="prog"></i></div>
<div class="toolbar">
<button class="btn-sm" onclick="filterDone()"><span id="fd">Erledigte ausblenden</span></button>
<button class="btn-sm" onclick="resetAll()">↺ Häkchen zurücksetzen</button>
</div>
<div id="list"></div>
<div class="foot">Stand: 2026-08-20 · gepflegt von Atlas · nur Admin</div>
</div>
<dialog id="notesDlg">
<div class="dlg-h"><span>📝 Alle Review-Notizen</span><button class="btn-sm" onclick="document.getElementById('notesDlg').close()"></button></div>
<div class="dlg-b">
<textarea id="notesOut" readonly></textarea>
<div style="margin-top:.6rem;display:flex;gap:.4rem"><button class="btn-sm primary" onclick="copyNotes()">In Zwischenablage</button></div>
</div>
</dialog>
<script>
// ---- Auth-Gate (wie andere Admin-Seiten) ----
var BASE = location.pathname.replace(/\/admin-todo\.html$/,'').replace(/\/$/,'');
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
.then(function(r){ return r.json(); })
.then(function(r){
if (!r || !r.authenticated) { location.href = 'admin.html'; return; }
document.getElementById('status').style.display='none';
document.getElementById('app').style.display='';
render();
})
.catch(function(){ location.href = 'admin.html'; });
// ---- ToDo-Daten (id STABIL halten, damit Notizen erhalten bleiben) ----
var TODO = [
{ sec:'Auth-Cutover: v3 → Produktion', prio:1, items:[
{id:'csrf', t:'CSRF-Token für POST-Endpoints', d:'Aktuell nur <code>SameSite=Lax</code>. Vor jedem Prod-Cutover des JWT-Backends CSRF-Token ergänzen (Double-Submit oder synchronizer).'},
{id:'cutover-dual', t:'Doppelgleisiger Cutover (alt + neu parallel)', d:'Übergangszeit altes <code>$_SESSION</code>/Cookie UND neues JWT akzeptieren, Migration beim nächsten Login. 9 echte Lehrer + Schüler mit 30-Tage-Cookies dürfen NICHT rausfliegen.'},
{id:'admin-pending-redis', t:'admin_pending (2FA-Zwischenstand) → Redis', d:'Liegt im jwt-Modus noch in nativer $_SESSION. Für echtes k8s (Multi-Pod) nach Redis. Auf Single-Container-v3 aktuell ok.'},
{id:'reporting-fk', t:'Reporting auf student_id-FK ziehen', d:'Voraussetzung, dass Schüler ganz ohne DB-Session-Zeile auskommen (assessments.session_id → student_id).'},
{id:'prod-redis', t:'Prod-Redis + JWT_SECRET, Cutover nachts', d:'Eigener Prod-Redis-Container + JWT_SECRET/REDIS_URL in Prod-.env.production. Umstellung nachts, wenn niemand online ist.'},
]},
{ sec:'Offener Prod-Deploy (Feature-Bündel)', prio:2, items:[
{id:'deploy-batch', t:'Bündel db4b75c5..HEAD auf Prod', d:'Non-breaking (AUTH bleibt <code>session</code>). <b>Enthält den kritischen PHPMailer-vendor-Fix</b> — ohne den bricht Prod-Mailversand. vendor/ auch ins Top-Level spiegeln.'},
{id:'deploy-content', t:'Inhalt des Bündels prüfen', d:'Lehrer-Aktivitätsstreifen · Tourismusregion-Balance+UI · Weltküche-Gravitation+Artikel+Sätze · Logistik-Pause-Fix · assessment-Skip-Fix · admin-weltkueche QA · Passstraße-Karte · Lehrplan-Prod-Mapping.'},
]},
{ sec:'v3-Härtung & Infrastruktur', prio:3, items:[
{id:'v3-noindex', t:'v3 schützen (öffentlich mit Echt-Daten)', d:'noindex ist gesetzt; überlegen: Basic-Auth/IP-Schutz vor v3, da geklonte Echt-DB.'},
{id:'secret-scrub', t:'Secret-Scrub vor Bitbucket/Bruder-Deploy', d:'Prod-DB-Passwort im Klartext in getrackten Deploy-Skripten; Voll-DB-Dumps getrackt (DSGVO). → Secrets in .env, Dumps untracken, Passwort rotieren.'},
{id:'bitbucket', t:'App-Ordner → Bitbucket für neues Prod-System', d:'Bruder baut Deployment-Umgebung; wir pullen/pushen. Zwei Origins + eigene .gitignore-Regeln.'},
]},
{ sec:'Feinschliff-Backlog (Features)', prio:4, items:[
{id:'tr-stars', t:'Tourismusregion: Sterne-Kurve/Eskalation', d:'Umwelt beißt jetzt (Pass 1). Sterne-Kurve + eskalierende Ansprüche über Wellen tunen — braucht deinen Playtest (Feeling headless nicht messbar).'},
{id:'wk-gesamt', t:'Weltküche-Gesamt-Kachel (teacher.html)', d:'Aggregat-Kachel in der Lehreransicht.'},
{id:'lehrplan-switch', t:'Lehrplan-Länderschalter (D-A-CH)', d:'Umschalter AT/DE/CH/LI in der Lehrplan-/Modul-Ansicht.'},
{id:'preview-rest', t:'display_preview: klima/kofferdetektiv/stadt', d:'Fehlen noch in der Screenshot-Galerie (andere Einstiegspfade).'},
{id:'strip-v2', t:'Aktivitätsstreifen v2: „läuft noch"', d:'Aktive live_sessions einblenden (rechts-offener Balken für laufende Runden).'},
]},
{ sec:'Ältere offene Entscheidungen', prio:4, items:[
{id:'klassen-exkl', t:'Klassenaufgaben: Exklusiv-Sperre', d:'Vertagt — nur eine Sim gleichzeitig freigeben?'},
{id:'klima-save', t:'Klima Save-Cleanup nach Game-Over', d:'Konzept steht, dein OK für Implementierung offen.'},
{id:'balance-rest', t:'Balancing sonnensystem / energiemanager', d:'Aus dem großen Review offen.'},
]},
];
var KEY='ggs_todo_review_v1';
function load(){ try{ return JSON.parse(localStorage.getItem(KEY)||'{}'); }catch(e){ return {}; } }
function save(s){ localStorage.setItem(KEY, JSON.stringify(s)); }
var state = load();
var hideDone = false;
function render(){
var host = document.getElementById('list');
host.innerHTML = '';
var total=0, done=0;
TODO.forEach(function(g){
var sec = document.createElement('div');
sec.className = 'sec p'+g.prio;
var pill = g.prio===1?'Priorität 1':g.prio===2?'Priorität 2':g.prio===3?'Priorität 3':'Backlog';
sec.innerHTML = '<div class="sec-h"><span class="pill">'+pill+'</span> '+g.sec+'</div>';
g.items.forEach(function(it){
total++;
var st = state[it.id] || {};
if (st.done) done++;
if (hideDone && st.done) return;
var el = document.createElement('div');
el.className = 'item' + (st.done?' done':'');
var note = (st.note||'');
el.innerHTML =
'<div class="item-top">'+
'<input type="checkbox" class="chk" '+(st.done?'checked':'')+' data-id="'+it.id+'">'+
'<div style="flex:1;min-width:0"><div class="ttl">'+it.t+'</div><div class="desc">'+it.d+'</div></div>'+
'</div>'+
'<textarea class="note'+(note?' has':'')+'" data-id="'+it.id+'" placeholder="Notiz für Review…">'+note.replace(/</g,'&lt;')+'</textarea>';
sec.appendChild(el);
});
host.appendChild(sec);
});
document.getElementById('prog').style.width = total? Math.round(done/total*100)+'%' : '0';
host.querySelectorAll('.chk').forEach(function(c){ c.addEventListener('change', function(){ var id=c.dataset.id; state[id]=state[id]||{}; state[id].done=c.checked; save(state); render(); }); });
host.querySelectorAll('.note').forEach(function(n){ n.addEventListener('input', function(){ var id=n.dataset.id; state[id]=state[id]||{}; state[id].note=n.value; save(state); n.classList.toggle('has', !!n.value); }); });
}
function filterDone(){ hideDone=!hideDone; document.getElementById('fd').textContent = hideDone?'Erledigte einblenden':'Erledigte ausblenden'; render(); }
function resetAll(){ if(!confirm('Alle Häkchen zurücksetzen? (Notizen bleiben)')) return; Object.keys(state).forEach(function(k){ if(state[k]) state[k].done=false; }); save(state); render(); }
function showNotes(){
var out=[];
TODO.forEach(function(g){ g.items.forEach(function(it){ var st=state[it.id]||{}; if(st.note){ out.push('• '+it.t+(st.done?' [erledigt]':'')+'\n '+st.note); } }); });
document.getElementById('notesOut').value = out.length? out.join('\n\n') : '(noch keine Notizen)';
document.getElementById('notesDlg').showModal();
}
function copyNotes(){ var t=document.getElementById('notesOut'); t.select(); try{ document.execCommand('copy'); }catch(e){} if(navigator.clipboard){ navigator.clipboard.writeText(t.value); } }
</script>
</body>
</html>
+66 -7
View File
@@ -65,7 +65,42 @@
<span class="chip" data-cat="europa" onclick="setCat(this,'europa')">Europa</span>
<span class="chip" data-cat="exotisch" onclick="setCat(this,'exotisch')">Exotisch</span>
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem"><input type="checkbox" id="onlyWarn" onchange="render()"> nur Auffällige ⚠</label>
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem">🛒 Land des Users:
<select id="userCty" onchange="setUser(this.value)" style="font-size:.72rem;padding:.15rem .3rem">
<option value="oesterreich">🇦🇹 Österreich</option>
<option value="deutschland">🇩🇪 Deutschland</option>
<option value="schweiz">🇨🇭 Schweiz</option>
</select></label>
</div>
<details class="maengel" open style="margin:1rem 0;border:1px solid #d9b38c;border-radius:8px;background:#fff9f0;overflow:hidden">
<summary style="cursor:pointer;padding:.7rem 1rem;font-weight:700;font-size:.95rem;color:#8a5a2b;background:#fdf2e2">🔧 Mängelliste — Zutaten vs. Rezept <span style="font-weight:400;color:#9a7a5a">(✅ komplett abgearbeitet 2026-08-22)</span></summary>
<div style="padding:.5rem 1rem 1rem">
<p style="font-size:.82rem;color:#2e6b34;margin:.3rem 0 .8rem;background:#e9f5ea;border-left:3px solid #4a7c4e;padding:.4rem .6rem"><b>✅ Erledigt (14 Fixes, Commit siehe git):</b> die Tabelle unten ist abgearbeitet — Zutaten raus/ersetzt, Guanciale neu für Carbonara. <b>Offen bleiben nur</b> die mediterranen Kräuter + Marillenknödel-Teig (weiter unten, deine Entscheidung).</p>
<p style="font-size:.8rem;color:#7a6a55;margin:.3rem 0 .8rem">Zutaten, die im <b>Rezepttext nicht vorkamen</b> oder <b>falsch</b> waren. „Vorschlag" = was stattdessen stimmt (umgesetzt).</p>
<table style="width:100%;border-collapse:collapse;font-size:.82rem">
<thead><tr style="text-align:left;color:#8a5a2b;border-bottom:2px solid #e6cba3">
<th style="padding:.35rem .5rem">Gericht</th><th style="padding:.35rem .5rem">Zutat</th><th style="padding:.35rem .5rem">Befund</th><th style="padding:.35rem .5rem">Vorschlag</th></tr></thead>
<tbody style="color:#4a4038">
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Apfelstrudel</td><td style="padding:.35rem .5rem"><b>zitrone, essig</b></td><td style="padding:.35rem .5rem">kommen im Rezept nicht vor</td><td style="padding:.35rem .5rem">beide raus</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Rheinischer Sauerbraten</td><td style="padding:.35rem .5rem"><b>mehl</b></td><td style="padding:.35rem .5rem">Sauce bindet mit Lebkuchen, kein Mehl</td><td style="padding:.35rem .5rem">raus</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Schwarzwälder Kirschtorte</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Schlagobers, keine Milch</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Zürcher Geschnetzeltes</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Rahm</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Quiche Lorraine</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Sahne + Crème fraîche</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pierogi Ruskie</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Teig mit Wasser, keine Milch</td><td style="padding:.35rem .5rem">raus</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pizza Margherita</td><td style="padding:.35rem .5rem"><b>hartweizen</b></td><td style="padding:.35rem .5rem">Pizza nutzt Tipo-00 (Weichweizen), kein Durum</td><td style="padding:.35rem .5rem">→ weiches Weizenmehl</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Spaghetti Carbonara</td><td style="padding:.35rem .5rem"><b>speck</b></td><td style="padding:.35rem .5rem">Rezept sagt ausdrücklich Guanciale, NICHT Speck</td><td style="padding:.35rem .5rem">→ guanciale</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Ungarisches Gulasch</td><td style="padding:.35rem .5rem"><b>tomaten</b></td><td style="padding:.35rem .5rem">Gulyás ist paprika-basiert, keine Tomaten im Rezept</td><td style="padding:.35rem .5rem">raus</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Ungarisches Gulasch</td><td style="padding:.35rem .5rem"><b>butter</b></td><td style="padding:.35rem .5rem">Rezept nutzt Schweineschmalz</td><td style="padding:.35rem .5rem">→ öl/schmalz</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Gemüse-Curry</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Curry nutzt neutrales Öl, kein Olivenöl</td><td style="padding:.35rem .5rem">→ oel (neutral)</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pad Thai</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Wok-Gericht, neutrales/Erdnussöl</td><td style="padding:.35rem .5rem">→ oel (neutral)</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Guacamole</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Guacamole hat kein Öl im Rezept</td><td style="padding:.35rem .5rem">raus</td></tr>
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Tiroler Gröstl</td><td style="padding:.35rem .5rem"><b>kuemmel</b></td><td style="padding:.35rem .5rem">kommt im Rezept nicht vor (nur Majoran)</td><td style="padding:.35rem .5rem">raus oder ins Rezept</td></tr>
</tbody>
</table>
<p style="font-size:.82rem;color:#2e6b34;margin:.9rem 0 .3rem;background:#e9f5ea;border-left:3px solid #4a7c4e;padding:.4rem .6rem"><b>✅ Entschieden:</b> <b>Mediterrane Kräuter bleiben</b> (Thomas: lassen) bei Zürcher, Pierogi, Gulasch, Moussaka, Falafel. <b>Marillenknödel</b> → Rezept auf reinen Topfenteig umgestellt (Erdäpfel raus).</p>
<p style="font-size:.8rem;color:#5a5048;margin:.2rem 0 .3rem"><b>📝 Rein kosmetisch (offen gelassen):</b> Steirisches Wurzelfleisch — Pfeffer nur implizit · Tiroler Gröstl / Brezel — Butter vs. Butterschmalz/Frischkäse</p>
</div>
</details>
<div id="status">Lade Daten…</div>
<div id="list"></div>
</div>
@@ -74,6 +109,8 @@ var BASE = location.pathname.replace(/\/admin-weltkueche\.html$/, '');
var D = 'sims/weltkueche/assets/data/';
var C = {}, PM = {}, DISHES = [];
var _cat = '';
var WK_USER = 'oesterreich'; // Teil B: Land des Users (Consumer-Anker) — via Umschalter
function setUser(v){ WK_USER = v || 'oesterreich'; render(); }
// ---- Spiel-Logik gespiegelt (siehe game.html buildTaskFor) ----
function mrank(m){ return m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1; }
@@ -87,16 +124,37 @@ function wkDist(a, b){
}
// Klassifiziert alle Produzenten einer Zutat für ein Gericht.
// Gibt { correct:[keys], distractors:[keys], branch } zurück (Pools, ohne Zufalls-Ziehung).
function classify(ingKey, heimat){
function classify(ingKey, heimat, isSig){
var ing = PM[ingKey]; if (!ing) return null;
var producers = ing.producers || {};
var keys = Object.keys(producers).filter(function(c){ return C[c]; });
// ── FEST VERDRAHTETE HERKUNFT: feste Länderliste + feste Distraktoren.
if (ing.correct && ing.correct.length) {
var fc = ing.correct.filter(function(c){ return C[c]; });
var fd = (ing.distractors||[]).filter(function(c){ return C[c] && fc.indexOf(c)<0; });
return { correct: fc, distractors: fd, branch:'fest', grav:function(){return 0;} };
}
// ── CONSUMER-REGEL (Teil B): Grundzutat, im Land des Users heimisch → kommt aus
// dem Land des Users (unabhängig vom Gericht-Land). Signaturzutaten ausgenommen.
if (!isSig && ing.heimisch && ing.heimisch.indexOf(WK_USER) >= 0 && C[WK_USER]) {
var cmarket = (ing.market||[]).filter(function(c){ return C[c] && c !== WK_USER; });
var ccorrect = [WK_USER].concat(cmarket).slice(0,3);
if (ccorrect.length < 2){ for (var hi=0; hi<(ing.heimisch||[]).length; hi++){ var hh=ing.heimisch[hi]; if(C[hh]&&ccorrect.indexOf(hh)<0){ccorrect.push(hh);break;} } }
var cdistr;
if (ing.distractors && ing.distractors.length){ cdistr = ing.distractors.filter(function(c){ return C[c] && ccorrect.indexOf(c)<0; }); }
else { var cfar = keys.filter(function(c){ var mm=producers[c].market; return (mm==='world-far'||mm==='local') && ccorrect.indexOf(c)<0; }).sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); });
var cclim = (ing.climate_possible||[]).filter(function(c){ return C[c] && ccorrect.indexOf(c)<0; }); cdistr = cfar.concat(cclim); }
return { correct: ccorrect, distractors: cdistr, branch:'consumer', grav:function(){return 0;} };
}
// Heimat-Zweig: Heimat erzeugt die Zutat selbst
if (heimat && C[heimat] && producers[heimat]) {
var grav = function(c){ return (producers[c].t || 0) / wkDist(heimat, c); };
var realistic = keys.filter(function(c){ return c !== heimat && mrank(producers[c].market) < 2; })
.sort(function(a,b){ return (mrank(producers[a].market)-mrank(producers[b].market)) || (grav(b)-grav(a)); });
var correct = [heimat]; if (realistic[0]) correct.push(realistic[0]);
// Kuratierte Zweitlieferanten (correct_for) übersteuern die Gravitation — wie im Spiel.
var curated = ((ing.correct_for && ing.correct_for[heimat]) || []).filter(function(c){ return C[c] && c !== heimat; });
var correct = curated.length ? [heimat].concat(curated) : [heimat];
if (!curated.length && realistic[0]) correct.push(realistic[0]);
if (correct.length < 2){ var any = keys.filter(function(c){ return c!==heimat && correct.indexOf(c)<0; })
.sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); }); if(any[0]) correct.push(any[0]); }
var farP = keys.filter(function(c){ return correct.indexOf(c)<0 && mrank(producers[c].market)===2; });
@@ -140,7 +198,7 @@ function render(){
var rows = ''; var dishHasWarn = false;
ings.forEach(function(k){
if (q && (d.name||'').toLowerCase().indexOf(q)<0 && (PM[k].nm||k).toLowerCase().indexOf(q)<0) return;
var cl = classify(k, heimat); if(!cl) return;
var cl = classify(k, heimat, !!(d.signature && d.signature[k])); if(!cl) return;
// Auffällig NUR im Heimat-Zweig: die Zutat ist lokal erzeugbar (Heimat baut
// sie an), trotzdem ist ein „richtiges" Land > 1500 km weg → echter Ausreißer.
// Welt-Zutaten (Pfeffer aus Vietnam etc.) sind zu Recht fern → keine Warnung.
@@ -152,7 +210,7 @@ function render(){
if (onlyWarn && !warn) return;
var okChips = cl.correct.map(function(c){ return ctyChip(c,'ok',k,heimat,true); }).join('');
var distChips = cl.distractors.slice(0,6).map(function(c){ return ctyChip(c,'dist',k,heimat,false); }).join('');
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')+'</small></td>'
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='fest'?'📌 Feste Herkunft':(cl.branch==='consumer'?'🛒 Consumer-Regel (User-Land)':(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')))+'</small></td>'
+ '<td><div class="colhead">richtig</div>'+(okChips||'—')+warn+'</td>'
+ '<td><div class="colhead">Distraktoren (Pool)</div>'+(distChips||'—')+'</td></tr>';
});
@@ -174,10 +232,11 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
.then(function(r){return r.json();})
.then(function(r){
if (!r.authenticated){ location.href = 'admin.html'; return; }
var _bust = '?v=' + Date.now(); // Cache-Busting: Admin-QA zeigt IMMER die frischen Daten, nie eine gecachte JSON
return Promise.all([
fetch(D+'countries-full.json').then(function(r){return r.json();}),
fetch(D+'production-matrix.json').then(function(r){return r.json();}),
fetch(D+'dishes.json').then(function(r){return r.json();}),
fetch(D+'countries-full.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
fetch(D+'production-matrix.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
fetch(D+'dishes.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
]).then(function(a){
C = a[0]; PM = a[1];
DISHES = Object.keys(a[2]).filter(function(k){return k[0]!=='_';}).map(function(id){ return Object.assign({id:id}, a[2][id]); });

Some files were not shown because too many files have changed in this diff Show More