Files
geograsim/App/php/api/progress.php
T
Adminator fb44ec7afe student_id-FK-Migration Stufe 1+2: Name-Joins -> student_sessions.student_id
- student_sessions: neue Spalte student_id (+Index), backfilled aus (class_id,display_name)
- Session::createStudent setzt student_id (per Lookup, falls nicht uebergeben)
- alle Joins students<->student_sessions von display_name auf student_id umgestellt:
  Benchmark.php, live.php (5x), results.php (4x + runs), modules.php, profile.php, progress.php (answers+runs)
- verlustfrei verifiziert (alt-Join == neu-Join, class 99/102)

Robuster gegen Umbenennung/Doppelnamen, indexierbare Joins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 19:16:41 +02:00

302 lines
16 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'] ?? '';
$reqStudentId = (int)($_GET['student_id'] ?? 0);
$studentId = 0;
if ($reqStudentId) {
// Lehrer-Ansicht: nur eingeloggte Lehrperson, und die:der Schüler:in
// MUSS zu einer Klasse dieser Lehrperson gehören (sonst IDOR).
$teacherId = Session::requireTeacher();
$owned = $db->fetchOne(
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$reqStudentId, $teacherId]
);
if (!$owned) Response::error('Kein Zugriff auf diese:n Schüler:in', 403);
$studentId = $reqStudentId;
} elseif ($simId) {
// Schüler*in: eigener Fortschritt (aus der Session abgeleitet)
$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 student_id = ?) AND sim_id = ? ORDER BY answered_at DESC', [$studentId, $simId]);
// Optional: alle Durchgänge mit results-JSON, wenn ?runs=1 → für Schüler-Eigen-Drilldown
$runs = [];
if (!empty($_GET['runs'])) {
$student = $db->fetchOne('SELECT class_id, display_name FROM students WHERE id = ?', [$studentId]);
if ($student) {
// Heartbeats (z.B. heli's 30-s assessmentPing) haben keine
// Mission-Ergebnisse. Wir wollen im Schüler-Drilldown NUR
// tatsächlich abgeschlossene Durchgänge zeigen — also Rows
// mit `stars` (submitHeliMission, klima, busfahrt etc.) ODER
// `sterneGesamt` ODER `missionId` (alte heli-assessmentComplete).
$rows = $db->fetchAll(
"SELECT a.id, a.submitted_at, a.duration_ms, a.results
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
WHERE ss.student_id = ? AND a.sim_id = ?
AND a.results IS NOT NULL
AND (
JSON_EXTRACT(a.results, '$.stars') IS NOT NULL
OR JSON_EXTRACT(a.results, '$.sterneGesamt') IS NOT NULL
OR JSON_EXTRACT(a.results, '$.missionId') IS NOT NULL
OR JSON_EXTRACT(a.results, '$.results.missionId') IS NOT NULL
OR JSON_EXTRACT(a.results, '$.results.totalStars') IS NOT NULL
)
ORDER BY a.submitted_at DESC
LIMIT 50",
[$studentId, $simId]
);
foreach ($rows as $row) {
$row['results'] = $row['results'] ? json_decode($row['results'], true) : null;
$runs[] = $row;
}
}
}
Response::ok(['progress' => $progress ?: ['level'=>1,'xp'=>0,'plays'=>0,'best_stars'=>0], 'answers' => $answers, 'runs' => $runs]);
}
// 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 — Cap defensiv (siehe GGS_MAX_SESSION_MS).
if (!defined('GGS_MAX_SESSION_MS')) define('GGS_MAX_SESSION_MS', 7200000);
$durationMs = max(0, min((int)($body['durationMs'] ?? 0), GGS_MAX_SESSION_MS));
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, results, duration_ms, submitted_at) VALUES (?, ?, ?, ?, ?, NOW())',
[$sessionId, $simId, $session['class_id'], $data, $durationMs]
);
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)));
// Cap defensiv: nie >120 min in die DB (siehe GGS_MAX_SESSION_MS).
if (!defined('GGS_MAX_SESSION_MS')) define('GGS_MAX_SESSION_MS', 7200000);
$rawDuration = (int)($payload['duration_ms'] ?? $body['durationMs'] ?? 0);
$durationMs = max(0, min($rawDuration, GGS_MAX_SESSION_MS));
$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);