Files
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (=
"bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex
/([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung
unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle
JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein
Logik-Key) -> safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:38:19 +02:00

863 lines
37 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.
/**
* Busfahrt — Engine (Phase 1 + 2)
*
* Ablauf pro Auftrag:
* 1. nextOrder(game) → setzt aktuellen Auftrag
* 2. checkTap(game, tap) → bewertet Karten-Tap, max 3 Versuche
* 3. settleOrder(game, estimateKm) → Phase-2-Wirtschaft:
* vergleicht Schätzung mit Konkurrenz, errechnet Marge,
* schiebt orderIndex und reports vor.
*
* Plattform-Hooks (vom Wrapper):
* BUSFAHRT_BASE, BUSFAHRT_FORCED_LEVEL, STUDENT_EASY,
* BUSFAHRT_TILE_PROXY, BUSFAHRT_API_BASE, BUSFAHRT_SESSION_ID
*/
window.BusfahrtEngine = (function () {
'use strict';
let CITIES_INDEX = null;
let ORDERS_POOL = null;
let EVENTS_POOL = null;
let COUNTRY_DATA = null; // Sprache, Währung, Vignette pro Land
let LANGUAGES = null; // Sprach-Quiz Content pro ISO
/** Feature-Toggles. Wrapper kann window.BUSFAHRT_FEATURES setzen — Defaults
* sind alle Features aktiv. Pro Feature kann ein Levels-Filter angegeben
* werden ('l1','l2','l3' — leer = überall aktiv). */
const DEFAULT_FEATURES = {
languageQuiz: { enabled: true, levels: ['l1','l2','l3'] },
geoHighlight: { enabled: true, levels: ['l1','l2','l3'] },
currencyExchange: { enabled: true, levels: ['l1','l2','l3'] },
vignette: { enabled: true, levels: ['l2','l3'] }
};
function features() {
const ext = (typeof window !== 'undefined' && window.BUSFAHRT_FEATURES) || {};
const out = {};
for (const key in DEFAULT_FEATURES) {
out[key] = Object.assign({}, DEFAULT_FEATURES[key], ext[key] || {});
}
return out;
}
function featureActive(key, levelId) {
const f = features()[key];
if (!f || !f.enabled) return false;
if (Array.isArray(f.levels) && f.levels.length && f.levels.indexOf(levelId) < 0) return false;
return true;
}
/** Länder, die NICHT im Schengen-Raum liegen — für Grenzkontroll-Events. */
const NON_SCHENGEN_COUNTRIES = new Set([
'Schweiz', // EFTA, aber Schengen (Achtung: Zoll-Union NEIN → Waren-Kontrolle)
'Vereinigtes Königreich', 'Großbritannien',
'Türkei',
'Serbien', 'Bosnien und Herzegowina', 'Nordmazedonien', 'Albanien', 'Montenegro', 'Kosovo',
'Ukraine', 'Moldawien', 'Belarus', 'Russland',
]);
// Schweiz Sonderfall: Schengen ja (keine Pass-Kontrolle), Zoll nein → Waren-Stichproben.
/** Alpen-Länder: Trigger für Bremsen-/Schneeketten-/Tunnel-Events. */
const ALPENPASS_COUNTRIES = new Set([
'Österreich', 'Schweiz', 'Italien', 'Slowenien', 'Liechtenstein'
]);
/** UK-Bezeichnungen (Großbritannien = Sek-I, Vereinigtes Königreich = politisch). */
const UK_NAMES = new Set(['Großbritannien', 'Vereinigtes Königreich']);
/** Strecken-spezifische Maut/Tunnel/Fähren — realistisch reduziert.
* Wird in settleOrder berechnet, in Buchhaltung als eigene Zeile ausgewiesen.
* Quellen: Eurotunnel-Preisliste 2024, Asfinag Brenner-Sondermaut,
* ATMB Mont-Blanc, Öresundbron. */
function computeCrossingFee(fromCountry, toCountry) {
const fromUk = UK_NAMES.has(fromCountry);
const toUk = UK_NAMES.has(toCountry);
if (fromUk !== toUk) { // genau eines ist UK
return {
fee: 320,
label: '🚇 Kanaltunnel / Fähre (CalaisDover, FolkestoneCalais)',
sachinfo: 'Seit Brexit 2021 ist UK kein EU-Mitglied mehr — Zoll- und Pass-Kontrollen erforderlich. Reisebus-Tarif rund 280 360 € für Eurotunnel-Shuttle (35 min Le Shuttle Freight) oder Fähre (90 min, DFDS/P&O).',
glossar: 'kanaltunnel'
};
}
const pair = new Set([fromCountry, toCountry]);
if (pair.has('Italien') && pair.has('Frankreich')) {
return {
fee: 280,
label: '🚧 Mont-Blanc-Tunnel oder Fréjus-Tunnel',
sachinfo: 'Mont-Blanc-Tunnel: 11,6 km unter Europas höchstem Berg, verbindet Chamonix (FR) und Courmayeur (IT). Bus-Maut ca. 280 €. Nach Brand 1999 (39 Tote) komplett umgebaut mit Notfall-Stollen alle 300 m. Alternative: Fréjus-Tunnel (12,9 km), ähnliches Preisniveau.',
glossar: 'mont-blanc-tunnel'
};
}
if (pair.has('Österreich') && pair.has('Italien')) {
return {
fee: 45,
label: '🛣 Brenner-Sondermaut',
sachinfo: 'Auf der Brenner-Autobahn A22/A13 wird zusätzlich zur normalen Asfinag-GO-Box Maut eine Sondermaut von ca. 45 € pro Reisebus für die Strecke InnsbruckBozen erhoben. Brenner ist der niedrigste Alpenpass (1.370 m) und Europas wichtigste Nord-Süd-Verkehrsachse.',
glossar: 'brennerkorridor'
};
}
if (pair.has('Schweden') && pair.has('Dänemark')) {
return {
fee: 250,
label: '🌉 Öresundbrücke (Malmö–Kopenhagen)',
sachinfo: '16 km lange Verbindung: 8 km Brücke + 4 km Tunnel + Insel Peberholm dazwischen. Eröffnet 2000, verbindet Skandinavien mit Festland-Europa. Bus-Maut rund 250 €. Vor 2000 nur per Fähre.',
glossar: 'oeresundbruecke'
};
}
if (pair.has('Frankreich') && pair.has('Schweiz')) {
return {
fee: 40,
label: '🛂 Schweizer Zoll-Stichprobe + Vignette-Aufschlag',
sachinfo: 'Die Schweiz ist Schengen-Mitglied (keine Pass-Kontrolle), aber NICHT in der EU-Zollunion — Waren werden stichprobenartig kontrolliert. Plus: 40 CHF Jahres-Vignette für PKW (Bus: GO-Box-äquivalent LSVA km-basiert, ca. 40 € pro Durchquerung).',
glossar: 'zoll'
};
}
// Island ↔ Festland-Europa: Norröna-Fähre Smyril Line
if (pair.has('Island')) {
return {
fee: 520,
label: '⛴ Norröna-Fähre HirtshalsTórshavnSeyðisfjörður',
sachinfo: 'Die einzige Auto-/Bus-Fähre nach Island ist die Norröna der Smyril Line. Route: Hirtshals (DK, nördlichste Spitze Jütlands) → Tórshavn (Färöer) → Seyðisfjörður (Ostküste Island). 1× pro Woche, Dauer 2,5 Tage. Bus-Tarif rund 400 600 €. Danach noch ca. 700 km quer durch Island bis Reykjavik (Westküste). 95 % aller Island-Reisenden fliegen — Bus-Anreise ist Roadtrip-Variante.',
glossar: 'norroena-faehre'
};
}
// Irland ↔ Festland: Fährverbindungen DublinCherbourg/Roscoff oder DublinHolyhead+Eurotunnel
if (pair.has('Irland')) {
// Wenn UK auf der anderen Seite: Irische See (kurze Fähre)
if (pair.has('Großbritannien') || pair.has('Vereinigtes Königreich')) {
return {
fee: 150,
label: '⛴ Irische See: DublinHolyhead (Stena Line)',
sachinfo: 'Stena Line / Irish Ferries verbinden Dublin mit Holyhead (Wales). Schnellfähre 2 h, normale Fähre 3,5 h. Bus-Tarif ca. 150 €. Verbindung in beide Richtungen mehrfach täglich.',
glossar: 'norroena-faehre'
};
}
// Sonst: Direktfähre zum Festland (Frankreich)
return {
fee: 290,
label: '⛴ Direktfähre DublinCherbourg (Irish Ferries)',
sachinfo: 'Die Direktfähre zwischen Irland und dem Festland (DublinCherbourg oder RosslareRoscoff) umgeht das UK seit Brexit. Dauer 17 20 h, Bus-Tarif rund 280 320 €. Alternative: über Holyhead+Eurotunnel — dann aber doppelte UK-Zollkontrolle.',
glossar: 'norroena-faehre'
};
}
// Norwegen: zwar Festland (Skandinavische Halbinsel), aber NICHT EU,
// sondern EFTA — Schengen ja, Zollunion nein. Stichprobenkontrolle wie CH.
if (pair.has('Norwegen') && !pair.has('Schweden') && !pair.has('Finnland') && !pair.has('Russland')) {
return {
fee: 35,
label: '🛂 Norwegischer Zoll (EFTA — kein EU-Mitglied)',
sachinfo: 'Norwegen ist EFTA-Mitglied und Teil des Schengen-Raums (keine Pass-Kontrolle), aber NICHT in der EU-Zollunion. An den Grenzen zu Schweden/Finnland (Schengen-intern) keine Kontrolle; bei Einreise aus EU per Bus über Dänemark/Schweden meist nur Stichproben. Mautstationen (Bompenger) auf vielen Strecken.',
glossar: 'zoll'
};
}
// Finnland ↔ Estland: Standard-Reisebus-Route von den Baltischen Staaten
// bzw. Mitteleuropa. Kürzeste Seeverbindung Europas (~80 km).
if (pair.has('Finnland') && pair.has('Estland')) {
return {
fee: 250,
label: '⛴ Fähre TallinnHelsinki (Tallink/Viking Line)',
sachinfo: 'Eine der meistbefahrenen Autofähren Europas. Strecke 80 km, Überfahrt 2 h, dutzende Abfahrten täglich. Tallink (Star, Megastar) und Viking Line teilen sich die Route. Standard-Weg für Reisebusse vom Baltikum nach Finnland. Bus-Tarif inkl. Lenker rund 250 €.',
glossar: 'tallink-faehre'
};
}
// Finnland ↔ Schweden: Übernacht-Fähre StockholmHelsinki bzw. Turku.
// Lange Strecke (16 h), aber für Busse aus Skandinavien/Norddeutschland Standard.
if (pair.has('Finnland') && pair.has('Schweden')) {
return {
fee: 320,
label: '⛴ Nachtfähre StockholmHelsinki (Viking Line, Tallink Silja)',
sachinfo: 'Übernacht-Fähre über die Ostsee. Strecke 400 km via Åland-Inseln, Überfahrt rund 16 h inkl. Schlaf in der Kabine. Viking Line und Tallink Silja konkurrieren. Alternative TurkuStockholm (11 h, kürzer). Für Reisebusse aus Skandinavien Standardweg nach Finnland. Bus-Tarif inkl. Kabinen rund 320 €.',
glossar: 'tallink-faehre'
};
}
return null;
}
/** Bewertungs-Schwellen Topografie (km Abweichung). */
const HIT_THRESHOLDS = {
PERFECT: 50, // 3 Punkte
OK: 200, // 1 Punkt
};
/** Wirtschafts-Konstanten. Werte mit Object {l1,l2,l3} sind pro Level,
* alles andere global. Lehrkraft kann später per Cockpit überschreiben.
*
* Level-Konzept (eingeführt 2026-05-30):
* L1 — Bus-Fahrer*in, DACH + Europa-Hauptstädte, Quiz mild, Karte mit
* Beschriftung, viele Tag-Fahrten. Einstieg.
* L2 — Reise-Manager*in, Europa erweitert, Quiz härter, mehr
* Nachtfahrten, gleiche Tap-Toleranz.
* L3 — Erfahrene:r Buslenker*in, anspruchsvolles Europa,
* 90 % Nachtfahrt (kein Navi nötig), Tap-Toleranz halbiert,
* Quiz teuer. Pool bleibt L1+L2 (kumulativ), keine neuen Daten.
* Außereuropäische Aufträge (USA, Brasilien, …) sind nach level='l4'
* umgezogen und für ein späteres Fluggesellschaft-Modul reserviert. */
const ECON = {
startBudget: 5000, // €
targetBudget: { l1: 8000, l2: 20000, l3: 35000 },
tariffPerKm: 1.20, // €/km — was wir an den Kunden verkaufen
costPerKm: 1.00, // €/km — Bus-Kosten (Treibstoff + Fahrer)
tapToleranceKm: { l1: 10, l2: 10, l3: 5 },
nightFirst: { l1: 5, l2: 3, l3: 1 }, // 0-basiert: L3 ab Auftrag 2
nightChanceAfter: { l1: 0.20, l2: 0.30, l3: 0.90 },
homeCity: 'wien',
tourLength: { l1: 16, l2: 20, l3: 24 },
// Zwischenfragen-Wirtschaft (Quiz vor jedem Karten-Tap):
// Richtige Antwort beim 1. Versuch → quizBonusFirstTryEur Bonus.
// Jede falsche Antwort → quizPenaltyPerWrongEur Abzug.
// Endlos probieren bleibt möglich, wird aber teuer — wie ein Verfahren.
quizBonusFirstTryEur: { l1: 50, l2: 75, l3: 100 },
quizPenaltyPerWrongEur: { l1: 50, l2: 75, l3: 100 },
};
/** Helper: liest ein ECON-Feld auf, das pro Level Objekt-strukturiert sein
* kann (z.B. {l1:10, l2:10, l3:5}) — fällt auf den Skalarwert oder L1
* zurück, wenn das Level fehlt. So bleiben Engine-Callsites kurz. */
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;
}
/** Lädt cities.json + orders.json + events.json + country-data.json + languages.json. */
async function loadData() {
if (CITIES_INDEX && ORDERS_POOL && EVENTS_POOL && COUNTRY_DATA && LANGUAGES) return;
const base = (window.BUSFAHRT_BASE || './') + 'assets/data/';
const [cRes, oRes, eRes, ctRes, lgRes] = await Promise.all([
fetch(base + 'cities.json'),
fetch(base + 'orders.json'),
fetch(base + 'events.json').catch(() => null),
fetch(base + 'country-data.json').catch(() => null),
fetch(base + 'languages.json').catch(() => null),
]);
if (!cRes.ok) throw new Error('cities.json HTTP ' + cRes.status);
if (!oRes.ok) throw new Error('orders.json HTTP ' + oRes.status);
let cJson, oJson;
try { cJson = await cRes.json(); }
catch (e) { throw new Error('cities.json: ungültiges JSON (' + (e.message||e) + ')'); }
try { oJson = await oRes.json(); }
catch (e) { throw new Error('orders.json: ungültiges JSON (' + (e.message||e) + ')'); }
CITIES_INDEX = {
raw: cJson,
byId: Object.fromEntries(cJson.map(c => [c.id, c])),
};
ORDERS_POOL = oJson;
EVENTS_POOL = (eRes && eRes.ok) ? await eRes.json().catch(() => []) : [];
COUNTRY_DATA = (ctRes && ctRes.ok) ? await ctRes.json().catch(() => ({})) : {};
LANGUAGES = (lgRes && lgRes.ok) ? await lgRes.json().catch(() => ({})) : {};
}
/** Liest Länder-Metadaten (Sprache, Währung, Vignette) für eine City. */
function countryMeta(city) {
if (!city || !COUNTRY_DATA) return null;
return COUNTRY_DATA[city.country] || null;
}
function isEuroCountry(city) {
const m = countryMeta(city);
return m && m.currency === 'EUR';
}
/** Zieht einen Sprach-Quiz-Eintrag zufällig für die Sprache der Stadt.
* Filtert die Sätze deterministisch via Auftrags-Seed, damit der gleiche
* Auftrag in einer Tour reproduzierbar denselben Satz zeigt. */
function pickLanguageQuiz(city, rng) {
const m = countryMeta(city);
if (!m || !LANGUAGES) return null;
const pool = LANGUAGES[m.lang];
if (!Array.isArray(pool) || !pool.length) return null;
const idx = Math.floor((rng ? rng() : Math.random()) * pool.length);
const item = pool[idx];
if (!item || !item.de || !Array.isArray(item.distractors)) return null;
// Distraktoren mischen + richtige Antwort an zufällige Stelle
const choices = item.distractors.slice();
const insertAt = Math.floor((rng ? rng() : Math.random()) * (choices.length + 1));
choices.splice(insertAt, 0, item.de);
return {
kind: 'language',
lang: m.lang,
country: city.country,
sentence: item.text,
choices: choices,
correctIndex: insertAt,
attempts: [],
resolved: false,
wasFirstTryCorrect: false,
bonusEur: 0,
penaltyEur: 0
};
}
/** Initialisiert eine neue Tour (zieht zufällig aus dem Pool).
* Markiert Nachtfahrten: Auftrag 6 (Index 5) ist immer Nacht,
* ab Auftrag 7 jede mit `ECON.nightChanceAfter`-Wahrscheinlichkeit. */
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: vermeide aufeinanderfolgende Aufträge mit derselben
// Zielstadt — und insbesondere keine Tour von Heimat-Garage zu Heimat-Garage.
// Tour-Auswahl mit DREIFACHEM Filter, von streng nach locker:
// 1) Stadt noch GAR NICHT in der Tour besucht (ideal: jeder Ort einmal)
// 2) Stadt zumindest nicht direkter Vorgänger (fallback bei kleinem Pool)
// 3) irgendetwas (Notfall, sollte praktisch nie greifen)
// Hintergrund: Pro Stadt gibt es mehrere Auftragstypen (A/B/C/D) mit
// derselben cityId. Der alte „nicht-prev"-Filter konnte Lissabon → Porto
// → Lissabon erlauben (zwei unterschiedliche Lissabon-Aufträge mit Porto
// dazwischen). User-Erlebnis: „Ich war doch gerade da."
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,
lastCity: ECON.homeCity,
reports: [],
};
}
/** Auftrag nach Level filtern (kumulativ). L4 ist explizit ausgeschlossen
* (= reserviert für späteres Fluggesellschaft-Modul) — sonst würde L3 die
* außereuropäischen Aufträge einsammeln, die für den Bus unrealistisch sind. */
function filterPoolByLevel(levelId) {
if (!ORDERS_POOL) return [];
if (levelId === 'l1') return ORDERS_POOL.filter(o => o.level === 'l1');
if (levelId === 'l2') return ORDERS_POOL.filter(o => o.level === 'l1' || o.level === 'l2');
// L3 = L1 + L2 + L3-eigene (aktuell keine — Tour wird trotzdem aus dem
// erweiterten Pool gezogen, mit höherer Schwierigkeit via ECON-Stufen).
return ORDERS_POOL.filter(o => o.level === 'l1' || o.level === 'l2' || o.level === 'l3');
}
/** Nächster Auftrag aus der Tour.
* Setzt auch `game.currentQuestion` mit den Zwischenfragen-Daten:
* - Typ B: nutzt order.choices (Hauptstadt-Frage, schon im Auftrag enthalten)
* → kind: 'capital', Frage steht im order.prompt selbst
* - sonst: nutzt city.question (Generator-Output „In welchem Land liegt X?")
* → kind: 'country'
* attempts ist die Liste der bisher gewählten Indizes für DIESE Frage. */
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) {
// Typ B trägt die Frage im Auftrag selbst (Hauptstadt von X?).
// choices sind cityId-Strings; correctIndex = Index von order.cityId.
const correctIdx = order.choices.indexOf(order.cityId);
if (correctIdx < 0) {
// Daten-Inkonsistenz: cityId ist NICHT in choices enthalten.
// Lautloses Fallback auf 0 würde die erste Antwort zur „richtigen"
// machen — Schüler:in kommt nicht drauf. Lieber laut werden.
console.error('[busfahrt] Datenfehler: order.cityId nicht in order.choices', order);
}
q = {
kind: 'capital',
prompt: order.prompt,
choices: order.choices, // cityId-Strings
choiceLabels: order.choices.map(cid => {
const c = CITIES_INDEX && CITIES_INDEX.byId[cid];
return c ? c.title : cid;
}),
correctIndex: correctIdx, // -1 wenn Datenfehler → keine Antwort ist „richtig"
attempts: [],
resolved: false,
wasFirstTryCorrect: false,
bonusEur: 0,
penaltyEur: 0,
};
} else {
// A/C/D: Stadt-Frage aus cities.json (vom Generator gepflegt).
const city = CITIES_INDEX && CITIES_INDEX.byId[order.cityId];
const cq = city && city.question;
if (cq && Array.isArray(cq.choices)) {
q = {
kind: 'country',
prompt: cq.prompt,
choices: cq.choices.slice(), // Klartext-Länder
choiceLabels: cq.choices.slice(),
correctIndex: cq.correctIndex,
attempts: [],
resolved: false,
wasFirstTryCorrect: false,
bonusEur: 0,
penaltyEur: 0,
};
}
}
game.currentQuestion = q;
// Zusätzliche optionale Phasen für DIESEN Auftrag vorbereiten.
// Sie laufen NACH dem Quiz, vor der Wirtschaftsabrechnung.
const city = CITIES_INDEX && CITIES_INDEX.byId[order.cityId];
game.currentExtras = buildExtras(game, city);
return order;
}
/** Baut die optionalen Bonus-Phasen für einen Auftrag — abhängig von
* aktivierten Features, Level und Land der Zielstadt. */
function buildExtras(game, city) {
const lvl = game.levelId;
const extras = { queue: [], current: null };
if (!city) return extras;
const meta = countryMeta(city);
// Währungs-Wechsel: nur wenn Ziel-Währung != EUR
if (featureActive('currencyExchange', lvl) && meta && meta.currency && meta.currency !== 'EUR') {
extras.queue.push({
kind: 'currency',
country: city.country,
currency: meta.currency,
rate: meta.rate,
feePct: 3, // 3 % Wechselgebühr
resolved: false
});
}
// Vignette: nur wenn vignette > 0 (0 = mautfrei, -1 = kein Vignetten-System)
if (featureActive('vignette', lvl) && meta && meta.vignette > 0) {
extras.queue.push({
kind: 'vignette',
country: city.country,
priceLocal: meta.vignette,
currency: meta.currency,
priceEur: meta.rate ? Math.round(meta.vignette / meta.rate) : meta.vignette,
resolved: false
});
}
// Sprach-Quiz: nur wenn andere Sprache als Deutsch und Pool vorhanden
if (featureActive('languageQuiz', lvl) && meta && meta.lang && meta.lang !== 'de') {
const lq = pickLanguageQuiz(city);
if (lq) extras.queue.push(lq);
}
return extras;
}
/** Liefert die nächste anstehende Bonus-Phase oder null, wenn alle
* erledigt sind. UI-Loop fragt das nach dem Quiz / vor settleOrder ab. */
function nextExtra(game) {
const ex = game.currentExtras;
if (!ex) return null;
if (ex.current && !ex.current.resolved) return ex.current;
while (ex.queue.length) {
const next = ex.queue.shift();
if (!next.resolved) { ex.current = next; return next; }
}
ex.current = null;
return null;
}
/** Wertet die aktuelle Extra-Phase (currency, vignette, language). */
function resolveExtra(game, payload) {
const ex = game.currentExtras && game.currentExtras.current;
if (!ex) return null;
const lvl = game.levelId;
let deltaEur = 0;
if (ex.kind === 'currency') {
// payload: { accept: true } — Spieler bestätigt, Gebühr wird abgezogen.
// Realistisch: Bank-Spread + kleine Wechselgebühr = ca. 3 % auf den
// gewechselten Betrag (Wechselstube am Flughafen wäre 5-10 %; Online-
// Karten unter 1 %). Wir nehmen 3 % von 200 € = 6 €.
const CHANGE_AMOUNT_EUR = 200;
const fee = Math.round(CHANGE_AMOUNT_EUR * (ex.feePct / 100));
deltaEur = -fee;
game.budget += deltaEur;
ex.changedEur = CHANGE_AMOUNT_EUR;
ex.feeEur = fee;
ex.resolved = true;
} else if (ex.kind === 'vignette') {
// payload: { buy: true|false } — wenn falsch entschieden → Strafe
// Pauschal vereinfacht: kaufen kostet ex.priceEur, nicht kaufen riskiert 100 € Strafe
const buy = payload && payload.buy;
if (buy) {
deltaEur = -ex.priceEur;
ex.outcome = 'bought';
} else {
// Bei vignettenpflichtigem Land verlieren wir 100 € Risiko-Strafe
deltaEur = -100;
ex.outcome = 'fined';
}
game.budget += deltaEur;
ex.deltaEur = deltaEur;
ex.resolved = true;
} else if (ex.kind === 'language') {
const pickedIndex = payload && payload.pickedIndex;
if (typeof pickedIndex !== 'number' || pickedIndex < 0 || pickedIndex >= ex.choices.length) return null;
ex.attempts.push(pickedIndex);
const correct = pickedIndex === ex.correctIndex;
const bonusValue = 20; // kleiner als Geo-Quiz (50/75/100)
const penaltyValue = 10; // ebenfalls milder
if (correct) {
if (ex.attempts.length === 1) {
ex.wasFirstTryCorrect = true;
ex.bonusEur = bonusValue;
deltaEur = bonusValue;
}
ex.resolved = true;
} else {
ex.penaltyEur += penaltyValue;
deltaEur = -penaltyValue;
if (ex.attempts.length >= 2) {
// Nach 2 Fehlversuchen Phase beenden — kein Endlos-Frust
ex.resolved = true;
}
}
game.budget += deltaEur;
}
return { kind: ex.kind, resolved: ex.resolved, deltaEur: deltaEur, state: ex };
}
/** Wertet eine Antwort auf die aktuelle Zwischenfrage. Bucht Geld sofort.
* @param {object} game
* @param {number} pickedIndex Index aus q.choices, den der User angeklickt hat
* @returns {{correct:boolean, resolved:boolean, deltaEur:number,
* bonusEur:number, penaltyEur:number, attempts:number[]}}
* oder null, wenn keine Frage aktiv. */
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;
// Datenfehler-Schutz: wenn der Quiz-Datensatz keinen korrekten Index hat
// (siehe nextOrder: correctIndex = -1 bei kaputter B-Order), nehmen wir
// die erste Antwort als „akzeptiert", ohne Bonus, ohne Penalty — sonst
// bleibt der Spieler in der Endlosschleife stecken.
if (q.correctIndex < 0 || q.correctIndex >= q.choices.length) {
q.attempts.push(pickedIndex);
q.resolved = true;
return { correct: false, resolved: true, deltaEur: 0, bonusEur: 0, penaltyEur: q.penaltyEur, attempts: q.attempts.slice() };
}
q.attempts.push(pickedIndex);
const correct = pickedIndex === q.correctIndex;
const bonusValue = econ('quizBonusFirstTryEur', game.levelId);
const penaltyValue = econ('quizPenaltyPerWrongEur', game.levelId);
let deltaEur = 0;
if (correct) {
if (q.attempts.length === 1) {
q.wasFirstTryCorrect = true;
q.bonusEur = bonusValue;
deltaEur = q.bonusEur;
}
q.resolved = true;
} else {
q.penaltyEur += penaltyValue;
deltaEur = -penaltyValue;
}
game.budget += deltaEur;
return {
correct,
resolved: q.resolved,
deltaEur,
bonusEur: q.bonusEur,
penaltyEur: q.penaltyEur,
attempts: q.attempts.slice(),
};
}
/**
* Bewertet einen Karten-Tap. attempt-Liste wird gefüllt.
* Schiebt den orderIndex NICHT — settleOrder macht das.
* @returns {{tap, distanceKm, score, hint, resolved}}
*/
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 — Marge ergibt sich aus dem Umweg, den der Bus
* durch das Daneben-Tippen fährt. Tipp = Zwischenstation.
*
* Wichtig: Verkaufs- und Bus-Kosten-Strecke müssen aus derselben
* Quelle kommen, sonst entsteht ein künstlicher Umweg durch
* Auto-Strecken-Faktor (z.B. Berge/Fjorde). Das Frontend liefert
* deshalb idealerweise BEIDE OSRM-Routen:
* directDistanceKm = OSRM von Start zu Ziel direkt (Verkaufs-Strecke)
* actualDistanceKm = OSRM von Start über Tap zu Ziel (gefahren)
* Fallback (ohne OSRM): Luftlinien — beide aus haversine.
*
* @param {object} game
* @param {object} [opts]
* @param {number} [opts.directDistanceKm] echte Auto-Route fromCity → toCity
* @param {number} [opts.actualDistanceKm] echte Auto-Route fromCity → tap → toCity
*/
function settleOrder(game, opts) {
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;
// Fallback-Strecken (Luftlinie), wenn OSRM nicht antwortet.
const directHaversine = haversineKm(fromCity.lat, fromCity.lon, toCity.lat, toCity.lon);
const startToTapKm = haversineKm(fromCity.lat, fromCity.lon, tap.lat, tap.lon);
const tapToTargetKm = haversineKm(tap.lat, tap.lon, toCity.lat, toCity.lon);
const viaTapHaversine = startToTapKm + tapToTargetKm;
const realDistanceKm = (opts && opts.directDistanceKm) || directHaversine;
let actualDistanceKm = (opts && opts.actualDistanceKm) || viaTapHaversine;
// Kulanz: Tap unter Toleranz-Schwelle → kein Umweg, Bus fährt direkt
const withinTolerance = lastAttempt.distanceKm < econ('tapToleranceKm', game.levelId);
if (withinTolerance) actualDistanceKm = realDistanceKm;
const detourKm = Math.max(0, actualDistanceKm - realDistanceKm);
const isShip = !!(opts && opts.isShip);
const tariff = order.tariffPerKm || ECON.tariffPerKm;
const playerPrice = realDistanceKm * tariff;
const busCost = actualDistanceKm * ECON.costPerKm;
// Strecken-spezifische Maut/Tunnel/Fähren — realistische Zusatzkosten,
// die der GO-Box-Pauschale (in costPerKm) nicht enthalten sind.
const crossing = computeCrossingFee(fromCity.country, toCity.country);
const crossingFee = crossing ? crossing.fee : 0;
const baseMargin = playerPrice - busCost - crossingFee;
const timeBonus = Math.max(0, Math.round((opts && opts.timeBonusEur) || 0));
const margin = Math.round(baseMargin + timeBonus);
const finalScore = lastAttempt.score;
game.score += finalScore;
game.budget += margin;
game.earnings += margin;
// Zwischenfragen-Bilanz (Quiz vor dem Karten-Tap): Bonus/Strafe wurde
// bereits via evaluateQuestion gebucht. Wir hängen die Werte trotzdem
// an den Report, damit Buchhaltung + Endbilanz das sauber ausweisen.
const q = game.currentQuestion;
const quizBonus = q ? (q.bonusEur || 0) : 0;
const quizPenalty = q ? (q.penaltyEur || 0) : 0;
const quizFirstTry = !!(q && q.wasFirstTryCorrect);
const quizAttemptsCount = q ? q.attempts.length : 0;
const report = {
orderId: order.id,
cityId: order.cityId,
fromCityId: game.lastCity,
realDistanceKm,
actualDistanceKm,
detourKm,
tapDistanceKm: lastAttempt.distanceKm,
withinTolerance,
isShip,
tariff,
playerPrice: Math.round(playerPrice),
busCost: Math.round(busCost),
crossingFee: crossingFee,
crossingLabel: crossing ? crossing.label : null,
crossingSachinfo: crossing ? crossing.sachinfo : null,
crossingGlossar: crossing ? crossing.glossar : null,
baseMargin: Math.round(baseMargin),
timeBonus,
timeSec: (opts && opts.timeSec) || null,
quizBonus,
quizPenalty,
quizFirstTry,
quizAttemptsCount,
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;
}
/** Wählt zufällig ein Ereignis für den NÄCHSTEN Auftrag aus,
* passend zur Strecke (Schengen-Außengrenze? Alpen-Pass? Winter?).
* Wird vom UI nach `settleOrder` aufgerufen, BEVOR `loadNextOrder`.
* @returns {object|null} Ereignis-Objekt (aus events.json) oder null. */
function pickEventForOrder(game) {
if (!EVENTS_POOL || !EVENTS_POOL.length) return null;
if (game.orderIndex >= game.tourOrders.length) return null;
const order = game.tourOrders[game.orderIndex];
if (!order) return null;
const fromCity = CITIES_INDEX.byId[game.lastCity];
const toCity = CITIES_INDEX.byId[order.cityId];
if (!fromCity || !toCity) return null;
const ctx = buildEventContext(game, fromCity, toCity);
const eligible = EVENTS_POOL.filter(ev => isEventEligible(ev, ctx));
// Pro Event eigene Probability ziehen — höchstens 1 Event pro Auftrag.
const triggered = eligible.filter(ev => Math.random() < ((ev.trigger && ev.trigger.probability) || 0));
if (!triggered.length) return null;
return triggered[Math.floor(Math.random() * triggered.length)];
}
function buildEventContext(game, fromCity, toCity) {
const month = new Date().getMonth() + 1; // 1..12
const isWinter = (month >= 11 || month <= 3);
const passingAlpen = ALPENPASS_COUNTRIES.has(fromCity.country) ||
ALPENPASS_COUNTRIES.has(toCity.country);
const crossesNonSchengen = NON_SCHENGEN_COUNTRIES.has(fromCity.country) ||
NON_SCHENGEN_COUNTRIES.has(toCity.country);
return {
orderIndex: game.orderIndex,
levelId: game.levelId,
fromCountry: fromCity.country,
toCountry: toCity.country,
crossesNonSchengen,
passingAlpen,
isWinter,
isSummer: !isWinter,
};
}
function isEventEligible(ev, ctx) {
const t = ev.trigger || {};
if (typeof t.minOrderIndex === 'number' && ctx.orderIndex < t.minOrderIndex) return false;
if (Array.isArray(t.levels) && t.levels.length && !t.levels.includes(ctx.levelId)) return false;
if (t.requiresCountriesOutsideSchengen && !ctx.crossesNonSchengen) return false;
if (t.requiresAlpenpass && !ctx.passingAlpen) return false;
if (t.requiresWinter && !ctx.isWinter) return false;
if (Array.isArray(t.requiresCountries) && t.requiresCountries.length) {
const lower = t.requiresCountries.map(s => String(s).toLowerCase());
const fc = ctx.fromCountry.toLowerCase();
const tc = ctx.toCountry.toLowerCase();
if (!lower.includes(fc) && !lower.includes(tc)) return false;
}
return true;
}
/** Wendet die Wahl der Spieler*in auf ein Ereignis an — bucht Geld direkt
* aufs Budget und protokolliert im game.eventLog. */
function applyEventChoice(game, eventId, choiceIndex) {
const ev = (EVENTS_POOL || []).find(e => e.id === eventId);
if (!ev) return null;
const choice = ev.choices[choiceIndex];
if (!choice) return null;
const deltaEur = choice.deltaEur || 0;
const deltaMin = choice.deltaMin || 0;
game.budget += deltaEur;
if (!Array.isArray(game.eventLog)) game.eventLog = [];
game.eventLog.push({
eventId: ev.id, eventTitle: ev.title, choiceIndex,
deltaEur, deltaMin, atOrderIndex: game.orderIndex,
});
return {
deltaEur, deltaMin,
feedback: choice.feedback || '',
penaltyHint: choice.penaltyHint || '',
};
}
/** Haversine-Distanz in km. */
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));
}
/** Richtungs-Hinweis bei Fehlversuch. */
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';
const km = Math.round(distanceKm);
return `Du musst ${km} km in Richtung ${ns}/${ew}.`;
}
/** Mulberry32-RNG (deterministisch, gleicher Seed = gleiche Tour). */
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;
};
}
/** Fisher-Yates mit gegebenem RNG. */
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 getCity(cityId) {
return CITIES_INDEX ? CITIES_INDEX.byId[cityId] : null;
}
function getOrdersForLevel(levelId) {
return filterPoolByLevel(levelId);
}
function findOrderById(orderId) {
return ORDERS_POOL ? ORDERS_POOL.find(o => o.id === orderId) : null;
}
/** Liste aller Hauptstädte (für Bus-Hintergrund-Animation). */
function getCapitals() {
if (!CITIES_INDEX) return [];
return CITIES_INDEX.raw.filter(c => c.isCapital);
}
return {
loadData, init, nextOrder, evaluateQuestion, checkTap, settleOrder,
pickEventForOrder, applyEventChoice,
getCity, getOrdersForLevel, findOrderById, getCapitals,
// Neue Bonus-Phasen-API (Sprache, Währung, Vignette, Geo-Highlight):
nextExtra, resolveExtra, featureActive, countryMeta,
HIT_THRESHOLDS, ECON,
};
})();