/** * 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 (Calais–Dover, Folkestone–Calais)', 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 Innsbruck–Bozen 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 Hirtshals–Tórshavn–Seyð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 Dublin–Cherbourg/Roscoff oder Dublin–Holyhead+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: Dublin–Holyhead (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 Dublin–Cherbourg (Irish Ferries)', sachinfo: 'Die Direktfähre zwischen Irland und dem Festland (Dublin–Cherbourg oder Rosslare–Roscoff) 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 Tallinn–Helsinki (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 Stockholm–Helsinki 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 Stockholm–Helsinki (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 Turku–Stockholm (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, }; })();