fecd8bb9c9
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>
863 lines
37 KiB
JavaScript
863 lines
37 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
})();
|