Files
geograsim/App/php/api/progress.php
T
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

322 lines
17 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 ===
// === Blackout (Energiemanager): Fehlversuch sofort protokollieren ===
// Ein Blackout beendet das Set NICHT — die Schüler:in plant neu. Damit der
// Fehlversuch aber auch bei Abbruch beim Lehrer sichtbar bleibt, schreiben
// wir ihn als eigene assessments-Zeile (score:0 → roter Tag-Balken).
// KEIN player_progress-Update: ein Blackout gibt weder Sterne noch XP.
if ($action === 'blackout') {
$sessionId = Session::requireStudent();
$simId = $body['simId'] ?? '';
if (!$simId) Response::error('simId erforderlich');
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
if (!defined('GGS_MAX_SESSION_MS')) define('GGS_MAX_SESSION_MS', 7200000);
$durationMs = max(0, min((int)($body['durationMs'] ?? 0), GGS_MAX_SESSION_MS));
$data = json_encode($body['data'] ?? []);
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, results, duration_ms, submitted_at) VALUES (?, ?, ?, ?, ?, NOW())',
[$sessionId, $simId, $session['class_id'] ?? null, $data, $durationMs]
);
Response::ok(['recorded' => 'blackout']);
}
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);