1e51ef7def
- 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>
389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
})();
|