Compare commits
4 Commits
37f6238d05
...
665b43e069
| Author | SHA1 | Date | |
|---|---|---|---|
| 665b43e069 | |||
| d9f9f901a1 | |||
| e23ebd960e | |||
| c1f90e255d |
@@ -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` (0–100), 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).
|
||||
@@ -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' },
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)+'" '
|
||||
|
||||
Reference in New Issue
Block a user