Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

389 lines
14 KiB
JavaScript
Raw Permalink 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.
/**
* Fluggesellschaft — Engine (Phase 1)
*
* Schwester-Modul zu Busfahrt. Wichtigste Unterschiede:
* - Großkreis-Routing (kürzeste Verbindung auf Kugel) statt OSRM
* - Flughafen-Marker = Stadtmitte (vereinfacht; reale Flughäfen liegen
* oft außerhalb, didaktisch nicht relevant in dieser Stufe)
* - Tarif/Kosten in €/km wie Bus, aber andere Werte (Flug ist teurer
* pro Sitzplatz, aber schneller)
* - Quiz hat 2 Fragen-Typen (Land + Kontinent), Engine wählt
* deterministisch pro Auftrag-ID
*
* Plattform-Hooks (vom Wrapper):
* FLUG_BASE, FLUG_FORCED_LEVEL, STUDENT_EASY,
* FLUG_API_BASE, FLUG_SESSION_ID
*/
window.FlugEngine = (function () {
'use strict';
let CITIES_INDEX = null;
let ORDERS_POOL = null;
/** Bewertungs-Schwellen — Flughafen-Tap (km Abweichung). Großzügiger
* als Bus, weil Welt-Karte deutlich größer skaliert ist. */
const HIT_THRESHOLDS = {
PERFECT: 200,
OK: 800,
};
/** Wirtschafts-Konstanten. Werte mit {l1,l2,l3} sind pro Level. */
const ECON = {
startBudget: 8000, // höher als Bus (Flugzeug ist teurer)
targetBudget: { l1: 14000, l2: 32000, l3: 60000 },
tariffPerKm: 0.18, // €/km — Ticketpreis pro Sitzplatz × 50 Pax = effektiv 9 €/km
costPerKm: 0.13, // €/km — Treibstoff + Flughafengebühr + Crew
tapToleranceKm: { l1: 250, l2: 200, l3: 150 },
nightFirst: { l1: 5, l2: 3, l3: 1 },
nightChanceAfter: { l1: 0.15, l2: 0.30, l3: 0.60 },
tourLength: { l1: 10, l2: 14, l3: 18 },
homeCity: 'frankfurt',
quizBonusFirstTryEur: { l1: 100, l2: 150, l3: 200 },
quizPenaltyPerWrongEur: { l1: 100, l2: 150, l3: 200 },
// CO2-Daten: pro Flug-km ca. 0.18 kg CO2 pro Passagier (Kurzstrecke).
// Wird in der Buchhaltung ausgewiesen — Umweltbildung.
co2KgPerKmPerPax: 0.18,
paxOnboard: 180,
};
function econ(key, levelId) {
const v = ECON[key];
if (v && typeof v === 'object' && !Array.isArray(v)) {
return v[levelId] != null ? v[levelId] : v.l1;
}
return v;
}
async function loadData() {
if (CITIES_INDEX && ORDERS_POOL) return;
const base = (window.FLUG_BASE || './') + 'assets/data/';
const [cRes, oRes] = await Promise.all([
fetch(base + 'cities.json'),
fetch(base + 'orders.json'),
]);
const cJson = await cRes.json();
const oJson = await oRes.json();
CITIES_INDEX = { raw: cJson, byId: Object.fromEntries(cJson.map(c => [c.id, c])) };
ORDERS_POOL = oJson;
}
/** Distanz-Filterung: L1 = Nahflug (<1500 km), L2 = Mittelstrecke (<4000 km),
* L3 = alles. Distanz wird gegen homeCity gerechnet. Aufträge ohne
* auflösbare cityId fallen heraus. Wenn ein Level zu wenig Aufträge hätte
* (Sicherheits-Floor 4), wird das Limit für dieses Level ignoriert. */
function distanceFromHomeKm(order) {
if (!CITIES_INDEX) return Infinity;
const home = CITIES_INDEX.byId[ECON.homeCity];
const dst = CITIES_INDEX.byId[order.cityId];
if (!home || !dst) return Infinity;
return haversineKm(home.lat, home.lon, dst.lat, dst.lon);
}
function filterPoolByLevel(levelId) {
if (!ORDERS_POOL) return [];
const inLevel = ORDERS_POOL.filter(o => {
if (levelId === 'l1') return o.level === 'l1';
if (levelId === 'l2') return o.level === 'l1' || o.level === 'l2';
return true;
});
// Distanz-Cap pro Level
const caps = { l1: 1500, l2: 4000, l3: Infinity };
const cap = caps[levelId] != null ? caps[levelId] : Infinity;
if (!isFinite(cap)) return inLevel;
const capped = inLevel.filter(o => distanceFromHomeKm(o) <= cap);
// Sicherheits-Floor: wenn unter 4 Aufträge übrig, Cap ignorieren
return capped.length >= 4 ? capped : inLevel;
}
function init(levelId, seed) {
const lvl = levelId || 'l1';
const seedNum = (seed || Date.now()) >>> 0;
const rng = mulberry32(seedNum);
const pool = filterPoolByLevel(lvl);
const tourLen = econ('tourLength', lvl) || 8;
const nightFirst = econ('nightFirst', lvl);
const nightChanceAfter = econ('nightChanceAfter', lvl);
const shuffled = shuffleSeeded(pool, rng);
// Tour-Auswahl analog Busfahrt: dreistufiger Filter, damit jede Stadt
// nur einmal pro Tour auftaucht (auch wenn mehrere Auftragstypen
// dieselbe cityId teilen).
const usedCities = new Set([ECON.homeCity]);
const candidates = shuffled.slice();
const tour = [];
let prevCity = ECON.homeCity;
for (let i = 0; i < tourLen && candidates.length; i++) {
let pickIdx = candidates.findIndex(c => !usedCities.has(c.cityId));
if (pickIdx === -1) pickIdx = candidates.findIndex(c => c.cityId !== prevCity);
if (pickIdx === -1) pickIdx = 0;
const pick = candidates.splice(pickIdx, 1)[0];
let isNight = false;
if (i === nightFirst) isNight = true;
else if (i > nightFirst && rng() < nightChanceAfter) isNight = true;
tour.push({ ...pick, isNight });
prevCity = pick.cityId;
usedCities.add(pick.cityId);
}
return {
levelId: lvl,
seed: seedNum,
tourOrders: tour,
orderIndex: 0,
currentOrder: null,
currentQuestion: null,
attempts: [],
score: 0,
budget: ECON.startBudget,
earnings: 0,
co2Kg: 0,
lastCity: ECON.homeCity,
reports: [],
};
}
/** Nächster Auftrag. Setzt auch game.currentQuestion mit Quiz-Daten.
* Wechselt deterministisch zwischen Land-Frage und Kontinent-Frage
* (basierend auf Auftrag-ID) — damit das Quiz nicht monoton wird. */
function nextOrder(game) {
if (game.orderIndex >= game.tourOrders.length) return null;
const order = game.tourOrders[game.orderIndex];
game.currentOrder = order;
game.attempts = [];
let q = null;
if (order.type === 'B' && Array.isArray(order.choices) && order.choices.length) {
const correctIdx = order.choices.indexOf(order.cityId);
q = {
kind: 'capital',
prompt: order.prompt,
choices: order.choices,
choiceLabels: order.choices.map(cid => {
const c = CITIES_INDEX && CITIES_INDEX.byId[cid];
return c ? c.title : cid;
}),
correctIndex: correctIdx >= 0 ? correctIdx : 0,
attempts: [], resolved: false, wasFirstTryCorrect: false,
bonusEur: 0, penaltyEur: 0,
};
} else {
const city = CITIES_INDEX && CITIES_INDEX.byId[order.cityId];
// Wechsel Land/Kontinent: gerade Hash → Land, ungerade → Kontinent
const h = simpleHash(order.id);
const cq = (h % 2 === 0) ? city && city.question : city && city.questionContinent;
const kind = (h % 2 === 0) ? 'country' : 'continent';
if (cq && Array.isArray(cq.choices)) {
q = {
kind,
prompt: cq.prompt,
choices: cq.choices.slice(),
choiceLabels: cq.choices.slice(),
correctIndex: cq.correctIndex,
attempts: [], resolved: false, wasFirstTryCorrect: false,
bonusEur: 0, penaltyEur: 0,
};
}
}
game.currentQuestion = q;
return order;
}
function evaluateQuestion(game, pickedIndex) {
const q = game.currentQuestion;
if (!q || q.resolved) return null;
if (typeof pickedIndex !== 'number' || pickedIndex < 0 || pickedIndex >= q.choices.length) return null;
q.attempts.push(pickedIndex);
const correct = pickedIndex === q.correctIndex;
const bonus = econ('quizBonusFirstTryEur', game.levelId);
const penalty = econ('quizPenaltyPerWrongEur', game.levelId);
let delta = 0;
if (correct) {
if (q.attempts.length === 1) {
q.wasFirstTryCorrect = true;
q.bonusEur = bonus;
delta = bonus;
}
q.resolved = true;
} else {
q.penaltyEur += penalty;
delta = -penalty;
}
game.budget += delta;
return {
correct, resolved: q.resolved, deltaEur: delta,
bonusEur: q.bonusEur, penaltyEur: q.penaltyEur,
attempts: q.attempts.slice(),
};
}
function checkTap(game, tap) {
if (!game.currentOrder || !CITIES_INDEX) return null;
const target = CITIES_INDEX.byId[game.currentOrder.cityId];
if (!target) return null;
const distanceKm = haversineKm(tap.lat, tap.lon, target.lat, target.lon);
let score = 0;
if (distanceKm < HIT_THRESHOLDS.PERFECT) score = 3;
else if (distanceKm < HIT_THRESHOLDS.OK) score = 1;
const hint = directionHint(tap, target, distanceKm);
const attempt = { tap, distanceKm, score, hint };
game.attempts.push(attempt);
const resolved = score > 0 || game.attempts.length >= 3;
return { ...attempt, resolved };
}
/** Auftrag abrechnen — Großkreis-Distanz vom Start-Flughafen zum Ziel-Flughafen.
* Tap-Abweichung: wenn weit daneben, fliegt der Pilot einen „Umweg" über die
* Tap-Position (didaktisch). Sonst Direkt-Großkreis. */
function settleOrder(game) {
if (!game.currentOrder || !CITIES_INDEX) return null;
if (!game.attempts.length) return null;
const lastAttempt = game.attempts[game.attempts.length - 1];
const tap = lastAttempt.tap;
const order = game.currentOrder;
const fromCity = CITIES_INDEX.byId[game.lastCity];
const toCity = CITIES_INDEX.byId[order.cityId];
if (!fromCity || !toCity) return null;
const realDistanceKm = haversineKm(fromCity.lat, fromCity.lon, toCity.lat, toCity.lon);
const startToTap = haversineKm(fromCity.lat, fromCity.lon, tap.lat, tap.lon);
const tapToTarget = haversineKm(tap.lat, tap.lon, toCity.lat, toCity.lon);
const viaTap = startToTap + tapToTarget;
const withinTolerance = lastAttempt.distanceKm < econ('tapToleranceKm', game.levelId);
const actualDistanceKm = withinTolerance ? realDistanceKm : viaTap;
const detourKm = Math.max(0, actualDistanceKm - realDistanceKm);
const tariff = order.tariffPerKm || ECON.tariffPerKm;
const playerPrice = realDistanceKm * tariff * ECON.paxOnboard;
const busCost = actualDistanceKm * ECON.costPerKm * ECON.paxOnboard;
const baseMargin = playerPrice - busCost;
const margin = Math.round(baseMargin);
const co2Kg = Math.round(actualDistanceKm * ECON.co2KgPerKmPerPax * ECON.paxOnboard);
const finalScore = lastAttempt.score;
game.score += finalScore;
game.budget += margin;
game.earnings += margin;
game.co2Kg += co2Kg;
const q = game.currentQuestion;
const quizBonus = q ? (q.bonusEur || 0) : 0;
const quizPenalty = q ? (q.penaltyEur || 0) : 0;
// Zeitzonen-Differenz beim Ankunft-Land (für Sachinfo in InfoCard)
const utcFrom = fromCity.utcOffset || 0;
const utcTo = toCity.utcOffset || 0;
const tzDiffHours = utcTo - utcFrom;
const report = {
orderId: order.id, cityId: order.cityId, fromCityId: game.lastCity,
realDistanceKm, actualDistanceKm, detourKm,
tapDistanceKm: lastAttempt.distanceKm, withinTolerance,
tariff,
playerPrice: Math.round(playerPrice),
busCost: Math.round(busCost),
baseMargin: Math.round(baseMargin),
co2Kg,
tzDiffHours,
quizBonus, quizPenalty,
quizFirstTry: !!(q && q.wasFirstTryCorrect),
quizAttemptsCount: q ? q.attempts.length : 0,
quizKind: q ? q.kind : null,
won: margin >= 0,
margin,
finalScore,
isNight: !!order.isNight,
attempts: game.attempts.slice(),
tap,
};
game.reports.push(report);
game.lastCity = order.cityId;
game.orderIndex += 1;
game.currentOrder = null;
game.currentQuestion = null;
game.attempts = [];
return report;
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
/** Großkreis-Linie als Polyline-Punkte (für Karten-Darstellung). 64 Segmente. */
function greatCirclePoints(lat1, lon1, lat2, lon2, segments) {
segments = segments || 64;
const toRad = d => d * Math.PI / 180;
const toDeg = r => r * 180 / Math.PI;
const φ1 = toRad(lat1), λ1 = toRad(lon1);
const φ2 = toRad(lat2), λ2 = toRad(lon2);
const d = 2 * Math.asin(Math.sqrt(
Math.sin((φ2-φ1)/2)**2 + Math.cos(φ1)*Math.cos(φ2) * Math.sin((λ2-λ1)/2)**2
));
if (d === 0) return [[lat1, lon1]];
const pts = [];
for (let i = 0; i <= segments; i++) {
const f = i / segments;
const A = Math.sin((1-f)*d) / Math.sin(d);
const B = Math.sin(f*d) / Math.sin(d);
const x = A*Math.cos(φ1)*Math.cos(λ1) + B*Math.cos(φ2)*Math.cos(λ2);
const y = A*Math.cos(φ1)*Math.sin(λ1) + B*Math.cos(φ2)*Math.sin(λ2);
const z = A*Math.sin(φ1) + B*Math.sin(φ2);
const φ = Math.atan2(z, Math.sqrt(x*x + y*y));
const λ = Math.atan2(y, x);
pts.push([toDeg(φ), toDeg(λ)]);
}
return pts;
}
function directionHint(tap, target, distanceKm) {
const dLat = target.lat - tap.lat;
const dLon = target.lon - tap.lon;
const ns = dLat > 0 ? 'nördlich' : 'südlich';
const ew = dLon > 0 ? 'östlich' : 'westlich';
return `Du musst ${Math.round(distanceKm)} km Richtung ${ns}/${ew}.`;
}
function mulberry32(seed) {
let t = seed >>> 0;
return function() {
t = (t + 0x6D2B79F5) >>> 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function shuffleSeeded(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function simpleHash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h * 16777619) >>> 0;
}
return h;
}
function getCity(id) { return CITIES_INDEX ? CITIES_INDEX.byId[id] : null; }
function getCapitals() { return CITIES_INDEX ? CITIES_INDEX.raw.filter(c => c.isCapital) : []; }
return {
loadData, init, nextOrder, evaluateQuestion, checkTap, settleOrder,
greatCirclePoints, getCity, getCapitals,
HIT_THRESHOLDS, ECON,
};
})();