self::scoreKlima($results), 'heli' => self::scoreHeli($results, $process), 'busfahrt' => self::scoreBusfahrt($results), 'farmer' => self::scoreFarmer($results), 'sonnensystem' => self::scoreSonnensystem($results), 'eu-werkstatt' => self::scoreEuWerkstatt($results), 'energiemanager' => self::scoreEnergiemanager($results), 'weltkueche' => self::scoreWeltkueche($results), 'logistik' => self::scoreLogistik($results), 'fluss' => self::scoreFluss($results), 'fluggesellschaft' => self::scoreFluggesellschaft($results), 'kofferdetektiv' => self::scoreKofferdetektiv($results), 'tourismustal' => self::scoreTourismustal($results), 'tourismusregion' => self::scoreTourismusregion($results), // Entscheidungstag: kein assessment-Submit-Pfad bekannt → null default => self::scoreFallbackStars($results), }; if ($score === null) return null; return max(0, min(100, (int)round($score))); } /** * Gesamt-Benchmark einer Schüler:in über alle ihre Submissions hinweg. * Pro Modul werden die TOP-3-Sessions berücksichtigt (Mittelwert), * dann gewichteter Mittelwert über alle gespielten Module. * * Returns null wenn die Schüler:in noch nirgendwo bewertbar gespielt hat. */ public static function studentOverall($db, int $studentId, int $classId): ?int { // Bewertbare Submissions ziehen, neueste zuerst pro (Schüler × Modul) $rows = $db->fetchAll( "SELECT a.sim_id, a.results, a.process_log, a.submitted_at FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id WHERE ss.student_id = ? AND a.class_id = ? ORDER BY a.sim_id, a.submitted_at DESC", [$studentId, $classId] ); if (!$rows) return null; // Pro Modul: bis zu 3 beste Scores sammeln $perModuleScores = []; foreach ($rows as $r) { $sim = $r['sim_id']; $score = self::scoreSubmission($sim, $r['results'], $r['process_log']); if ($score === null) continue; if (!isset($perModuleScores[$sim])) $perModuleScores[$sim] = []; // Nach DESC sortiert eingehend → erste 3 sind die jüngsten, // wir wollen die BESTEN 3 (egal wann) → erst alle sammeln, dann sortieren. $perModuleScores[$sim][] = $score; } if (!$perModuleScores) return null; // Pro Modul Top 3 mitteln $perModuleAvg = []; foreach ($perModuleScores as $sim => $list) { rsort($list); // höchster zuerst $top3 = array_slice($list, 0, 3); // bis zu drei $perModuleAvg[$sim] = array_sum($top3) / count($top3); } // Gesamt-Mittelwert über alle gespielten Module $overall = array_sum($perModuleAvg) / count($perModuleAvg); return (int)round($overall); } // ==================================================================== // PRO-MODUL-FORMELN // Jede returnt 0–100 oder null (wenn Daten unbewertbar). // ==================================================================== /** * Klima 2D/3D — vier didaktische Dimensionen, gemittelt. * Quelle: assessments.results.results.{final_co2, final_temperature, * final_budget, final_flooded_pct, final_population} * Skalen aus App/teacher.html (_klimaScoreTemp etc.) übernommen. */ private static function scoreKlima(array $r): ?int { // Klima verschachtelt: top-level + nested 'results'-Objekt $rr = $r['results'] ?? $r; $temp = $rr['final_temperature'] ?? null; $co2 = $rr['final_co2'] ?? null; $budget = $rr['final_budget'] ?? null; $flooded = $rr['final_flooded_pct'] ?? null; $pop = $rr['final_population'] ?? null; $parts = []; // Temperatur: 15.5° → 100, 18.5° → 0 (linear klemmen) if (is_numeric($temp)) { $v = (18.5 - $temp) / 3.0 * 100; $parts[] = max(0, min(100, $v)); } // Inselüberflutung: 0 % → 100, 100 % → 0 if (is_numeric($flooded)) { $parts[] = max(0, min(100, 100 - $flooded)); } // Budget: −200 → 0, 0 → 40, 500 → 90, ≥1000 → 100 if (is_numeric($budget)) { $b = (float)$budget; if ($b <= -200) $v = 0; elseif ($b < 0) $v = 40 + ($b / -200) * -40; elseif ($b < 500) $v = 40 + ($b / 500) * 50; else $v = 90 + min(10, ($b - 500) / 50); $parts[] = max(0, min(100, $v)); } // Bevölkerung: 60 % → 0, 90 % → 90, 100 %+ → 100 // Klima hat „population" als absolute Zahl, nicht Prozent — wir lassen das // hier optional weg, weil Skalierung start-pop-abhängig ist. if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Heli — Sterne als primärer Score, planPct als sekundäre Qualität. * Quelle: assessments.results.{sterneGesamt, planPct, startScore, landingScore} * Viele Heli-Submissions sind leere Arrays (Interim-Speicherungen) → null. */ private static function scoreHeli(array $r, array $proc): ?int { $stars = $r['sterneGesamt'] ?? ($r['stars'] ?? null); $planPct = $r['planPct'] ?? null; $startScore = $r['startScore'] ?? null; // 0-5 $landingScore = $r['landingScore'] ?? null; // 0-5 // Wenn nichts da, abbrechen if (!is_numeric($stars) && !is_numeric($planPct)) return null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($planPct)) $parts[] = (float)$planPct; if (is_numeric($startScore)) $parts[] = ($startScore / 5) * 100; if (is_numeric($landingScore)) $parts[] = ($landingScore / 5) * 100; if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Busfahrt — Tour-Score aus stars + Quiz-Quote + Distanz-Abweichung. * Quelle: assessments.results.{stars, wonOrders, totalOrders, * quizFirstTry, avgTapKm} */ private static function scoreBusfahrt(array $r): ?int { $stars = $r['stars'] ?? null; $wonOrders = $r['wonOrders'] ?? null; $totalOrders = $r['totalOrders'] ?? null; $quizFirstTry = $r['quizFirstTry'] ?? null; $avgTapKm = $r['avgTapKm'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($wonOrders) && is_numeric($totalOrders) && $totalOrders > 0) { $parts[] = ($wonOrders / $totalOrders) * 100; } if (is_numeric($quizFirstTry) && is_numeric($totalOrders) && $totalOrders > 0) { $parts[] = ($quizFirstTry / $totalOrders) * 100; } // Distanz: 30 km → 100, 200 km → 0 (linear klemmen) if (is_numeric($avgTapKm)) { $d = (float)$avgTapKm; $parts[] = max(0, min(100, (200 - $d) / 170 * 100)); } if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Farmer — Erfolgsquote + erreichte Jahre + Sortenvielfalt. * Quelle: assessments.results.{stars, hit_rate, year, crops_used} * Fallback wenn nichts da: Sterne aus top-level. */ private static function scoreFarmer(array $r): ?int { $stars = $r['stars'] ?? null; $hitRate = $r['hit_rate'] ?? null; $year = $r['year'] ?? null; $cropsUsed = $r['crops_used'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($hitRate)) $parts[] = (float)$hitRate; // 0 Jahre → 0, 10 Jahre → 80, 15+ → 100 if (is_numeric($year)) { $y = (int)$year; if ($y <= 0) $v = 0; elseif ($y < 10) $v = ($y / 10) * 80; elseif ($y < 15) $v = 80 + (($y - 10) / 5) * 20; else $v = 100; $parts[] = $v; } // Sortenvielfalt: 0 → 0, 6 → 100 (gekappt) if (is_numeric($cropsUsed)) { $parts[] = max(0, min(100, ($cropsUsed / 6) * 100)); } if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Sonnensystem — eine Submission = ein gelöstes Task-Event. * Quelle: assessments.results.{completed, total, firstTry} * Score: completed/total × Bonus für firstTry. */ private static function scoreSonnensystem(array $r): ?int { // Neue Bewertung = Erst-Treffer-Quote (Meisterschaft auf Anhieb, 0–100). // Nur „alle gelöst" ergibt nicht mehr automatisch 100 %. $firstTryQuote = $r['firstTryQuote'] ?? null; if (is_numeric($firstTryQuote)) { return (int)round(max(0, min(100, (float)$firstTryQuote))); } // Fallback für Alt-Datensätze ohne Erst-Treffer-Feld. $completed = $r['completed'] ?? null; $total = $r['total'] ?? null; $firstTry = $r['firstTry'] ?? null; if (!is_numeric($completed) || !is_numeric($total) || $total <= 0) return null; $base = ($completed / $total) * 100; // Bonus +10 % wenn FirstTry, gekappt if ($firstTry === true || $firstTry === 1 || $firstTry === '1') { $base = min(100, $base + 10); } return (int)round($base); } /** * EU-Werkstatt — Quote der Erste-Wahl-Treffer × Sauberkeit (kein Mülleimer). * Quelle: assessments.results.{endType, stepsTotal, firstTryHits, * totalAttempts, muelleimerCount} */ private static function scoreEuWerkstatt(array $r): ?int { $endType = $r['endType'] ?? null; $stepsTotal = $r['stepsTotal'] ?? null; $firstTryHits = $r['firstTryHits'] ?? null; $totalAttempts = $r['totalAttempts'] ?? null; $muelleimer = $r['muelleimerCount']?? null; if (!is_numeric($stepsTotal) || $stepsTotal <= 0) return null; $firstTryPct = is_numeric($firstTryHits) ? ($firstTryHits / $stepsTotal) * 100 : 0; $cleanFactor = 1.0; if (is_numeric($muelleimer) && is_numeric($totalAttempts) && $totalAttempts > 0) { $cleanFactor = max(0, 1 - ($muelleimer / $totalAttempts)); } $score = $firstTryPct * $cleanFactor; // Erfolgs-Bonus if ($endType === 'success') $score = min(100, $score + 5); return (int)round($score); } /** * Energiemanager — Anteil ausbalancierter Blöcke × Vollständigkeit der Tage. * Quelle: assessments.results.{stars, blocksBalanced, blocksTotal, * daysPlayed, daysTotal} */ private static function scoreEnergiemanager(array $r): ?int { $stars = $r['stars'] ?? null; $bb = $r['blocksBalanced'] ?? null; $bt = $r['blocksTotal'] ?? null; $dp = $r['daysPlayed'] ?? null; $dt = $r['daysTotal'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($bb) && is_numeric($bt) && $bt > 0) $parts[] = ($bb / $bt) * 100; if (is_numeric($dp) && is_numeric($dt) && $dt > 0) $parts[] = ($dp / $dt) * 100; if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Weltküche — abgeschlossene Gerichte × Fehlklick-Qualität. * Quelle: assessments.results.{stars, completedDishes, totalDishes, * mistakesInDish, score} */ private static function scoreWeltkueche(array $r): ?int { $stars = $r['stars'] ?? null; $completed = $r['completedDishes'] ?? ($r['completed'] ?? null); $total = $r['totalDishes'] ?? ($r['completedTotal'] ?? null); $mistakes = $r['mistakesInDish'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($completed) && is_numeric($total) && $total > 0) $parts[] = ($completed / $total) * 100; // Fehlklick-Penalty: 0 → 100, 15 → 0 if (is_numeric($mistakes)) { $parts[] = max(0, min(100, 100 - (((int)$mistakes) / 15) * 100)); } if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Logistik — pünktliche Auslieferungen × positive Endbilanz. * Quelle: assessments.results.{stars, ordersDelivered, totalOrders, * balance, ordersLate} */ private static function scoreLogistik(array $r): ?int { $stars = $r['stars'] ?? null; $delivered = $r['ordersDelivered'] ?? null; $total = $r['totalOrders'] ?? null; $late = $r['ordersLate'] ?? null; $balance = $r['balance'] ?? null; $finalBalance = $r['finalBalance'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($delivered) && is_numeric($total) && $total > 0) { // Pünktlich = delivered minus late, normiert $onTime = max(0, $delivered - (is_numeric($late) ? $late : 0)); $parts[] = ($onTime / $total) * 100; } $bal = $finalBalance ?? $balance; if (is_numeric($bal)) { // <= -1000 → 0, 0 → 40, +1000 → 90, +5000+ → 100 $b = (float)$bal; if ($b <= -1000) $v = 0; elseif ($b < 0) $v = 40 + ($b / -1000) * -40; elseif ($b < 1000) $v = 40 + ($b / 1000) * 50; else $v = 90 + min(10, ($b - 1000) / 400); $parts[] = max(0, min(100, $v)); } if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Fluss — vier didaktische Dimensionen, gemittelt. * Quelle: assessments.results.{stars, biodiv, economy, flood, food, population} */ private static function scoreFluss(array $r): ?int { $stars = $r['stars'] ?? null; $bd = $r['biodiv'] ?? ($r['final_biodiv'] ?? null); $ec = $r['economy'] ?? ($r['final_economy'] ?? null); $fl = $r['flood'] ?? ($r['final_flood'] ?? null); $fd = $r['food'] ?? ($r['final_food'] ?? null); $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($bd)) $parts[] = max(0, min(100, (float)$bd)); if (is_numeric($ec)) $parts[] = max(0, min(100, (float)$ec)); if (is_numeric($fl)) $parts[] = max(0, min(100, 100 - (float)$fl)); // niedrig = gut if (is_numeric($fd)) $parts[] = max(0, min(100, (float)$fd)); if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Fluggesellschaft — Gewonnene Aufträge × Quiz-Quote × Marge. * Aktuell als „geplant" markiert, Formel liegt vor für Aktivierung. */ private static function scoreFluggesellschaft(array $r): ?int { $stars = $r['stars'] ?? null; $wonOrders = $r['wonOrders'] ?? null; $totalOrders = $r['totalOrders'] ?? null; $quizFirstTry = $r['quizFirstTry'] ?? null; $earnings = $r['earnings'] ?? null; $parts = []; if (is_numeric($stars)) $parts[] = ($stars / 5) * 100; if (is_numeric($wonOrders) && is_numeric($totalOrders) && $totalOrders > 0) { $parts[] = ($wonOrders / $totalOrders) * 100; } if (is_numeric($quizFirstTry) && is_numeric($totalOrders) && $totalOrders > 0) { $parts[] = ($quizFirstTry / $totalOrders) * 100; } if (!$parts) return null; return (int)round(array_sum($parts) / count($parts)); } /** * Kofferdetektiv — Lösungsquote × Erstversuch × Hinweis-Effizienz − Fehler-Malus. * Formel aus INTEGRATION.md (Sim-Lieferung): * 60 % Lösungsquote + 25 % Erstversuch + 15 % Hinweis-Effizienz − bis 20 % Fehlversuch-Malus. * Sim-internal: 100 Basis pro Fall, −7 je Hinweis ab dem zweiten, −15 je Fehlversuch, * −20 bei Zeitüberschreitung, Minimum 10 bei Lösung. */ private static function scoreKofferdetektiv(array $r): ?int { $total = (int)($r['cases_total'] ?? 0); $solved = (int)($r['cases_solved'] ?? 0); $firstTry = (int)($r['correct_first_try'] ?? 0); $wrong = (int)($r['wrong_guesses'] ?? 0); $hintsAvg = (float)($r['hints_used_avg'] ?? 10); if ($total === 0) return null; $base = ($solved / $total) * 60; // 60 % Lösungsquote $clean = ($firstTry / $total) * 25; // 25 % Erstversuch-Quote $sparse = max(0, (10 - $hintsAvg) / 9) * 15; // 15 % Hinweis-Effizienz (wenig Hinweise → mehr Punkte) $malus = min(20, $wrong * 4); // bis −20 für Fehlversuche return (int)round(max(0, min(100, $base + $clean + $sparse - $malus))); } /** * Tourismustal — Zielerreichung × Zufriedenheit × Naturwert − Schulden-Malus. * 50 % Szenen-Ziele + 25 % Gäste-Zufriedenheit + 25 % Naturwert, * −15 wenn das Konto im Minus endet (Rücklagen sind Teil der Didaktik). * Felder aus dem Submit-Hook (game.html → progress.php submit_assessment). */ private static function scoreTourismustal(array $r): ?int { $goalsDone = $r['goalsDone'] ?? null; $goalsTotal = (int)($r['goalsTotal'] ?? 0); if (!is_numeric($goalsDone) || $goalsTotal <= 0) return null; $sat = (float)($r['satisfaction'] ?? 0); $nature = (float)($r['nature'] ?? 0); $money = (float)($r['money'] ?? 0); $score = ($goalsDone / $goalsTotal) * 50 + ($sat / 100) * 25 + ($nature / 100) * 25 - ($money < 0 ? 15 : 0); return (int)round(max(0, min(100, $score))); } /** * Tourismusregion — die Sim berechnet selbst einen gewichteten 0–100-Score * (Bewertung 35 % + Wertschöpfung 25 % + Umwelt 20 % + Besucherlenkung 10 % * + Finanzen 10 %, model.js finish()). Den übernehmen wir direkt. */ private static function scoreTourismusregion(array $r): ?int { $score = $r['score'] ?? null; if (is_numeric($score)) return (int)round(max(0, min(100, $score))); // Fallback: Zielerreichung $done = $r['goalsDone'] ?? null; $total = (int)($r['goalsTotal'] ?? 0); if (!is_numeric($done) || $total <= 0) return null; return (int)round(($done / $total) * 100); } /** * Generischer Fallback: Sterne aus 5-er-Skala. */ private static function scoreFallbackStars(array $r): ?int { $stars = $r['stars'] ?? ($r['totalStars'] ?? null); if (!is_numeric($stars)) return null; return (int)round(($stars / 5) * 100); } }