Files
geograsim/App/php/api/progress.php
T
Adminator 33fb423967 Atlas: Live-View-Feature + Submit-Bug-Fixes + Admin-Styleguide
- Neuer API-Endpoint /api/live (heartbeat / spectator) + DB-Tabelle
  live_sessions + class_modules.paused-Spalte
- Plattform-Live-Client (assets/js/live-client.js): Heartbeat,
  Pause-Overlay, Spectator-Mode bei ?view=teacher
- ggs_inject_live() in _scripts.php als Helper, in alle 11 Sim-Wrapper
  integriert
- Lehrer-Cockpit: tabellarische Klassen-Live-Ansicht pro Sim,
  Pause-Toggle, blinkender Tab-Indikator wenn aktive Sessions
- Submit-Bug erschlagen: progress.php hat jetzt submit_assessment-
  und reflection-Action; saves.php akzeptiert beide Key-Konventionen;
  alle Direkt-Pfad-API-Files mit require_once-Bootstrap
- Avatar-Default: zufaelliger DALL-E-Avatar fuer neue Schueler:innen
- Admin-Styleguide-Seite mit Bildstil, Farb-Tokens, Layout, iPad-Pattern
- Klima 2D + 3D: GGS_LIVE_STATE-Hook, Klima 2D zusaetzlich skipResume
  fuer Klassenaufgaben-Reset
- Test-Lehrer Jakob + 3 Schueler:innen lokal + auf Live angelegt
- Rundbriefe an alle Sim-Instanzen + Submit-Briefe an sonnensystem,
  busfahrt, energiemanager + Glossar-Anfrage zu Grundriess
- Status-Uebergabe in _inbox/zentrale/_status.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:20:53 +02:00

253 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* API: Spieler-Fortschritt + Level-System + Wirkungsklammer
* GET /api/progress?sim_id=X → Level/XP für ein Spiel
* GET /api/progress?student_id=X → Alle Fortschritte (Lehrer-Ansicht)
* POST /api/progress {action} → Level updaten, Pre/Post-Antworten speichern
*
* Bootstrap: idempotent, falls direkt über /php/api/progress.php aufgerufen
* (Klima 2D + 3D rufen diese URL direkt — bisher ergab das einen Fatal Error,
* weil Database/Session/Response erst über index.php geladen werden).
*/
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../lib/Database.php';
require_once __DIR__ . '/../lib/Session.php';
require_once __DIR__ . '/../lib/Response.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
if ($method === 'GET') {
$simId = $_GET['sim_id'] ?? '';
$studentId = (int)($_GET['student_id'] ?? 0);
// Schüler*in: eigener Fortschritt
if ($simId && !$studentId) {
$sessionId = Session::requireStudent();
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
if ($session) {
$student = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]);
if ($student) $studentId = (int)$student['id'];
}
}
if ($studentId && $simId) {
$progress = $db->fetchOne('SELECT level, xp, plays, best_stars FROM player_progress WHERE student_id = ? AND sim_id = ?', [$studentId, $simId]);
$answers = $db->fetchAll('SELECT phase, question_id, answer, correct, answered_at FROM assessment_answers WHERE session_id IN (SELECT id FROM student_sessions WHERE class_id = (SELECT class_id FROM students WHERE id = ?)) AND sim_id = ? ORDER BY answered_at DESC', [$studentId, $simId]);
Response::ok(['progress' => $progress ?: ['level'=>1,'xp'=>0,'plays'=>0,'best_stars'=>0], 'answers' => $answers]);
}
// Lehrer*in: alle Fortschritte eines/r bestimmten Schüler*in
if ($studentId && !$simId) {
$teacherId = Session::requireTeacher();
$all = $db->fetchAll('SELECT sim_id, level, xp, plays, best_stars FROM player_progress WHERE student_id = ? ORDER BY sim_id', [$studentId]);
Response::ok($all);
}
// Schüler*in: eigene Fortschritts-Liste (Frontend ruft mit student_id=0 oder ohne)
if (!$simId) {
$sessionId = Session::requireStudent();
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
$student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null;
if (!$student) { Response::ok([]); }
$studentId = (int)$student['id'];
$all = $db->fetchAll(
'SELECT pp.sim_id, pp.level, pp.xp, pp.plays, pp.best_stars,
mi.title, mi.icon,
(SELECT MAX(submitted_at) FROM assessments
WHERE session_id IN (SELECT id FROM student_sessions
WHERE class_id = ? AND display_name = ?)
AND sim_id = pp.sim_id) AS last_played_at
FROM player_progress pp
LEFT JOIN module_info mi ON mi.module_id = pp.sim_id
WHERE pp.student_id = ?
ORDER BY pp.xp DESC, pp.plays DESC',
[$session['class_id'], $session['display_name'], $studentId]
);
Response::ok($all);
}
Response::error('sim_id oder student_id erforderlich');
}
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
// === Spiel-Ergebnis speichern + Level berechnen ===
if ($action === 'complete') {
$sessionId = Session::requireStudent();
$simId = $body['simId'] ?? '';
$stars = max(0, min(5, (int)($body['stars'] ?? 0)));
$data = json_encode($body['data'] ?? []);
if (!$simId) Response::error('simId erforderlich');
// Student-ID finden
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
$student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null;
if (!$student) Response::error('Schüler*in nicht gefunden');
$studentId = (int)$student['id'];
// XP berechnen: 10 pro Stern + 5 Bonus für 5 Sterne
$xpGain = $stars * 10 + ($stars >= 5 ? 5 : 0);
// Upsert progress
$db->execute(
'INSERT INTO player_progress (student_id, sim_id, level, xp, plays, best_stars)
VALUES (?, ?, 1, ?, 1, ?)
ON DUPLICATE KEY UPDATE xp = xp + VALUES(xp), plays = plays + 1, best_stars = GREATEST(best_stars, VALUES(best_stars))',
[$studentId, $simId, $xpGain, $stars]
);
// Level berechnen: 0-49 XP = Level 1, 50-149 = Level 2, 150-299 = Level 3, 300+ = Level 4
$progress = $db->fetchOne('SELECT xp FROM player_progress WHERE student_id = ? AND sim_id = ?', [$studentId, $simId]);
$xp = (int)$progress['xp'];
$newLevel = $xp < 50 ? 1 : ($xp < 150 ? 2 : ($xp < 300 ? 3 : 4));
$db->execute('UPDATE player_progress SET level = ? WHERE student_id = ? AND sim_id = ?', [$newLevel, $studentId, $simId]);
// Assessment speichern
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, results, duration_ms, submitted_at) VALUES (?, ?, ?, ?, ?, NOW())',
[$sessionId, $simId, $session['class_id'], $data, (int)($body['durationMs'] ?? 0)]
);
Response::ok(['xpGained' => $xpGain, 'totalXp' => $xp + $xpGain, 'level' => $newLevel, 'stars' => $stars]);
}
// === Pre/Post-Antworten speichern ===
if ($action === 'answer') {
$sessionId = Session::requireStudent();
$simId = $body['simId'] ?? '';
$phase = $body['phase'] ?? ''; // 'pre' oder 'post'
$questionId = (int)($body['questionId'] ?? 0);
$answer = substr(trim($body['answer'] ?? ''), 0, 1);
$correct = (bool)($body['correct'] ?? false);
if (!$simId || !$phase || !$questionId || !$answer) Response::error('Felder unvollständig');
$db->execute(
'INSERT INTO assessment_answers (session_id, sim_id, phase, question_id, answer, correct) VALUES (?, ?, ?, ?, ?, ?)',
[$sessionId, $simId, $phase, $questionId, $answer, $correct ? 1 : 0]
);
Response::ok();
}
// === Sim-Abschluss speichern (Klima-Format mit nested data) ===
// Klima 2D + 3D senden action: 'submit_assessment' mit Body
// {sim_id, data: {level, stars, score, duration_ms, completed, results, action_log, badges_earned}}.
// 'complete' (oben) erwartet ein anderes Format; daher hier ein eigener Handler.
if ($action === 'submit_assessment') {
$sessionId = Session::requireStudent();
$simId = $body['sim_id'] ?? $body['simId'] ?? '';
if (!$simId) Response::error('sim_id erforderlich');
$payload = is_array($body['data'] ?? null) ? $body['data'] : [];
$stars = max(0, min(5, (int)($payload['stars'] ?? 0)));
$durationMs = (int)($payload['duration_ms'] ?? $body['durationMs'] ?? 0);
$resultsJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
$student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null;
if (!$student) Response::error('Schüler*in nicht gefunden');
$studentId = (int)$student['id'];
// XP + player_progress upserten (gleiche Logik wie 'complete')
$xpGain = $stars * 10 + ($stars >= 5 ? 5 : 0);
$db->execute(
'INSERT INTO player_progress (student_id, sim_id, level, xp, plays, best_stars)
VALUES (?, ?, 1, ?, 1, ?)
ON DUPLICATE KEY UPDATE xp = xp + VALUES(xp), plays = plays + 1, best_stars = GREATEST(best_stars, VALUES(best_stars))',
[$studentId, $simId, $xpGain, $stars]
);
$progress = $db->fetchOne('SELECT xp FROM player_progress WHERE student_id = ? AND sim_id = ?', [$studentId, $simId]);
$xp = (int)$progress['xp'];
$newLevel = $xp < 50 ? 1 : ($xp < 150 ? 2 : ($xp < 300 ? 3 : 4));
$db->execute('UPDATE player_progress SET level = ? WHERE student_id = ? AND sim_id = ?', [$newLevel, $studentId, $simId]);
// Assessment speichern (zentrale Tabelle für Lehrer-Auswertung)
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, results, duration_ms, submitted_at) VALUES (?, ?, ?, ?, ?, NOW())',
[$sessionId, $simId, $session['class_id'], $resultsJson, $durationMs]
);
// Spiegel in student_results (V2)
$score = isset($payload['score']) ? (float)$payload['score'] : null;
$db->execute(
'INSERT INTO student_results (student_id, class_id, module_id, score, duration_sec, detail_json, completed_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())',
[$studentId, $session['class_id'], $simId, $score, (int)round($durationMs/1000), $resultsJson]
);
Response::ok(['xpGained' => $xpGain, 'totalXp' => $xp + $xpGain, 'level' => $newLevel, 'stars' => $stars]);
}
// === Endscreen-Reflexion speichern (Multiple-Choice-Antwort am Spielende) ===
// Sims senden {action:'reflection', data:{level, question, answer}}.
// Speichert die Antwort an den letzten assessment-Eintrag des Schülers für
// diese Sim — damit Lehrkräfte später nachvollziehen können, was die
// Bearbeiter:in als wirksamste/schwierigste Entscheidung empfunden hat.
if ($action === 'reflection') {
$sessionId = Session::requireStudent();
$simId = $body['sim_id'] ?? $body['simId'] ?? '';
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
if (!$simId) Response::error('sim_id erforderlich');
// Letzten assessment-Eintrag dieser Schüler:in × Sim suchen.
$row = $db->fetchOne(
'SELECT id, reflections FROM assessments
WHERE session_id = ? AND sim_id = ? ORDER BY submitted_at DESC LIMIT 1',
[$sessionId, $simId]
);
$entry = [
'level' => $data['level'] ?? null,
'question' => $data['question'] ?? null,
'answer' => $data['answer'] ?? null,
'recorded_at'=> date('Y-m-d H:i:s'),
];
if ($row) {
$existing = $row['reflections'] ? json_decode($row['reflections'], true) : [];
if (!is_array($existing)) $existing = [];
$existing[] = $entry;
$db->execute(
'UPDATE assessments SET reflections = ? WHERE id = ?',
[json_encode($existing, JSON_UNESCAPED_UNICODE), $row['id']]
);
} else {
// Kein Assessment-Eintrag (z. B. Reflexion vor submit_assessment) →
// schreibe einen Stub mit nur Reflexion. Wird bei späterem Submit
// nicht überschrieben (verschiedene IDs).
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, reflections) VALUES (?, ?, ?, ?)',
[$sessionId, $simId, $session['class_id'] ?? null, json_encode([$entry], JSON_UNESCAPED_UNICODE)]
);
}
Response::ok();
}
// === Wirkungsklammer-Ergebnis abrufen ===
if ($action === 'wirkung') {
$sessionId = $body['sessionId'] ?? Session::studentId();
$simId = $body['simId'] ?? '';
if (!$simId) Response::error('simId erforderlich');
$pre = $db->fetchAll('SELECT question_id, correct FROM assessment_answers WHERE session_id = ? AND sim_id = ? AND phase = "pre"', [$sessionId, $simId]);
$post = $db->fetchAll('SELECT question_id, correct FROM assessment_answers WHERE session_id = ? AND sim_id = ? AND phase = "post"', [$sessionId, $simId]);
$preCorrect = count(array_filter($pre, function($a){return $a['correct'];}));
$postCorrect = count(array_filter($post, function($a){return $a['correct'];}));
Response::ok([
'pre' => ['total' => count($pre), 'correct' => $preCorrect],
'post' => ['total' => count($post), 'correct' => $postCorrect],
'improvement' => $postCorrect - $preCorrect,
]);
}
Response::error('Unbekannte Aktion');
}
Response::error('Methode nicht erlaubt', 405);