0a2ebf46b4
Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails, Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform, Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau- Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf), Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md). Plattform: PHP-Wrapper mit base-Tag + Session-Mode, Modul-Detailseite, module_info (beta) + Glossar (14 Begriffe inkl. Leichter Sprache + Quellen) + Lehrplan-Anker AT/DE/CH an neuer Kompetenz tourismus-raumentwicklung. Lehrer-Backend: Benchmark-Formel, Live-Cockpit-Spalten, needsHelp-Heuristik, Modul-Highlights. Strategie-Testharness (tests/strategien.mjs) belegt: Panzer-Rush wird liquidiert, Ruecklagen zahlen sich aus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
472 lines
20 KiB
PHP
472 lines
20 KiB
PHP
<?php
|
||
/**
|
||
* GeoGraSim · Benchmark-Engine
|
||
*
|
||
* Berechnet pro Submission einen normalisierten Modul-Score (0–100)
|
||
* sowie über alle Submissions einer Schüler:in einen Gesamt-Benchmark.
|
||
*
|
||
* Design-Prinzipien:
|
||
* - Jede Modul-Formel ist eine eigene private Methode, dokumentiert mit
|
||
* Quelle und Erwartungswert. Wenn ein erwartetes Feld fehlt → null
|
||
* statt eine geratene Zahl (lieber „noch keine Bewertung" als falsch).
|
||
* - Gesamt-Benchmark = Mittelwert über alle gespielten Module, wobei
|
||
* pro Modul max. die 3 besten Sessions zählen (verhindert Dominanz
|
||
* durch viel-gespielte Sims, ohne stillgewordene Module zu verlieren).
|
||
* - Frontend zeigt den Wert als „Benchmark" — nicht als Note. Schüler:innen
|
||
* sehen ihn nicht direkt.
|
||
*
|
||
* Aufrufer:
|
||
* - App/php/api/live.php (cockpit-Block) für Top 3 / Bottom 3 und
|
||
* benchmarkPct pro Schüler:in.
|
||
* - App/php/api/results.php (perspektivisch — kann den heutigen
|
||
* Stars-Stub im Cockpit ersetzen).
|
||
*
|
||
* Stand: 2026-06-11
|
||
*/
|
||
|
||
class Benchmark
|
||
{
|
||
/**
|
||
* Berechnet den Modul-Score (0–100) für eine einzelne Submission.
|
||
* Returns null wenn die Submission keine bewertbaren Daten enthält.
|
||
*/
|
||
public static function scoreSubmission(string $simId, ?string $resultsJson, ?string $processLogJson = null): ?int
|
||
{
|
||
$results = $resultsJson ? json_decode($resultsJson, true) : null;
|
||
$process = $processLogJson ? json_decode($processLogJson, true) : null;
|
||
if (!is_array($results) && !is_array($process)) return null;
|
||
$results = is_array($results) ? $results : [];
|
||
$process = is_array($process) ? $process : [];
|
||
|
||
// Pro Sim dispatchen. Klima-3D nutzt dieselbe Logik wie 2D.
|
||
$score = match ($simId) {
|
||
'klima', 'klima-3d' => 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),
|
||
// 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
|
||
JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name
|
||
WHERE s.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
|
||
{
|
||
$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)));
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|