Compare commits

..

4 Commits

Author SHA1 Message Date
Adminator 665b43e069 Doku: Relevante Infos für Lehrpersonen (Zusammenfassung Lehrer-Cockpit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 15:16:08 +02:00
Adminator d9f9f901a1 Logistik-Pause friert die Aktivzeit ein (Lehrer-Cockpit)
Problem: bei Pause lief die „Seit"-Zeit im Lehrer-Cockpit weiter, obwohl das
Spiel steht — live-client.js akkumulierte activeMs unabhängig vom Pause-Zustand.

- live-client.js: opt-in Pause-Unterstützung. window.__GGS_LIVE__.setPaused(bool)
  friert die activeMs-Buchhaltung ein (lastTickAt wird weiter aktualisiert, damit
  beim Fortsetzen nichts nachspringt). __paused zusätzlich im Heartbeat. Default
  aus → alle anderen Sims unberührt.
- logistik game.html: Pause-/Auto-Button ruft setPaused(mode==='pause').

Verifiziert (Playwright): activeMs 0→2000 (läuft) → 2000 (Pause eingefroren)
→ 3999 (Fortsetzen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 15:12:50 +02:00
Adminator e23ebd960e Weltküche: Herkunft per Handels-Gravitation + Punktefix „alle falsch = 0"
- Zweit-Lieferant im Heimat-Zweig jetzt via Handels-Gravitation (Menge ÷
  Entfernung zur Heimat, Haversine über Länder-Koordinaten) statt roher
  Weltproduktion. AT-Eier → Deutschland (468 km) statt Frankreich (916 km),
  obwohl FR mehr Eier erzeugt. Distraktoren bleiben nie echte AT-Lieferanten.
  Verifiziert: Eier/Kartoffel/Milch → [Österreich, Deutschland]; alle 70
  Zutaten bauen fehlerfrei.
- Punktefix: Strafe pro Fehlklick = 100 ÷ Anzahl Distraktoren → „alle Falschen
  zuerst" ergibt IMMER 0 (bei 4 Distraktoren −25, bei 2 −50). Task-Banner
  zeigt die tatsächliche Strafe + „alle falsch zuerst = 0".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 15:09:16 +02:00
Adminator c1f90e255d Lehreransicht aufräumen: Sim-Labels + Logistik-Spalten + assessment-Skip-Fix
- SIM_LABELS: EIN Wort + eindeutiges 3-Buchstaben-Kürzel je App (zentrale
  Quelle simWord()/simCode()). Tages-Balken nutzt jetzt Kürzel (eng) → Wort
  (mittel) → Wort+Lektion (breit) statt kryptischem "Log L1".
- LIVE_PRIMARY_FIELDS['logistik'] ergänzt (Stufe · Budget · Geliefert/Gesamt ·
  Unterwegs · Offen · Verspätet · Fortschritt) — vorher Roh-Dump aller Keys.
- Aktuell-Fallback (uncurated Sims): interne __-Heartbeat-Keys + simId/
  timestamp/screen ausgeblendet, nicht mehr als Spalten.
- assessment.php: Skip-Bug gefixt — las phase aus $body['processLog']['phase'],
  gesendet wird $body['phase']. Dadurch landeten alle 'started'/'running'-Pings
  als LEERE Zeilen in assessments und verschmutzten Ergebnisse/Tag. Jetzt
  greift der Skip; nur echte 'completed'-Abschlüsse werden gespeichert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 15:05:42 +02:00
6 changed files with 147 additions and 15 deletions
@@ -0,0 +1,42 @@
# Relevante Infos für Lehrpersonen — Zusammenfassung
Stand 2026-08-19. Konsolidiert, was wir zum Lehrer-Cockpit festgehalten und gebaut haben.
## Die Kernfrage der Lehrperson
> „Wer ist gerade **engagiert und macht es gut** — und wer **blödelt herum oder hängt fest**?"
Alles im Cockpit dient dieser Frage. Kein Zahlen-Dump, sondern wenige aussagekräftige Signale.
## Die 6 relevanten Signale
| Signal | Was es beantwortet | Woher |
|---|---|---|
| **Fortschritt** | Wie weit durch die Sim? (%) | `progressPct` / abgeleitet je Sim |
| **Qualität** | Blödeln vs. gut — Verhältnis **richtige ↔ falsche** Klicks (grafischer Balken) | `correctClicks`/`wrongClicks` (bisher Weltküche; andere ziehen nach) |
| **Fleiß** | Wie viel im **gewählten Zeitraum** geschafft? (z. B. Gerichte, gelieferte Aufträge) | assessments im Intervall |
| **Lage** | Klartext: **Stark / Solide / Viele Fehler** (kein „Kritisch"-Jargon) | `_liveMood` je Sim |
| **Aktivität** | aktiv / idle / **pausiert** — war das Kind die ganze Stunde dabei? | Heartbeat `__activeMs`/`__idleMs`/`__paused` |
| **Hilfe-Bedarf** | „🆘 braucht evtl. Hilfe" | `_liveNeedsHelp` je Sim (z. B. Budget < 0, viele Fehlversuche) |
## Die Ansichten (Menü „Ergebnisse")
- **📅 Tag** — Zeitstrahl aller Sessions des Tages je Kind, eingefärbt nach Erfolg. Balken tragen **Sim-Kürzel/Wort** (LOG, Logistik …), Klick öffnet Detail.
- **🔴 Aktuell** — Live-Tabelle je Sim mit **kuratierten** Spalten (LIVE_PRIMARY_FIELDS), Qualitäts-Balken, Fortschritt, Lage. Aktualisiert alle 4 s.
- **📊 Vergleich** — aktive Kinder EINER Sim nebeneinander (Fortschritt + Qualität) → direkter „wer gut / wer nicht".
- **📚 Gesamt** — Aggregat (Fleiß-Zeit, Modul-Karten, Matrix) über den gewählten Zeitraum.
## Design-Prinzipien (verbindlich)
1. **Kuratierte Spalten je Sim** — nie Roh-State dumpen, nie interne `__`-Heartbeat-Keys zeigen. Fehlt ein Schema → gefilterter Fallback (ohne Rausch-Felder).
2. **Klartext statt Jargon** — „Stark/Solide/Viele Fehler" statt „Kritisch/OK"; „Gerade dabei" statt „Phase".
3. **Ehrliche Zeit** — Aktivzeit zählt **nicht** bei Spiel-Pause und **nicht** bei Tab-weg/idle (>5 min). Sonst wirkt ein pausiertes Kind fälschlich „fleißig".
4. **Ein Wort + 3-Buchstaben-Kürzel je Sim** (SIM_LABELS) — enge Balken nutzen das Kürzel, breite das Wort.
5. **Datenschutz** — nur was die Lehrperson pädagogisch braucht; verdeckte Antworten (z. B. Kofferdetektiv-Ziel) bleiben verdeckt, solange das Kind sucht.
## Was jede Sim liefern sollte (GGS_LIVE_STATE)
- `progressPct` (0100), ein Fortschritts-Zähler (z. B. `completed`/`total`),
- **`correctClicks`/`wrongClicks`** (für die Qualitäts-Kennzahl — Ziel: alle Sims),
- optional `health` (good/ok/struggling) für die Lage,
- Heartbeat-Felder (`__activeMs` etc.) kommen automatisch aus live-client.js; bei Pause `window.__GGS_LIVE__.setPaused(true)` rufen.
## Offen (siehe project_backlog_2026-08-18 / project_lehrer_observability_overhaul)
- **Aktivitäts-/Qualitätsstreifen** pro Kind über die Stunde (grün→rot, aktiv→inaktiv & bullshit→ernsthaft) — braucht Zeitreihen-Aktivitätsdaten.
- Weltküche-**Gesamt-Kachel** (Gerichte · Ø Punkte · Fehlquote).
- **Andere Sims** mit `correctClicks`/`wrongClicks` nachrüsten (bisher nur Weltküche).
+8
View File
@@ -63,6 +63,7 @@
var activeMs = 0; // akkumuliert, NUR wenn nicht idle
var lastTickAt = Date.now();
var sessionCapped = false;
var paused = false; // Sim-Pause: friert activeMs ein (opt-in)
function onInteraction() {
lastInteractionAt = Date.now();
@@ -80,6 +81,9 @@
var now = Date.now();
var delta = now - lastTickAt;
lastTickAt = now;
// Spiel pausiert (Sim hat setPaused(true) gerufen) → Zeit steht, nicht zählen.
// lastTickAt ist schon aktualisiert, daher springt activeMs beim Fortsetzen nicht.
if (paused) return;
// Tab nicht sichtbar (anderer Tab, minimiert) → echtes Weg-Sein, nicht zählen.
// Denkpausen bei SICHTBAREM Tab bleiben über das großzügige Idle-Fenster erhalten.
if (typeof document !== 'undefined' && document.hidden) return;
@@ -107,6 +111,9 @@
get sessionStartedAt() { return sessionStartedAt; },
get lastInteractionAt() { return lastInteractionAt; },
get capped() { return sessionCapped; },
get paused() { return paused; },
// Sim ruft dies bei Pause/Fortsetzen — friert die Aktivzeit-Buchhaltung ein.
setPaused: function (v) { paused = !!v; },
MAX_SESSION_MS: MAX_SESSION_MS,
IDLE_THRESHOLD_MS: IDLE_THRESHOLD_MS,
});
@@ -131,6 +138,7 @@
state.__lastInteractionAt = lastInteractionAt;
state.__sessionStartedAt = sessionStartedAt;
state.__capped = sessionCapped;
state.__paused = paused;
fetch(apiBase + '/live.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+6 -1
View File
@@ -29,8 +29,13 @@ $durationMs = max(0, min($rawDuration, GGS_MAX_SESSION_MS));
// Der Live-Zustand für das Lehrer-Cockpit kommt über den Heartbeat (live_sessions);
// diese 30-s-Pings (phase 'running'/'started') haben früher die Tabelle zugemüllt
// (~96 % der Zeilen). Nur echte Abschlüsse/Ergebnisse werden gespeichert.
// phase steht auf oberster Body-Ebene (alle Sims senden {simId, phase, …}).
// Früher wurde fälschlich $body['processLog']['phase'] gelesen → der Skip
// griff nie, und JEDER 'started'/'running'-Ping landete als leere Zeile in
// assessments (verschmutzt Ergebnisse/Tag). Robust: oberste Ebene, Fallback nested.
$pl = $body['processLog'] ?? [];
$phase = (is_array($pl) && isset($pl['phase'])) ? $pl['phase'] : null;
$phase = $body['phase']
?? ((is_array($pl) && isset($pl['phase'])) ? $pl['phase'] : null);
if ($phase === 'running' || $phase === 'started') {
Response::ok(['skipped' => true]);
}
+3
View File
@@ -3031,6 +3031,9 @@ window.pickText = function(opts) {
LogistikEngine.startSimulation(game);
}
playerMode = mode;
// Aktivzeit-Buchhaltung fürs Lehrer-Cockpit bei Pause einfrieren
// (sonst läuft die „Seit"-Zeit weiter, obwohl das Spiel steht).
try { if (window.__GGS_LIVE__) window.__GGS_LIVE__.setPaused(mode === 'pause'); } catch (_) {}
setActiveSpeedBtn(btn);
updateStatusUI();
});
+24 -3
View File
@@ -1031,6 +1031,17 @@ function ingWas(ing, sTask, ingKey) {
return a + ' ' + (ing ? ing.nm : '');
}
// Entfernung zweier Länder (Haversine, km) über die Länder-Koordinaten.
// Für die Handels-Gravitation (Menge ÷ Entfernung) beim Zweit-Lieferanten.
function _wkDist(a, b) {
const A = C[a], B = C[b];
if (!A || !B || A.lat == null || B.lat == null) return 3000;
const R = 6371, toR = Math.PI / 180;
const dLat = (B.lat - A.lat) * toR, dLng = (B.lng - A.lng) * toR;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(A.lat * toR) * Math.cos(B.lat * toR) * Math.sin(dLng / 2) ** 2;
return Math.max(60, 2 * R * Math.asin(Math.min(1, Math.sqrt(s))));
}
function buildTaskFor(ingKey) {
if (_taskCache[ingKey]) return _taskCache[ingKey];
const ing = ING[ingKey];
@@ -1054,9 +1065,14 @@ function buildTaskFor(ingKey) {
// (global/ungetaggt). „world-far"/„local" (wächst dort, landet aber nicht in unseren
// Regalen) zählen NICHT als korrekt.
const mrank = m => (m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1);
// Zweit-Lieferant = realistisch für UNSEREN Markt via Handels-Gravitation:
// Menge ÷ Entfernung zur Heimat. Ein naher großer Nachbar schlägt einen
// fernen Großproduzenten (AT-Eier → Deutschland, NICHT Frankreich), obwohl
// Frankreich mehr Eier erzeugt. Markt-Tier bleibt vorrangig (EU vor global).
const grav = ([c, info]) => (info.t || 0) / _wkDist(heimat, c);
const realistic = Object.entries(ing.producers)
.filter(([c, info]) => C[c] && c !== heimat && mrank(info.market) < 2)
.sort((a, b) => (mrank(a[1].market) - mrank(b[1].market)) || ((b[1].t || 0) - (a[1].t || 0)))
.sort((a, b) => (mrank(a[1].market) - mrank(b[1].market)) || (grav(b) - grav(a)))
.map(([c]) => c);
const correct = [heimat, ...realistic.slice(0, 1)]; // Heimat + eine realistische Zweit-Wahl (wie normaler Zweig: 2 Korrekte)
if (correct.length < 2) { // Garantie: es gibt IMMER eine zweite Wahl
@@ -1231,9 +1247,11 @@ function loadIngredient(){
: (task && task.signature
? 'aus welchem Land stammt diese Zutat in <b>diesem Gericht</b>?'
: 'aus welchem Land kommt diese Zutat häufig zu uns auf den Markt?');
const _nd = Math.max(1, task ? (task.choices.length - task.correct.length) : 4);
const _per = Math.round(100 / _nd);
document.getElementById('taskBanner').innerHTML=
`🔎 Suche: <b>${ing.nm}</b> ${frage}`
+ ` <span style="opacity:.75;font-weight:600;white-space:nowrap">· richtig <b>+100</b>, jeder Fehlklick <b>25</b> (¼)</span>`;
+ ` <span style="opacity:.75;font-weight:600;white-space:nowrap">· richtig <b>+100</b>, jeder Fehlklick <b>${_per}</b> (alle falsch zuerst = 0)</span>`;
document.getElementById('resultCard').className="result-card";
drawMarkers(ing); renderIngList();
}
@@ -1270,7 +1288,10 @@ function guess(key){
playSfx('click-country');
if (cls === 'correct') {
b.classList.add('correct');
const pts = Math.max(0, 100 - 25 * (current.wrongHere || 0)); // richtig = 100; jeder Fehlklick auf dieser Zutat: 25 (¼)
// Strafe pro Fehlklick so, dass „alle Falschen zuerst" IMMER 0 ergibt:
// 100 ÷ Anzahl Distraktoren. Bei 4 Distraktoren = 25 (¼), bei 2 = 50.
const numDistr = Math.max(1, task ? (task.choices.length - task.correct.length) : 4);
const pts = Math.max(0, Math.round(100 - (100 / numDistr) * (current.wrongHere || 0)));
state.score+=pts; current.dishScore+=pts;
state.totalCorrect++; current.wrongStreak=0; current.found.push(key);
map.flyTo([c.lat,c.lng],3,{duration:.7});
+64 -11
View File
@@ -888,6 +888,26 @@ var LIVE_PRIMARY_FIELDS = {
{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'); }},
],
// Default-Schema: zeigt einfach den state, soweit Felder benannt sind.
_default: null,
};
@@ -1337,10 +1357,16 @@ async function renderLive() {
// Spalten-Schema: bevorzugt LIVE_PRIMARY_FIELDS, sonst Vereinigung aller state-Keys.
var fields = LIVE_PRIMARY_FIELDS[modId];
if (!fields) {
// Kein kuratiertes Schema → nur sinnvolle State-Keys zeigen.
// Interne Heartbeat-Keys (__…) und Rausch-Felder NIE anzeigen.
var _hideKeys = {simId:1, timestamp:1, screen:1};
var keySet = {};
grp.rows.forEach(function(r) {
var s = r.state || {};
Object.keys(s).forEach(function(k) { keySet[k] = true; });
Object.keys(s).forEach(function(k) {
if (k.indexOf('__') === 0 || _hideKeys[k]) return;
keySet[k] = true;
});
});
fields = Object.keys(keySet).map(function(k) { return {k:k, label:k}; });
}
@@ -1430,14 +1456,39 @@ async function renderLive() {
// === LIVE-ÜBERSICHT (Tages-Balkenchart pro Schüler*in) ===
// Kurz-Form pro Sim für Balken-Label
var LIVE_SIM_SHORT = {
heli: 'Heli', klima: 'Klima', 'klima-3d': 'Klima 3D',
busfahrt: 'Bus', fluggesellschaft: 'Flug',
sonnensystem: 'Sonne', 'eu-werkstatt': 'EU', energiemanager: 'Energie',
farmer: 'Farmer', fluss: 'Fluss', logistik: 'Log', stadt: 'Stadt',
regenwald: 'Regenw.', staustufen: 'Stau', erdbeben: 'Erdb.',
vulkan: 'Vulkan', wal: 'Wal',
// Kanonische Sim-Bezeichner: EIN Wort + 3-Buchstaben-Kürzel je App.
// Wort wo Platz ist, Kürzel in engen Balken. Zentrale Quelle — auch anderswo
// nutzbar (Tages-Balken, Kompakt-Anzeigen). Kürzel sind eindeutig.
var SIM_LABELS = {
weltkueche: { w:'Weltküche', c:'WEK' },
klima: { w:'Klima', c:'KLI' },
'klima-3d': { w:'Klima 3D', c:'K3D' },
staustufen: { w:'Staustufen', c:'STU' },
fluss: { w:'Fluss', c:'FLU' },
heli: { w:'Heli', c:'HEL' },
energiemanager: { w:'Energie', c:'ENE' },
'eu-werkstatt': { w:'EU-Werkstatt', c:'EUW' },
regenwald: { w:'Regenwald', c:'RGW' },
erdbeben: { w:'Erdbeben', c:'ERD' },
energiemix: { w:'Energiemix', c:'EMX' },
lieferketten: { w:'Lieferketten', c:'LIF' },
farmer: { w:'Farmer', c:'FAR' },
busfahrt: { w:'Busfahrt', c:'BUS' },
sonnensystem: { w:'Sonne', c:'SON' },
tourismustal: { w:'Tourismustal', c:'TAL' },
tourismusregion: { w:'Tourismusregion', c:'REG' },
logistik: { w:'Logistik', c:'LOG' },
fluggesellschaft: { w:'Flug', c:'FLG' },
vulkan: { w:'Vulkan', c:'VUL' },
wal: { w:'Wal', c:'WAL' },
stadt: { w:'Stadt', c:'STD' },
kofferdetektiv: { w:'Koffer', c:'KOF' },
entscheidungstag: { w:'Alltag', c:'ALT' },
};
function simWord(id){ return (SIM_LABELS[id] && SIM_LABELS[id].w) || id; }
function simCode(id){ return (SIM_LABELS[id] && SIM_LABELS[id].c) || String(id||'').slice(0,3).toUpperCase(); }
// Rückwärtskompatibel: LIVE_SIM_SHORT[id] = Wort je Sim.
var LIVE_SIM_SHORT = Object.keys(SIM_LABELS).reduce(function(o,k){ o[k]=SIM_LABELS[k].w; return o; }, {});
function _liveFmtMin(sec) {
if (sec < 60) return sec + 's';
var m = Math.floor(sec/60); var s = sec%60;
@@ -1998,14 +2049,16 @@ async function renderLiveSummary() {
var pctL = _msToPct(sess.startMs);
var pctR = _msToPct(endMs);
var pctW = Math.max(0.8, pctR - pctL); // mindestens 0.8 % sichtbar
var simShort = LIVE_SIM_SHORT[sess.simId] || sess.simIcon || sess.simId;
var lbl = simShort + (sess.lessonLabel ? ' '+sess.lessonLabel : '');
var simW = simWord(sess.simId);
var simC = simCode(sess.simId);
var lbl = simW + (sess.lessonLabel ? ' '+sess.lessonLabel : '');
var color = _liveSuccessColor(sess.success);
var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'')
+ ' · ' + _fmtHM(sess.startMs) + '' + _fmtHM(endMs)
+ ' · ' + _liveFmtMin(realDurSec)
+ (sess.stars!=null?' · '+sess.stars+'/5★':'');
var inner = (pctW < 3) ? '' : (pctW < 8) ? simShort : lbl;
// enger Balken → 3-Buchstaben-Kürzel, mittel → Wort, breit → Wort + Lektion
var inner = (pctW < 2.5) ? '' : (pctW < 6) ? simC : (pctW < 11) ? simW : lbl;
var sessKey = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs;
html += '<div onclick="openLiveSessionDetail(\''+sessKey+'\')" '
+ 'title="'+esc(tip)+'" '